diff --git a/Microsoft.Identity.Web.sln b/Microsoft.Identity.Web.sln index 05c15260d..2c07d42a0 100644 --- a/Microsoft.Identity.Web.sln +++ b/Microsoft.Identity.Web.sln @@ -164,6 +164,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Identity.Web.Oidc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Identity.Web.UI.Test", "tests\Microsoft.Identity.Web.UI.Test\Microsoft.Identity.Web.UI.Test.csproj", "{CF31F33A-E5F5-DB57-4FEF-81BDAFD497C8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "daemon-app-msi", "tests\DevApps\daemon-app\daemon-app-msi\daemon-app-msi.csproj", "{A8181404-23E0-D38B-454C-D16ECDB18B9F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -387,6 +389,10 @@ Global {CF31F33A-E5F5-DB57-4FEF-81BDAFD497C8}.Debug|Any CPU.Build.0 = Debug|Any CPU {CF31F33A-E5F5-DB57-4FEF-81BDAFD497C8}.Release|Any CPU.ActiveCfg = Release|Any CPU {CF31F33A-E5F5-DB57-4FEF-81BDAFD497C8}.Release|Any CPU.Build.0 = Release|Any CPU + {A8181404-23E0-D38B-454C-D16ECDB18B9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8181404-23E0-D38B-454C-D16ECDB18B9F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8181404-23E0-D38B-454C-D16ECDB18B9F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8181404-23E0-D38B-454C-D16ECDB18B9F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -461,6 +467,7 @@ Global {E927D215-A96C-626C-9A1A-CF99876FE7B4} = {45B20A78-91F8-4DD2-B9AD-F12D3A93536C} {8DA7A2C6-00D4-4CF1-8145-448D7B7B4E5A} = {1DDE1AAC-5AE6-4725-94B6-A26C58D3423F} {CF31F33A-E5F5-DB57-4FEF-81BDAFD497C8} = {B4E72F1C-603F-437C-AAA1-153A604CD34A} + {A8181404-23E0-D38B-454C-D16ECDB18B9F} = {E37CDBC1-18F6-4C06-A3EE-532C9106721F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {104367F1-CE75-4F40-B32F-F14853973187} diff --git a/docs/design/capab1.png b/docs/design/capab1.png new file mode 100644 index 000000000..7e6737ee9 Binary files /dev/null and b/docs/design/capab1.png differ diff --git a/docs/design/managed_identity_capabilities_devex.md b/docs/design/managed_identity_capabilities_devex.md new file mode 100644 index 000000000..cc9931f2f --- /dev/null +++ b/docs/design/managed_identity_capabilities_devex.md @@ -0,0 +1,117 @@ +# Microsoft.Identity.Web – Continuous Access Evaluation (CAE) for Managed Identity + +## Why Continuous Access Evaluation? + +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.). + +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. + +## What this spec adds to **Microsoft.Identity.Web** + +* **Declarative opt-in** – one configuration knob (`ClientCapabilities: [ "cp1" ]`). +* **Transparent 401 recovery** – when a downstream Microsoft API responds with a 401+claims challenge, Id.Web automatically: + 1. extracts the claims body; + 2. bypasses its token cache; + 3. requests a fresh token that satisfies the claims; + 4. retries the HTTP call **once**. + +The goal is **zero-touch** for most developers. + +## Typical Flow (Managed Identity → Downstream API) + +```text +1. Id.Web → MSI endpoint : GET /token?resource=...&xms_cc=cp1 ──▶ +2. MSI → ESTS : request includes cp1 ──▶ +3. ESTS → Id.Web : access_token (xms_cc claim present) ◀── +4. Id.Web → Downstream API : GET /resource ⟶ 200 OK │ +5. Policy change occurs │ +6. Id.Web → Downstream API : GET /resource ⟶ 401 + claims payload │ +7. Id.Web handles challenge (steps 1-4 again, bypassing msal cache) ──▶ +``` + +## Design Goals + +| # | Goal | Success Metric | +|-----|--------------------------------------------------------------|----------------------------------------------------------| +| G1 | Transparent CAE retry with cache-bypass on 401 claims challenge. | Downstream API call recovers without developer code. | +| G2 | Declarative client capabilities via configuration. | Single place to add `cp1`; all MI calls include it. | + +## Public API Impact + +no changes to the public api. + +## Configuration Example + +``` +{ + "AzureAd": { + "ClientCapabilities": [ "cp1" ] + }, + + // Example downstream API definition (Contoso Storage API) + "ContosoStorage": { + "BaseUrl": "https://storage.contoso.com/", + "RelativePath": "data/records?api-version=1.0", + "RequestAppToken": true, + "Scopes": [ "https://storage.contoso.com/.default" ], + "AcquireTokenOptions": { + "ManagedIdentity": { + // optional – omit for system-assigned MI + "UserAssignedClientId": "" + } + } + } +} +``` + +> **Note** : The same configuration block works in *appsettings.json* or can be supplied programmatically. + + +## Code Snippets + +### Registering & Calling a Downstream API + +```csharp +// 1 – set up the TokenAcquirerFactory (test-helper shown for brevity) +var factory = TokenAcquirerFactory.GetDefaultInstance(); + +// 2 – register the downstream API using section "ContosoStorage" +factory.Services.AddDownstreamApi("ContosoStorage", + factory.Configuration.GetSection("ContosoStorage")); + +IServiceProvider sp = factory.Build(); +IDownstreamApi api = sp.GetRequiredService(); + +// 3 – call the API (Id.Web handles CAE automatically) +HttpResponseMessage resp = await api.CallApiForAppAsync("ContosoStorage"); +``` + +### Using **IAuthorizationHeaderProvider** (advanced) + +`IAuthorizationHeaderProvider` is fully supported with Managed Identity. Claims challenges propagate the same way: + +```csharp +var headerProvider = sp.GetRequiredService(); +string header = await headerProvider.CreateAuthorizationHeaderForAppAsync( + scope: "https://storage.contoso.com/.default", + options: new AuthorizationHeaderProviderOptions + { + AcquireTokenOptions = new AcquireTokenOptions + { + ManagedIdentity = new ManagedIdentityOptions(), // system-assigned MI + Claims = claimsChallengeJson // when retrying after 401 + } + }); +``` + +## Telemetry + +We rely on server side telemetry for the token revocation features. + +Server dashboards add MI success‑rate with/without cp1. + +## Options as seen in MSAL + +![alt text](capab1.png) + +### reference - [How to use Continuous Access Evaluation enabled APIs in your applications](https://learn.microsoft.com/en-us/entra/identity-platform/app-resilience-continuous-access-evaluation?tabs=dotnet) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/IManagedIdentityTestHttpClientFactory.cs b/src/Microsoft.Identity.Web.TokenAcquisition/IManagedIdentityTestHttpClientFactory.cs new file mode 100644 index 000000000..33b6f4f04 --- /dev/null +++ b/src/Microsoft.Identity.Web.TokenAcquisition/IManagedIdentityTestHttpClientFactory.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Identity.Client; + +namespace Microsoft.Identity.Web.TestOnly +{ + /// + /// **TEST-ONLY.** Allows unit tests to supply a custom . + /// + internal interface IManagedIdentityTestHttpClientFactory + { + IMsalHttpClientFactory Create(); + } +} diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net462/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net462/InternalAPI.Unshipped.txt index 5dfd019d9..d83645276 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net462/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net462/InternalAPI.Unshipped.txt @@ -1 +1,3 @@ -const Microsoft.Identity.Web.IDWebErrorMessage.ExceptionAcquiringTokenForConfidentialClient = "IDW10501: Exception acquiring token for a confidential client: " -> string! \ No newline at end of file +const Microsoft.Identity.Web.IDWebErrorMessage.ExceptionAcquiringTokenForConfidentialClient = "IDW10501: Exception acquiring token for a confidential client: " -> string! +Microsoft.Identity.Web.TestOnly.IManagedIdentityTestHttpClientFactory +Microsoft.Identity.Web.TestOnly.IManagedIdentityTestHttpClientFactory.Create() -> Microsoft.Identity.Client.IMsalHttpClientFactory! diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net472/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net472/InternalAPI.Unshipped.txt index 5dfd019d9..d83645276 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net472/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net472/InternalAPI.Unshipped.txt @@ -1 +1,3 @@ -const Microsoft.Identity.Web.IDWebErrorMessage.ExceptionAcquiringTokenForConfidentialClient = "IDW10501: Exception acquiring token for a confidential client: " -> string! \ No newline at end of file +const Microsoft.Identity.Web.IDWebErrorMessage.ExceptionAcquiringTokenForConfidentialClient = "IDW10501: Exception acquiring token for a confidential client: " -> string! +Microsoft.Identity.Web.TestOnly.IManagedIdentityTestHttpClientFactory +Microsoft.Identity.Web.TestOnly.IManagedIdentityTestHttpClientFactory.Create() -> Microsoft.Identity.Client.IMsalHttpClientFactory! diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net6.0/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net6.0/InternalAPI.Unshipped.txt index 2c5e8d3a5..25f816d66 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net6.0/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net6.0/InternalAPI.Unshipped.txt @@ -1,2 +1,4 @@ const Microsoft.Identity.Web.IDWebErrorMessage.ExceptionAcquiringTokenForConfidentialClient = "IDW10501: Exception acquiring token for a confidential client: " -> string! -Microsoft.Identity.Web.ClientInfoJsonContext \ No newline at end of file +Microsoft.Identity.Web.ClientInfoJsonContext +Microsoft.Identity.Web.TestOnly.IManagedIdentityTestHttpClientFactory +Microsoft.Identity.Web.TestOnly.IManagedIdentityTestHttpClientFactory.Create() -> Microsoft.Identity.Client.IMsalHttpClientFactory! diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net7.0/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net7.0/InternalAPI.Unshipped.txt index 2c5e8d3a5..25f816d66 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net7.0/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net7.0/InternalAPI.Unshipped.txt @@ -1,2 +1,4 @@ const Microsoft.Identity.Web.IDWebErrorMessage.ExceptionAcquiringTokenForConfidentialClient = "IDW10501: Exception acquiring token for a confidential client: " -> string! -Microsoft.Identity.Web.ClientInfoJsonContext \ No newline at end of file +Microsoft.Identity.Web.ClientInfoJsonContext +Microsoft.Identity.Web.TestOnly.IManagedIdentityTestHttpClientFactory +Microsoft.Identity.Web.TestOnly.IManagedIdentityTestHttpClientFactory.Create() -> Microsoft.Identity.Client.IMsalHttpClientFactory! diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net8.0/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net8.0/InternalAPI.Unshipped.txt index 2c5e8d3a5..25f816d66 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net8.0/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net8.0/InternalAPI.Unshipped.txt @@ -1,2 +1,4 @@ const Microsoft.Identity.Web.IDWebErrorMessage.ExceptionAcquiringTokenForConfidentialClient = "IDW10501: Exception acquiring token for a confidential client: " -> string! -Microsoft.Identity.Web.ClientInfoJsonContext \ No newline at end of file +Microsoft.Identity.Web.ClientInfoJsonContext +Microsoft.Identity.Web.TestOnly.IManagedIdentityTestHttpClientFactory +Microsoft.Identity.Web.TestOnly.IManagedIdentityTestHttpClientFactory.Create() -> Microsoft.Identity.Client.IMsalHttpClientFactory! diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net9.0/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net9.0/InternalAPI.Unshipped.txt index 2c5e8d3a5..25f816d66 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net9.0/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/net9.0/InternalAPI.Unshipped.txt @@ -1,2 +1,4 @@ const Microsoft.Identity.Web.IDWebErrorMessage.ExceptionAcquiringTokenForConfidentialClient = "IDW10501: Exception acquiring token for a confidential client: " -> string! -Microsoft.Identity.Web.ClientInfoJsonContext \ No newline at end of file +Microsoft.Identity.Web.ClientInfoJsonContext +Microsoft.Identity.Web.TestOnly.IManagedIdentityTestHttpClientFactory +Microsoft.Identity.Web.TestOnly.IManagedIdentityTestHttpClientFactory.Create() -> Microsoft.Identity.Client.IMsalHttpClientFactory! diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/netstandard2.0/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/netstandard2.0/InternalAPI.Unshipped.txt index 5dfd019d9..d83645276 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/netstandard2.0/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/netstandard2.0/InternalAPI.Unshipped.txt @@ -1 +1,3 @@ -const Microsoft.Identity.Web.IDWebErrorMessage.ExceptionAcquiringTokenForConfidentialClient = "IDW10501: Exception acquiring token for a confidential client: " -> string! \ No newline at end of file +const Microsoft.Identity.Web.IDWebErrorMessage.ExceptionAcquiringTokenForConfidentialClient = "IDW10501: Exception acquiring token for a confidential client: " -> string! +Microsoft.Identity.Web.TestOnly.IManagedIdentityTestHttpClientFactory +Microsoft.Identity.Web.TestOnly.IManagedIdentityTestHttpClientFactory.Create() -> Microsoft.Identity.Client.IMsalHttpClientFactory! diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.ManagedIdentity.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.ManagedIdentity.cs index e22e7589c..02192cf8d 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.ManagedIdentity.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.ManagedIdentity.cs @@ -2,11 +2,14 @@ // Licensed under the MIT License. using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Identity.Abstractions; using Microsoft.Identity.Client; using Microsoft.Identity.Client.AppConfig; +using Microsoft.Identity.Web.TestOnly; using Microsoft.IdentityModel.Tokens; namespace Microsoft.Identity.Web @@ -19,6 +22,7 @@ internal partial class TokenAcquisition private readonly ConcurrentDictionary _managedIdentityApplicationsByClientId = new(); private readonly SemaphoreSlim _managedIdSemaphore = new(1, 1); private const string SystemAssignedManagedIdentityKey = "SYSTEM"; + private readonly IManagedIdentityTestHttpClientFactory? _miHttpFactory; /// /// Gets a cached ManagedIdentityApplication object or builds a new one if not found. @@ -62,6 +66,7 @@ internal async Task GetOrBuildManagedIdentityApplic // Build the application application = BuildManagedIdentityApplication( managedIdentityId, + mergedOptions.ClientCapabilities, mergedOptions.ConfidentialClientApplicationOptions.EnablePiiLogging ); @@ -80,17 +85,33 @@ internal async Task GetOrBuildManagedIdentityApplic /// Creates a managed identity client application. /// /// Indicates if system-assigned or user-assigned managed identity is used. + /// Indicates the capabilities of the managed identity application. /// Indicates if logging that may contain personally identifiable information is enabled. /// A managed identity application. - private IManagedIdentityApplication BuildManagedIdentityApplication(ManagedIdentityId managedIdentityId, bool enablePiiLogging) + private IManagedIdentityApplication BuildManagedIdentityApplication( + ManagedIdentityId managedIdentityId, + IEnumerable? capabilities, + bool enablePiiLogging) { - return ManagedIdentityApplicationBuilder + ManagedIdentityApplicationBuilder miBuilder = ManagedIdentityApplicationBuilder .Create(managedIdentityId) .WithLogging( Log, ConvertMicrosoftExtensionsLogLevelToMsal(_logger), - enablePiiLogging: enablePiiLogging) - .Build(); + enablePiiLogging: enablePiiLogging); + + if (capabilities?.Any() == true) + { + miBuilder.WithClientCapabilities(capabilities); + } + + if (_miHttpFactory != null) + { + miBuilder.WithHttpClientFactory(_miHttpFactory.Create()); + } + + return miBuilder.Build(); + } /// diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 9b45a442f..dfe17bcc5 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -21,6 +21,7 @@ using Microsoft.Identity.Client.Advanced; using Microsoft.Identity.Client.Extensibility; using Microsoft.Identity.Web.Experimental; +using Microsoft.Identity.Web.TestOnly; using Microsoft.Identity.Web.TokenCacheProviders; using Microsoft.Identity.Web.TokenCacheProviders.InMemory; using Microsoft.IdentityModel.JsonWebTokens; @@ -108,6 +109,7 @@ public TokenAcquisition( _credentialsLoader = credentialsLoader; _certificatesObserver = serviceProvider.GetService(); tokenAcquisitionExtensionOptionsMonitor = serviceProvider.GetService>(); + _miHttpFactory = serviceProvider.GetService(); } #if NET6_0_OR_GREATER @@ -499,7 +501,15 @@ public async Task GetAuthenticationResultForAppAsync( mergedOptions, tokenAcquisitionOptions.ManagedIdentity ); - return await managedIdApp.AcquireTokenForManagedIdentity(scope).ExecuteAsync().ConfigureAwait(false); + + var miBuilder = managedIdApp.AcquireTokenForManagedIdentity(scope); + + if (!string.IsNullOrEmpty(tokenAcquisitionOptions.Claims)) + { + miBuilder.WithClaims(tokenAcquisitionOptions.Claims); + } + + return await miBuilder.ExecuteAsync().ConfigureAwait(false); } catch (Exception ex) { diff --git a/tests/DevApps/daemon-app/daemon-app-msi/Program.cs b/tests/DevApps/daemon-app/daemon-app-msi/Program.cs new file mode 100644 index 000000000..0733e48c4 --- /dev/null +++ b/tests/DevApps/daemon-app/daemon-app-msi/Program.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using System.Net; +using System.Text.Json; + +// ── 1. bootstrap factory (reads appsettings.json automatically) ───────── +var factory = TokenAcquirerFactory.GetDefaultInstance(); + +// optional console logging +factory.Services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Warning)); + +// ── 2. register the downstream API using the "AzureKeyVault" section ──── +factory.Services.AddDownstreamApi("AzureKeyVault", + factory.Configuration.GetSection("AzureKeyVault")); +IServiceProvider sp = factory.Build(); +IDownstreamApi api = sp.GetRequiredService(); + +// ── 3. call the vault (app-token path) ────────────────────────────────── +HttpResponseMessage response = await api.CallApiForAppAsync("AzureKeyVault"); + +if (response.StatusCode != HttpStatusCode.OK) +{ + Console.WriteLine($"Vault returned {(int)response.StatusCode} {response.ReasonPhrase}"); + return; +} + +//Get the secret value from the response +using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + +// Check if the "value" property exists and is a string +if (doc.RootElement.TryGetProperty("value", out var valueElement) && valueElement.ValueKind == JsonValueKind.String) +{ + // Retrieve the secret value but do not print it + string secret = valueElement.GetString()!; + + // Optionally, you can check if the secret is not null or empty + if (!string.IsNullOrEmpty(secret)) + { + Console.WriteLine("Secret retrieved successfully (non-null)."); + } + else + { + Console.WriteLine("Secret value was empty."); + } +} diff --git a/tests/DevApps/daemon-app/daemon-app-msi/appsettings.json b/tests/DevApps/daemon-app/daemon-app-msi/appsettings.json new file mode 100644 index 000000000..8b9a0c86a --- /dev/null +++ b/tests/DevApps/daemon-app/daemon-app-msi/appsettings.json @@ -0,0 +1,29 @@ +{ + // authentication settings (apply to the whole app) + "AzureAd": { + // Continuous Access Evaluation capability at app-level + "ClientCapabilities": [ "cp1" ] + }, + + // downstream API settings (per-resource) + "AzureKeyVault": { + "BaseUrl": "https://msidlabs.vault.azure.net/", + "RelativePath": "secrets/msidlab4?api-version=7.4", + "RequestAppToken": true, + "Scopes": [ "https://vault.azure.net/.default" ], + // per request settings + "AcquireTokenOptions": { + "ManagedIdentity": { + // user-assigned MI; omit for system-assigned + "UserAssignedClientId": "4b7a4b0b-ecb2-409e-879a-1e21a15ddaf6" + } + } + }, + + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft": "Information" + } + } +} diff --git a/tests/DevApps/daemon-app/daemon-app-msi/daemon-app-msi.csproj b/tests/DevApps/daemon-app/daemon-app-msi/daemon-app-msi.csproj new file mode 100644 index 000000000..36e7e2b3f --- /dev/null +++ b/tests/DevApps/daemon-app/daemon-app-msi/daemon-app-msi.csproj @@ -0,0 +1,29 @@ + + + + Exe + net8.0 + Daemon_app + enable + enable + 13 + false + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/tests/DevApps/daemon-app/daemon-app-msi/readme.md b/tests/DevApps/daemon-app/daemon-app-msi/readme.md new file mode 100644 index 000000000..ffeb4469b --- /dev/null +++ b/tests/DevApps/daemon-app/daemon-app-msi/readme.md @@ -0,0 +1,30 @@ +# VM-Hosted Key Vault Secret Retriever + +Tiny console app that runs **inside an Azure VM** configured with a **User-Assigned Managed Identity (UAMI)**. +Its only job is to fetch **Secret** from an Azure Key Vault section specified in the *appsettings.json*. + +--- + +## 1. Prerequisites + +| Requirement | Notes | +|-------------|-------| +| Azure VM | Any OS; the UAMI must be **assigned** to the VM. | +| User-Assigned Managed Identity | Needs ** `Get`** permission on the Key Vault’s **Secrets**. | +| Key Vault | Secret named `secret` (or whatever your *AzureKeyVault* section points to). | +| .NET 8 SDK | Build / run the app locally or on the VM. | +| *appsettings.json* | Contains an `AzureKeyVault` block with `BaseUrl`, `RelativePath`, etc. | + +> 💡 **Least privilege**: grant the UAMI only the `secrets/get` permission. + +--- + +## 2. How the Code Works + +```mermaid +flowchart TD + A["1️⃣ **appsettings.json**
(TokenAcquirerFactory auto-binds UAMI creds)"] --> B["2️⃣ **DI Container**
(register “AzureKeyVault” downstream API)"] + B --> C["3️⃣ **IDownstreamApi.Call**
GET {vault-url}/secrets/secret?api-version=7.4"] + C --> D["4️⃣ **HttpResponseMessage**
Parse JSON → extract `value`"] + D --> E["5️⃣ **Console logging**
Log *success*"] +``` diff --git a/tests/Microsoft.Identity.Web.Test.Common/Mocks/MockHttpCreator.cs b/tests/Microsoft.Identity.Web.Test.Common/Mocks/MockHttpCreator.cs index 19918c8d3..38ee3ebc9 100644 --- a/tests/Microsoft.Identity.Web.Test.Common/Mocks/MockHttpCreator.cs +++ b/tests/Microsoft.Identity.Web.Test.Common/Mocks/MockHttpCreator.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; using System.Collections.Generic; +using System.Globalization; using System.Net; using System.Net.Http; using Microsoft.Identity.Web.Util; @@ -71,5 +73,31 @@ public static MockHttpMessageHandler CreateHandlerToValidatePostData( ResponseMessage = CreateSuccessfulClientCredentialTokenResponseMessage(), }; } + + public static MockHttpMessageHandler CreateMsiTokenHandler( + string accessToken, + string resource = "https://management.azure.com/", + int expiresIn = 3599) + { + string expiresOn = DateTimeOffset.UtcNow + .AddSeconds(expiresIn) + .ToUnixTimeSeconds() + .ToString(CultureInfo.InvariantCulture); + + string json = $@"{{ + ""access_token"": ""{accessToken}"", + ""expires_in"" : ""{expiresIn}"", + ""expires_on"" : ""{expiresOn}"", + ""resource"" : ""{resource}"", + ""token_type"" : ""Bearer"", + ""client_id"" : ""client_id"" + }}"; + + return new MockHttpMessageHandler + { + ExpectedMethod = HttpMethod.Get, + ResponseMessage = CreateSuccessResponseMessage(json) + }; + } } } diff --git a/tests/Microsoft.Identity.Web.Test/FmiTests.cs b/tests/Microsoft.Identity.Web.Test/FmiTests.cs index f40a20ec9..b79083566 100644 --- a/tests/Microsoft.Identity.Web.Test/FmiTests.cs +++ b/tests/Microsoft.Identity.Web.Test/FmiTests.cs @@ -13,6 +13,7 @@ namespace Microsoft.Identity.Web.Test { + [Collection("Run tests - serial")] public class FmiTests { [Fact] diff --git a/tests/Microsoft.Identity.Web.Test/ManagedIdentityCaeTests.cs b/tests/Microsoft.Identity.Web.Test/ManagedIdentityCaeTests.cs new file mode 100644 index 000000000..6ea9ee5b5 --- /dev/null +++ b/tests/Microsoft.Identity.Web.Test/ManagedIdentityCaeTests.cs @@ -0,0 +1,288 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Client; +using Microsoft.Identity.Web.Test.Common.Mocks; +using Microsoft.Identity.Web.TestOnly; +using Xunit; +using Microsoft.Identity.Web.Test; +using NSubstitute; +using System.Collections.Generic; +using System.Net.Http; +using System.Net; +using System.Text; +using System.Security.Claims; +using System.Threading; +using Microsoft.IdentityModel.Tokens; + +namespace Microsoft.Identity.Web.Tests.Certificateless +{ + [Collection("Run tests - serial")] + public class ManagedIdentityTests + { + private const string Scope = "https://management.azure.com/.default"; + private const string UamiClientId = "04ca4d6a-c720-4ba1-aa06-f6634b73fe7a"; + private const string MockToken = "mocked.access.token"; + private const string CaeClaims = + @"{""access_token"":{""nbf"":{""essential"":true,""value"":""1702682181""}}}"; + + private const string Downstream401Service = "Downstream401"; + private const string FirstToken = "mocked.access.token-1"; + private const string SecondToken = "mocked.access.token-2"; + private const string VaultBaseUrl = "https://my-vault.vault.azure.net/"; + private const string SecretPath = "secrets/mySecret"; + + private sealed record VaultSecret(string Value); + + [Fact] + public async Task ManagedIdentity_ReturnsBearerHeader() + { + TokenAcquirerFactoryTesting.ResetTokenAcquirerFactoryInTest(); + var factory = TokenAcquirerFactory.GetDefaultInstance(); + + var mockHttp = new MockHttpClientFactory(); + + mockHttp.AddMockHandler( + MockHttpCreator.CreateMsiTokenHandler(accessToken: MockToken)); + + // Add the mock handler to the DI container + factory.Services.AddSingleton( + _ => new TestManagedIdentityHttpFactory(mockHttp)); + + IAuthorizationHeaderProvider headerProvider = factory.Build() + .GetRequiredService(); + + // basic mi flow where we get a token + string header = await headerProvider.CreateAuthorizationHeaderForAppAsync( + Scope, + new AuthorizationHeaderProviderOptions + { + AcquireTokenOptions = new AcquireTokenOptions + { + ManagedIdentity = new ManagedIdentityOptions { UserAssignedClientId = UamiClientId }, + } + }); + + Assert.Equal($"Bearer {MockToken}", header); + } + + [Fact] + public async Task ManagedIdentity_WithClaims_HeaderBypassesCache() + { + // Arrange + TokenAcquirerFactoryTesting.ResetTokenAcquirerFactoryInTest(); + var tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance(); + var mockedHttp = new MockHttpClientFactory(); + + // token-1 will be cached, token-2 should be returned when claims force a bypass + mockedHttp.AddMockHandler(MockHttpCreator.CreateMsiTokenHandler("token1")); + mockedHttp.AddMockHandler(MockHttpCreator.CreateMsiTokenHandler("token2")); + + tokenAcquirerFactory.Services.AddSingleton( + _ => new TestManagedIdentityHttpFactory(mockedHttp)); + + var headerProvider = tokenAcquirerFactory.Build() + .GetRequiredService(); + + // Initial call � no claims, token cached + string header1 = await headerProvider.CreateAuthorizationHeaderForAppAsync( + Scope, + new AuthorizationHeaderProviderOptions + { + AcquireTokenOptions = new AcquireTokenOptions + { + ManagedIdentity = new ManagedIdentityOptions + { + UserAssignedClientId = "UamiClientId2" + } + } + }); + Assert.Equal("Bearer token1", header1); + + // Same UAMI with CAE claims � should bypass cache + string header2 = await headerProvider.CreateAuthorizationHeaderForAppAsync( + Scope, + new AuthorizationHeaderProviderOptions + { + AcquireTokenOptions = new AcquireTokenOptions + { + ManagedIdentity = new ManagedIdentityOptions + { + UserAssignedClientId = "UamiClientId2" + }, + Claims = CaeClaims + } + }); + Assert.Equal("Bearer token2", header2); + } + + [Fact] + public async Task UserAssigned_MI_Caching_and_Claims() + { + // Arrange + TokenAcquirerFactoryTesting.ResetTokenAcquirerFactoryInTest(); + var factory = TokenAcquirerFactory.GetDefaultInstance(); + var mockHttp = new MockHttpClientFactory(); + + // a = token-1 b = cached token (i.e. token-1) + // c = token-2 d = token-3 + mockHttp.AddMockHandler(MockHttpCreator.CreateMsiTokenHandler("token-1")); // a + mockHttp.AddMockHandler(MockHttpCreator.CreateMsiTokenHandler("token-2")); // c + mockHttp.AddMockHandler(MockHttpCreator.CreateMsiTokenHandler("token-3")); // d + + factory.Services.AddSingleton( + _ => new TestManagedIdentityHttpFactory(mockHttp)); + + var provider = factory.Build(); + var tokens = provider.GetRequiredService(); + + // helper + static TokenAcquisitionOptions Uami(string id, string? claims = null) => new() + { + ManagedIdentity = new ManagedIdentityOptions { UserAssignedClientId = id }, + Claims = claims + }; + + // scenario a : first call directed to IdP for uamiA + var r1 = await tokens.GetAuthenticationResultForAppAsync( + Scope, tokenAcquisitionOptions: Uami("uamiA")); + Assert.Equal(TokenSource.IdentityProvider, r1.AuthenticationResultMetadata.TokenSource); + Assert.Equal("token-1", r1.AccessToken); + + // scenario b : same uamiA and no claims gets a cached token + var r2 = await tokens.GetAuthenticationResultForAppAsync( + Scope, tokenAcquisitionOptions: Uami("uamiA")); + Assert.Equal(TokenSource.Cache, r2.AuthenticationResultMetadata.TokenSource); + Assert.Equal("token-1", r2.AccessToken); + + // scenario c : same uamiA + CAE claims gets a token from IdP (bypasses cache) + var r3 = await tokens.GetAuthenticationResultForAppAsync( + Scope, tokenAcquisitionOptions: Uami("uamiA", CaeClaims)); + Assert.Equal(TokenSource.IdentityProvider, r3.AuthenticationResultMetadata.TokenSource); + Assert.Equal("token-2", r3.AccessToken); + + // scenario d : different UAMI (say uamiB) gets a token from IdP + var r4 = await tokens.GetAuthenticationResultForAppAsync( + Scope, tokenAcquisitionOptions: Uami("uamiB")); + Assert.Equal(TokenSource.IdentityProvider, r4.AuthenticationResultMetadata.TokenSource); + Assert.Equal("token-3", r4.AccessToken); + } + + [Fact] + public async Task SystemAssigned_MSI_Forwards_ClientCapabilities_InQuery() + { + // Arrange + TokenAcquirerFactoryTesting.ResetTokenAcquirerFactoryInTest(); + var factory = TokenAcquirerFactory.GetDefaultInstance(); + + // Mock IMDS/MSI: returns a 200 with "access_token" and records the request + var captureHandler = MockHttpCreator.CreateMsiTokenHandler(MockToken); + + var mockHttp = new MockHttpClientFactory(); + mockHttp.AddMockHandler(captureHandler); + factory.Services.AddSingleton( + _ => new TestManagedIdentityHttpFactory(mockHttp)); + + // Enable capabilities cp1,cp2 + factory.Services.Configure(opts => + opts.ClientCapabilities = ["cp1", "cp2"]); + + var tokenAcquirer = factory.Build() + .GetRequiredService(); + + // Act + var result = await tokenAcquirer.GetAuthenticationResultForAppAsync( + Scope, + tokenAcquisitionOptions: new TokenAcquisitionOptions + { + ManagedIdentity = new ManagedIdentityOptions(), // system-assigned + Claims = CaeClaims + }); + + // Assert + Assert.Equal(MockToken, result.AccessToken); + + // Assert - outbound GET includes xms_cc=cp1%2Ccp2 + // This check can be enabled when MSAL enables cae + //string query = captureHandler.ActualRequestMessage!.RequestUri!.Query; + //Assert.Contains("xms_cc=cp1%2Ccp2", query, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DownstreamApi_401Claims_TriggersSingleRetry_AndSucceeds() + { + // challenge JSON + string challengeB64 = Base64UrlEncoder.Encode( + Encoding.UTF8.GetBytes(CaeClaims)); + + // authProvider mock + var authProvider = Substitute.For(); + DownstreamApiOptions? capturedOpts = null; + + authProvider.CreateAuthorizationHeaderAsync( + Arg.Any>(), + Arg.Do(o => capturedOpts = o), + Arg.Any(), + Arg.Any()) + .Returns(ci => $"Bearer {FirstToken}", + ci => $"Bearer {SecondToken}"); + + // queue handler: 401 w/ claims - 200 OK + // Id Web will single retry the request on 401 + var queue = new QueueHttpMessageHandler(); + + // 401 response with claims + var r401 = new HttpResponseMessage(HttpStatusCode.Unauthorized); + + // add the claims challenge with error in the header + r401.Headers.WwwAuthenticate.ParseAdd( + $"Bearer realm=\"\", error=\"insufficient_claims\", " + + $"error_description=\"token requires claims\", " + + $"claims=\"{challengeB64}\""); + queue.AddHttpResponseMessage(r401); + + queue.AddHttpResponseMessage(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{ \"value\": \"MockSecretValue\" }", + Encoding.UTF8, "application/json") + }); + + // DI container + var services = new ServiceCollection(); + services.AddHttpClient(Downstream401Service) + .ConfigurePrimaryHttpMessageHandler(() => queue); + services.AddLogging(); + services.AddTokenAcquisition(); + services.AddSingleton(authProvider); + + services.AddDownstreamApi(Downstream401Service, opts => + { + opts.BaseUrl = VaultBaseUrl; + opts.RelativePath = SecretPath; + opts.RequestAppToken = true; + opts.Scopes = [Scope]; + }); + + var sp = services.BuildServiceProvider(); + var api = sp.GetRequiredService(); + + // ACT + VaultSecret? secret = await api.GetForAppAsync(Downstream401Service); + + // ASSERT + Assert.NotNull(secret); + Assert.Equal("MockSecretValue", secret!.Value); // retry succeeded + + await authProvider.Received(2).CreateAuthorizationHeaderAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any()); // called twice + + Assert.Equal(challengeB64, capturedOpts!.AcquireTokenOptions.Claims); + } + } +} diff --git a/tests/Microsoft.Identity.Web.Test/TestManagedIdentityHttpFactory.cs b/tests/Microsoft.Identity.Web.Test/TestManagedIdentityHttpFactory.cs new file mode 100644 index 000000000..ddad6546c --- /dev/null +++ b/tests/Microsoft.Identity.Web.Test/TestManagedIdentityHttpFactory.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Identity.Client; +using Microsoft.Identity.Web.TestOnly; + +namespace Microsoft.Identity.Web.Test +{ + internal sealed class TestManagedIdentityHttpFactory : IManagedIdentityTestHttpClientFactory + { + private readonly IMsalHttpClientFactory _msalHttpClientFactory; + public TestManagedIdentityHttpFactory(IMsalHttpClientFactory msalHttpClientFactory) + => _msalHttpClientFactory = msalHttpClientFactory; + public IMsalHttpClientFactory Create() => _msalHttpClientFactory; + } +} diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquirerCollection.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquirerCollection.cs new file mode 100644 index 000000000..3cdc7b182 --- /dev/null +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquirerCollection.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Xunit; + +namespace Microsoft.Identity.Web.Tests +{ + /// + /// Disables parallel execution for every test-class that + /// is decorated with [Collection("Run tests - serial")]. + /// NOTE: tests that rely on TokenAcquirerFactory / TokenAcquisition (and the + /// IMsalHttpClientFactory mocks they spin-up) share several static / singleton + /// caches. If xUnit runs them in parallel those shared objects collide and the + /// mocks return the wrong handler, producing flaky failures. Putting them all + /// in this “serial” collection forces xUnit to execute them one-by-one. + /// Be cautious: only include tests that really need this, as this will impact + /// the overall test suite running time + /// + [CollectionDefinition("Run tests - serial", DisableParallelization = true)] + public sealed class TokenAcquirerCollection : ICollectionFixture + { + } +}