Skip to content

Commit f2795da

Browse files
pr comments
1 parent aa275fa commit f2795da

File tree

2 files changed

+210
-31
lines changed

2 files changed

+210
-31
lines changed

docs/design/managed_identity_capabilities_devex.md

Lines changed: 67 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,107 @@
1-
# Microsoft.Identity.Web – Claims & Client Capabilities Mini‑Spec (ManagedIdentity)
1+
# Microsoft.Identity.Web – Continuous Access Evaluation (CAE) for Managed Identity
22

3-
## Why ClientCapabilities (cp1)?
3+
## Why Continuous Access Evaluation?
44

5-
Adding the `cp1` client‑capability tells Microsoft Entra ID your service can handle Continuous Access Evaluation (CAE) claims challenges. Tokens will include an extra xms_cc claim, allowing near‑realtime revocation.
5+
Continuous Access Evaluation (CAE) lets Microsoft Entra ID revoke tokens or demand extra claims almost immediately when risk changes (user disabled, password reset, network change, policy update, etc.).
66

7-
## Overview
7+
A workload opts-in by sending the client-capability **`cp1`** when acquiring tokens. Entra then includes an **`xms_cc`** claim in the token so downstream Microsoft services know the caller can handle claims challenges.
88

9-
cp1 signals to Microsoft Entra ID that a workload identity can handle Continuous Access Evaluation (CAE) claims challenges. When a token includes the extra xms_cc claim, Azure can revoke the token (or demand additional claims) in near‑realtime.
9+
## What this spec adds to **Microsoft.Identity.Web**
1010

11-
This spec adds declarative cp1 and claims challenge auto‑handling to Managed Identity flows in Microsoft.Identity.Web (Id.Web). The goal is zero‑touch for most developers: a single configuration knob at startup, automatic 401/claims recovery at runtime.
11+
* **Declarative opt-in** – one configuration knob (`ClientCapabilities: [ "cp1" ]`).
12+
* **Transparent 401 recovery** – when a downstream Microsoft API responds with a 401+claims challenge, Id.Web automatically:
13+
1. extracts the claims body;
14+
2. bypasses its token cache;
15+
3. requests a fresh token that satisfies the claims;
16+
4. retries the HTTP call **once**.
1217

13-
## Typical Flow   (MI + Downstream API)
18+
The goal is **zero-touch** for most developers.
1419

15-
- MI token request – Id.Web sends xms_cc=cp1 at app creation.
16-
- Access granted – Downstream API returns 200.
17-
- Policy change – Token later revoked; next call to Downstream API gets 401 + claims.
18-
- Transparent recovery – Id.Web detects challenge ⇒ forwards claims body, acquires fresh token, retries once.
20+
## Typical Flow (Managed Identity → Downstream API)
1921

20-
_app developer does not need to handle claims, downstream api takes care of this_
22+
```text
23+
1. Id.Web → MSI endpoint : GET /token?resource=...&xms_cc=cp1 ──▶
24+
2. MSI → ESTS : request includes cp1 ──▶
25+
3. ESTS → Id.Web : access_token (xms_cc claim present) ◀──
26+
4. Id.Web → Downstream API : GET /resource ⟶ 200 OK │
27+
5. Policy change occurs │
28+
6. Id.Web → Downstream API : GET /resource ⟶ 401 + claims payload │
29+
7. Id.Web handles challenge (steps 1-4 again, bypassing msal cache) ──▶
30+
```
2131

2232
## Design Goals
2333

2434
| # | Goal | Success Metric |
2535
|-----|--------------------------------------------------------------|----------------------------------------------------------|
26-
| G1 | Transparent CAE retry with cache-bypass on 401 claims challenge. | Secret or Graph call recovers without developer code. |
36+
| G1 | Transparent CAE retry with cache-bypass on 401 claims challenge. | Downstream API call recovers without developer code. |
2737
| G2 | Declarative client capabilities via configuration. | Single place to add `cp1`; all MI calls include it. |
2838

29-
## 5 · Public API Impact
39+
## Public API Impact
3040

3141
no changes to the public api.
3242

33-
## Configuration
43+
## Configuration Example
3444

3545
```
3646
{
3747
"AzureAd": {
3848
"ClientCapabilities": [ "cp1" ]
3949
},
4050
41-
// Resource entry (example)
42-
"AzureKeyVault": {
43-
"BaseUrl": "https://<your‑vault>.vault.azure.net/",
44-
"RelativePath": "secrets/<secret-name>?api-version=7.4",
51+
// Example downstream API definition (Contoso Storage API)
52+
"ContosoStorage": {
53+
"BaseUrl": "https://storage.contoso.com/",
54+
"RelativePath": "data/records?api-version=1.0",
4555
"RequestAppToken": true,
46-
"Scopes": [ "https://vault.azure.net/.default" ],
47-
56+
"Scopes": [ "https://storage.contoso.com/.default" ],
4857
"AcquireTokenOptions": {
4958
"ManagedIdentity": {
50-
"UserAssignedClientId": "<user-assigned-mi-client-id>"
59+
// optional – omit for system-assigned MI
60+
"UserAssignedClientId": "<client-id>"
5161
}
5262
}
5363
}
5464
}
5565
```
5666

57-
## Code
67+
> **Note** : The same configuration block works in *appsettings.json* or can be supplied programmatically.
68+
69+
70+
## Code Snippets
71+
72+
### Registering & Calling a Downstream API
73+
74+
```csharp
75+
// 1 – set up the TokenAcquirerFactory (test-helper shown for brevity)
76+
var factory = TokenAcquirerFactory.GetDefaultInstance();
77+
78+
// 2 – register the downstream API using section "ContosoStorage"
79+
factory.Services.AddDownstreamApi("ContosoStorage",
80+
factory.Configuration.GetSection("ContosoStorage"));
5881

59-
```cs
60-
// register the downstream API using the "AzureKeyVault" section ────
61-
factory.Services.AddDownstreamApi("AzureKeyVault",
62-
factory.Configuration.GetSection("AzureKeyVault"));
6382
IServiceProvider sp = factory.Build();
64-
IDownstreamApi api = sp.GetRequiredService<IDownstreamApi>();
83+
IDownstreamApi api = sp.GetRequiredService<IDownstreamApi>();
84+
85+
// 3 – call the API (Id.Web handles CAE automatically)
86+
HttpResponseMessage resp = await api.CallApiForAppAsync("ContosoStorage");
87+
```
6588

66-
// ── 3. call the vault (app-token path) ──────────────────────────────────
67-
HttpResponseMessage response = await api.CallApiForAppAsync("AzureKeyVault");
89+
### Using **IAuthorizationHeaderProvider** (advanced)
90+
91+
`IAuthorizationHeaderProvider` is fully supported with Managed Identity. Claims challenges propagate the same way:
92+
93+
```csharp
94+
var headerProvider = sp.GetRequiredService<IAuthorizationHeaderProvider>();
95+
string header = await headerProvider.CreateAuthorizationHeaderForAppAsync(
96+
scope: "https://storage.contoso.com/.default",
97+
options: new AuthorizationHeaderProviderOptions
98+
{
99+
AcquireTokenOptions = new AcquireTokenOptions
100+
{
101+
ManagedIdentity = new ManagedIdentityOptions(), // system-assigned MI
102+
Claims = claimsChallengeJson // when retrying after 401
103+
}
104+
});
68105
```
69106

70107
## Telemetry

tests/Microsoft.Identity.Web.Test/ManagedIdentityCaeTests.cs

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@
99
using Microsoft.Identity.Web.TestOnly;
1010
using Xunit;
1111
using Microsoft.Identity.Web.Test;
12+
using NSubstitute;
13+
using System.Collections.Generic;
14+
using System.Net.Http;
15+
using System.Net;
16+
using System;
17+
using System.Text;
18+
using System.Security.Claims;
19+
using System.Threading;
20+
using Microsoft.IdentityModel.Tokens;
1221

1322
namespace Microsoft.Identity.Web.Tests.Certificateless
1423
{
@@ -21,6 +30,14 @@ public class ManagedIdentityTests
2130
private const string CaeClaims =
2231
@"{""access_token"":{""nbf"":{""essential"":true,""value"":""1702682181""}}}";
2332

33+
private const string Downstream401Service = "Downstream401";
34+
private const string FirstToken = "mocked.access.token-1";
35+
private const string SecondToken = "mocked.access.token-2";
36+
private const string VaultBaseUrl = "https://my-vault.vault.azure.net/";
37+
private const string SecretPath = "secrets/mySecret";
38+
39+
private sealed record VaultSecret(string Value);
40+
2441
[Fact]
2542
public async Task ManagedIdentity_ReturnsBearerHeader()
2643
{
@@ -32,13 +49,14 @@ public async Task ManagedIdentity_ReturnsBearerHeader()
3249
mockHttp.AddMockHandler(
3350
MockHttpCreator.CreateMsiTokenHandler(accessToken: MockToken));
3451

52+
// Add the mock handler to the DI container
3553
factory.Services.AddSingleton<IManagedIdentityHttpClientFactory>(
3654
_ => new TestManagedIdentityHttpFactory(mockHttp));
3755

3856
IAuthorizationHeaderProvider headerProvider = factory.Build()
3957
.GetRequiredService<IAuthorizationHeaderProvider>();
4058

41-
// basic mi flow
59+
// basic mi flow where we get a token
4260
string header = await headerProvider.CreateAuthorizationHeaderForAppAsync(
4361
Scope,
4462
new AuthorizationHeaderProviderOptions
@@ -52,6 +70,56 @@ public async Task ManagedIdentity_ReturnsBearerHeader()
5270
Assert.Equal($"Bearer {MockToken}", header);
5371
}
5472

73+
[Fact]
74+
public async Task ManagedIdentity_WithClaims_HeaderBypassesCache()
75+
{
76+
// Arrange
77+
TokenAcquirerFactoryTesting.ResetTokenAcquirerFactoryInTest();
78+
var tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance();
79+
var mockedHttp = new MockHttpClientFactory();
80+
81+
// token-1 will be cached, token-2 should be returned when claims force a bypass
82+
mockedHttp.AddMockHandler(MockHttpCreator.CreateMsiTokenHandler("token1"));
83+
mockedHttp.AddMockHandler(MockHttpCreator.CreateMsiTokenHandler("token2"));
84+
85+
tokenAcquirerFactory.Services.AddSingleton<IManagedIdentityHttpClientFactory>(
86+
_ => new TestManagedIdentityHttpFactory(mockedHttp));
87+
88+
var headerProvider = tokenAcquirerFactory.Build()
89+
.GetRequiredService<IAuthorizationHeaderProvider>();
90+
91+
// Initial call – no claims, token cached
92+
string header1 = await headerProvider.CreateAuthorizationHeaderForAppAsync(
93+
Scope,
94+
new AuthorizationHeaderProviderOptions
95+
{
96+
AcquireTokenOptions = new AcquireTokenOptions
97+
{
98+
ManagedIdentity = new ManagedIdentityOptions
99+
{
100+
UserAssignedClientId = "UamiClientId2"
101+
}
102+
}
103+
});
104+
Assert.Equal("Bearer token1", header1);
105+
106+
// Same UAMI with CAE claims – should bypass cache
107+
string header2 = await headerProvider.CreateAuthorizationHeaderForAppAsync(
108+
Scope,
109+
new AuthorizationHeaderProviderOptions
110+
{
111+
AcquireTokenOptions = new AcquireTokenOptions
112+
{
113+
ManagedIdentity = new ManagedIdentityOptions
114+
{
115+
UserAssignedClientId = "UamiClientId2"
116+
},
117+
Claims = CaeClaims
118+
}
119+
});
120+
Assert.Equal("Bearer token2", header2);
121+
}
122+
55123
[Fact]
56124
public async Task UserAssigned_MI_Caching_and_Claims()
57125
{
@@ -143,5 +211,79 @@ public async Task SystemAssigned_MSI_Forwards_ClientCapabilities_InQuery()
143211
//string query = captureHandler.ActualRequestMessage!.RequestUri!.Query;
144212
//Assert.Contains("xms_cc=cp1%2Ccp2", query, StringComparison.OrdinalIgnoreCase);
145213
}
214+
215+
[Fact]
216+
public async Task DownstreamApi_401Claims_TriggersSingleRetry_AndSucceeds()
217+
{
218+
// challenge JSON
219+
string challengeB64 = Base64UrlEncoder.Encode(
220+
Encoding.UTF8.GetBytes(CaeClaims));
221+
222+
// authProvider mock
223+
var authProvider = Substitute.For<IAuthorizationHeaderProvider>();
224+
DownstreamApiOptions? capturedOpts = null;
225+
226+
authProvider.CreateAuthorizationHeaderAsync(
227+
Arg.Any<IEnumerable<string>>(),
228+
Arg.Do<DownstreamApiOptions>(o => capturedOpts = o),
229+
Arg.Any<ClaimsPrincipal?>(),
230+
Arg.Any<CancellationToken>())
231+
.Returns(ci => $"Bearer {FirstToken}",
232+
ci => $"Bearer {SecondToken}");
233+
234+
// queue handler: 401 w/ claims ? 200 OK
235+
// Id Web will single retry the request on 401
236+
var queue = new QueueHttpMessageHandler();
237+
238+
// 401 response with claims
239+
var r401 = new HttpResponseMessage(HttpStatusCode.Unauthorized);
240+
241+
// add the claims challenge with error in the header
242+
r401.Headers.WwwAuthenticate.ParseAdd(
243+
$"Bearer realm=\"\", error=\"insufficient_claims\", " +
244+
$"error_description=\"token requires claims\", " +
245+
$"claims=\"{challengeB64}\"");
246+
queue.AddHttpResponseMessage(r401);
247+
248+
queue.AddHttpResponseMessage(new HttpResponseMessage(HttpStatusCode.OK)
249+
{
250+
Content = new StringContent("{ \"value\": \"MockSecretValue\" }",
251+
Encoding.UTF8, "application/json")
252+
});
253+
254+
// DI container
255+
var services = new ServiceCollection();
256+
services.AddHttpClient(Downstream401Service)
257+
.ConfigurePrimaryHttpMessageHandler(() => queue);
258+
services.AddLogging();
259+
services.AddTokenAcquisition();
260+
services.AddSingleton(authProvider);
261+
262+
services.AddDownstreamApi(Downstream401Service, opts =>
263+
{
264+
opts.BaseUrl = VaultBaseUrl;
265+
opts.RelativePath = SecretPath;
266+
opts.RequestAppToken = true;
267+
opts.Scopes = [ Scope ];
268+
});
269+
270+
var sp = services.BuildServiceProvider();
271+
var api = sp.GetRequiredService<IDownstreamApi>();
272+
273+
// ACT
274+
VaultSecret? secret = await api.GetForAppAsync<VaultSecret>(Downstream401Service);
275+
276+
// ASSERT
277+
Assert.NotNull(secret);
278+
Assert.Equal("MockSecretValue", secret!.Value); // retry succeeded
279+
280+
await authProvider.Received(2).CreateAuthorizationHeaderAsync(
281+
Arg.Any<IEnumerable<string>>(),
282+
Arg.Any<DownstreamApiOptions>(),
283+
Arg.Any<ClaimsPrincipal?>(),
284+
Arg.Any<CancellationToken>()); // called twice
285+
286+
Assert.Equal(challengeB64, capturedOpts!.AcquireTokenOptions.Claims);
287+
}
146288
}
147289
}

0 commit comments

Comments
 (0)