From 44a045b533aef7da86de987c5a18e6fde014ca29 Mon Sep 17 00:00:00 2001 From: avdunn Date: Thu, 4 Jun 2026 09:46:26 -0700 Subject: [PATCH 01/20] Fix #3840: Use native MSAL UserFIC API for agentic UPN flows Replace ROPC piggybacking with MSAL's native AcquireTokenByUserFederatedIdentityCredential API using the multi-CCA pattern (blueprint + per-agent CCAs with assertion callbacks). This enables proper token caching for agentic User FIC flows when ClaimsPrincipal is null, eliminating 2-4 unnecessary network round-trips per bot message. Phase 1: UPN-based flows only. OID-based flows remain on the existing ROPC+add-in path pending MSAL .NET support for the OID overload. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TokenAcquisition.cs | 220 +++++++++++++++++ .../TokenAcquisitionTests.cs | 229 ++++++++++++++++++ 2 files changed, 449 insertions(+) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index ddc96fd65..7e3774840 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -59,6 +59,28 @@ class OAuthConstants private readonly ConcurrentDictionary _applicationsByAuthorityClientId = new(); private readonly ConcurrentDictionary _appSemaphores = new(); + /// + /// Caches agent CCAs for the native User FIC flow. Each agent CCA uses an assertion + /// callback that chains to the blueprint CCA for Leg 1 (FMI token acquisition). + /// Key format: "{agentAppId}:{authenticationScheme}". + /// + private readonly ConcurrentDictionary _agentUserFicCcas = new(); + private readonly ConcurrentDictionary _agentCcaSemaphores = new(); + + /// + /// Maps (agentAppId, username, tenantId) tuples to MSAL account identifiers for the + /// native User FIC flow. This is needed because AcquireTokenSilent requires an IAccount, + /// which can only be obtained from GetAccountAsync(identifier) using an identifier that + /// comes back from a prior token acquisition. In all other ID Web flows, this identifier + /// is stored in the ClaimsPrincipal (via GetMsalAccountId / oid+tid claims). In the + /// agentic scenario, however, ClaimsPrincipal is typically null or freshly created per + /// request (bot/service pattern), so there is no persistent object to write back to. + /// This dictionary fills that role, keyed by "{agentAppId}:{USERNAME}:{TENANTID}". + /// Entries are cleaned up when MSAL evicts the corresponding account from its cache. + /// + private readonly ConcurrentDictionary _agentUserFicAccountIds = new(); + private static readonly string[] s_ficScopes = new[] { "api://AzureADTokenExchange/.default" }; + private const string TokenBindingParameterName = "IsTokenBinding"; private const int MaxCertificateRetries = 1; protected readonly IMsalHttpClientFactory _httpClientFactory; @@ -403,6 +425,16 @@ private async Task GetAuthenticationResultForUserInternalA MergedOptions mergedOptions, TokenAcquisitionOptions? tokenAcquisitionOptions) { + // Handle UPN-based agentic User FIC flow using MSAL's native UserFIC API. + // This bypasses the ROPC piggybacking approach and provides proper cache behavior. + // OID-based agentic flows continue through the existing ROPC + add-in path below. + var agentUserFicResult = await TryGetAuthenticationResultForAgentUserFicAsync( + application, tenantId, scopes, mergedOptions, tokenAcquisitionOptions).ConfigureAwait(false); + if (agentUserFicResult is not null) + { + return agentUserFicResult; + } + string? username = null; string? password = null; string? agentIdentity = string.Empty; @@ -526,6 +558,194 @@ private async Task GetAuthenticationResultForUserInternalA return authenticationResult; } + /// + /// Handles UPN-based agentic User FIC flow using MSAL's native + /// + /// API. This replaces the ROPC piggybacking approach for UPN scenarios, providing proper + /// token cache behavior via MSAL's built-in cache. + /// + /// The flow follows the multi-CCA pattern: + /// Leg 1: Blueprint CCA acquires FMI token (T1) for the agent — handled transparently + /// by the agent CCA's assertion callback (see ). + /// Leg 2: Agent CCA acquires instance token (T2) via AcquireTokenForClient. + /// Leg 3: Agent CCA exchanges T2 + username for a user-scoped token via native UserFIC. + /// + /// On subsequent calls, AcquireTokenSilent returns the cached token without network calls. + /// Unlike other ID Web flows where the MSAL account identifier is stored in the + /// ClaimsPrincipal (via oid/tid claims), the agentic scenario typically has a null or + /// request-scoped ClaimsPrincipal — so account identifiers are tracked in + /// instead. + /// + /// An if this is a UPN-based agentic flow; + /// null if the request is not an agentic UPN flow (OID or regular ROPC). + private async Task TryGetAuthenticationResultForAgentUserFicAsync( + IConfidentialClientApplication application, + string? tenantId, + IEnumerable scopes, + MergedOptions mergedOptions, + TokenAcquisitionOptions? tokenAcquisitionOptions) + { + var extraParameters = tokenAcquisitionOptions?.ExtraParameters; + + // Only handle UPN-based agentic flows (AgentIdentityKey + UsernameKey). + // OID-based flows (UserIdKey) continue through the existing ROPC + add-in path. + if (extraParameters is null + || !extraParameters.TryGetValue(Constants.AgentIdentityKey, out object? agentObj) + || !extraParameters.ContainsKey(Constants.UsernameKey)) + { + return null; + } + + string? agentAppId = agentObj as string; + string? username = extraParameters[Constants.UsernameKey] as string; + if (string.IsNullOrEmpty(agentAppId) || string.IsNullOrEmpty(username)) + { + return null; + } + + string? authScheme = tokenAcquisitionOptions?.AuthenticationOptionsName; + var agentCca = await GetOrBuildAgentUserFicCcaAsync( + agentAppId!, application.Authority, authScheme).ConfigureAwait(false); + + bool forceRefresh = tokenAcquisitionOptions?.ForceRefresh ?? false; + + // Try silent retrieval first using a stored account identifier from a prior call. + // Include tenantId in the key so cross-tenant calls don't collide. + string normalizedTenant = tenantId?.ToUpperInvariant() ?? string.Empty; + string accountLookupKey = $"{agentAppId}:{username!.ToUpperInvariant()}:{normalizedTenant}"; + if (!forceRefresh + && _agentUserFicAccountIds.TryGetValue(accountLookupKey, out string? cachedAccountId) + && !string.IsNullOrEmpty(cachedAccountId)) + { + var account = await agentCca.GetAccountAsync(cachedAccountId).ConfigureAwait(false); + if (account is not null) + { + try + { + var silentBuilder = agentCca.AcquireTokenSilent( + scopes.Except(_scopesRequestedByMsal), + account); + if (!string.IsNullOrEmpty(tenantId)) + { + silentBuilder.WithTenantId(tenantId); + } + + return await silentBuilder.ExecuteAsync().ConfigureAwait(false); + } + catch (MsalUiRequiredException ex) + { + Logger.TokenAcquisitionError(_logger, ex.Message, ex); + } + } + else + { + // Account was evicted from MSAL's cache — remove stale mapping. + _agentUserFicAccountIds.TryRemove(accountLookupKey, out _); + } + } + + // Leg 2: Get the agent's instance token (T2). + // The assertion callback handles Leg 1 (blueprint → T1) transparently. + var leg2Builder = agentCca.AcquireTokenForClient(s_ficScopes); + if (!string.IsNullOrEmpty(tenantId)) + { + leg2Builder.WithTenantId(tenantId); + } + + var leg2 = await leg2Builder.ExecuteAsync().ConfigureAwait(false); + + // Leg 3: Exchange T2 + username for a user-scoped token via native UserFIC. + var leg3Builder = ((IByUserFederatedIdentityCredential)agentCca) + .AcquireTokenByUserFederatedIdentityCredential( + scopes.Except(_scopesRequestedByMsal), + username, + leg2.AccessToken); + + if (!string.IsNullOrEmpty(tenantId)) + { + leg3Builder.WithTenantId(tenantId); + } + + var result = await leg3Builder.ExecuteAsync().ConfigureAwait(false); + + // Store the account identifier for subsequent silent lookups. + // This parallels how other ID Web flows write oid/tid claims back into the + // ClaimsPrincipal after acquisition (see line ~541 in the ROPC path). Here, + // ClaimsPrincipal is unavailable, so we use _agentUserFicAccountIds instead. + if (result.Account?.HomeAccountId is not null) + { + _agentUserFicAccountIds[accountLookupKey] = result.Account.HomeAccountId.Identifier; + } + + return result; + } + + /// + /// Gets or builds an agent CCA for the native User FIC flow. Each agent CCA uses an + /// assertion callback that chains back to the blueprint CCA for Leg 1 (FMI token). + /// The agent CCA's in-memory cache provides natural token isolation per agent. + /// + private async Task GetOrBuildAgentUserFicCcaAsync( + string agentAppId, + string authority, + string? authenticationScheme) + { + // Include authenticationScheme in the CCA cache key so different schemes + // (pointing to different blueprint credentials) get separate CCAs. + string ccaCacheKey = $"{agentAppId}:{authenticationScheme ?? string.Empty}"; + + if (_agentUserFicCcas.TryGetValue(ccaCacheKey, out var existing)) + { + return existing; + } + + var semaphore = _agentCcaSemaphores.GetOrAdd(ccaCacheKey, _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync().ConfigureAwait(false); + try + { + if (_agentUserFicCcas.TryGetValue(ccaCacheKey, out var app)) + { + return app; + } + + // Capture authenticationScheme for the assertion callback closure. + string? capturedAuthScheme = authenticationScheme; + + var newApp = ConfidentialClientApplicationBuilder + .Create(agentAppId) + .WithClientAssertion(async (AssertionRequestOptions options) => + { + // Leg 1: Blueprint acquires FMI token (T1) for this agent. + // AcquireTokenForClient checks cache first — only the first call + // or an expired T1 hits the network. + MergedOptions blueprintOptions = _tokenAcquisitionHost.GetOptions(capturedAuthScheme, out _); + var blueprintCca = await GetOrBuildConfidentialClientApplicationAsync( + blueprintOptions, isTokenBinding: false).ConfigureAwait(false); + + var leg1 = await blueprintCca + .AcquireTokenForClient(s_ficScopes) + .WithFmiPath(agentAppId) + .WithSendX5C(blueprintOptions.SendX5C) + .ExecuteAsync(options.CancellationToken) + .ConfigureAwait(false); + + return leg1.AccessToken; + }) + .WithAuthority(authority) + .WithHttpClientFactory(_httpClientFactory) + .WithInstanceDiscovery(false) // Blueprint already validated the authority. + .WithExperimentalFeatures() + .Build(); + + _agentUserFicCcas[ccaCacheKey] = newApp; + return newApp; + } + finally + { + semaphore.Release(); + } + } + private void LogAuthResult(AuthenticationResult? authenticationResult) { if (authenticationResult != null) diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs index ef66fa427..acd4560bf 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs @@ -417,5 +417,234 @@ private static System.Security.Cryptography.X509Certificates.X509Certificate2 Cr return certificate; } + + #region Agent User Identity Cache Tests (Issue #3840) + + private const string AgentTestUsername = "testuser@contoso.com"; + + /// + /// Verifies the fix for GitHub issue #3840: the native User FIC flow uses the + /// multi-CCA pattern (blueprint + agent CCA) and caches user tokens properly. + /// The second call should use AcquireTokenSilent — no additional network calls. + /// + [Fact] + public async Task AgentUserIdentity_NativeUserFic_UsesCacheOnSecondCall() + { + // Arrange — use a unique agent app ID to avoid MSAL shared cache interference + string agentAppId = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + + // First call needs 3 handlers: Leg 1 (blueprint FMI), Leg 2 (agent instance), Leg 3 (user_fic). + // Second call needs 0 handlers (silent cache hit). + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "user-token-1"); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + var options = CreateAgentIdentityOptions(agentAppId); + + // Act — first call: full 3-leg flow (Leg 1 → T1, Leg 2 → T2, Leg 3 → user token) + string result1 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: null); + + // Act — second call: should use AcquireTokenSilent (no mock handlers left) + string result2 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: null); + + // Assert — both return the same token, second from cache + Assert.Equal("Bearer user-token-1", result1); + Assert.Equal("Bearer user-token-1", result2); + } + + /// + /// Verifies that the native User FIC path works even when ClaimsPrincipal is non-null. + /// The UPN agentic flow always uses the native path regardless of ClaimsPrincipal state. + /// + [Fact] + public async Task AgentUserIdentity_NativeUserFic_WorksWithNonNullClaimsPrincipal() + { + // Arrange + string agentAppId = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "user-token-1"); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + var claimsPrincipal = new System.Security.Claims.ClaimsPrincipal( + new Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity()); + var options = CreateAgentIdentityOptions(agentAppId); + + // Act — first call with non-null ClaimsPrincipal + string result1 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: claimsPrincipal); + + // Act — second call: should still use cache + string result2 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: claimsPrincipal); + + // Assert + Assert.Equal("Bearer user-token-1", result1); + Assert.Equal("Bearer user-token-1", result2); + } + + /// + /// Verifies cache works with new ClaimsPrincipal instances per call. Unlike the + /// old ROPC path, the native User FIC path does not depend on ClaimsPrincipal + /// for cache lookups — the account identifier is stored internally. + /// + [Fact] + public async Task AgentUserIdentity_NativeUserFic_CacheWorksWithNewClaimsPrincipalPerCall() + { + // Arrange + string agentAppId = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "user-token-1"); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + var options = CreateAgentIdentityOptions(agentAppId); + + // Act — each call gets a fresh ClaimsPrincipal (simulates request-scoped DI) + string result1 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: new System.Security.Claims.ClaimsPrincipal( + new Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity())); + + string result2 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: new System.Security.Claims.ClaimsPrincipal( + new Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity())); + + // Assert — both return the same cached token (unlike old ROPC path) + Assert.Equal("Bearer user-token-1", result1); + Assert.Equal("Bearer user-token-1", result2); + } + + private static AuthorizationHeaderProviderOptions CreateAgentIdentityOptions(string agentAppId) + { + return new AuthorizationHeaderProviderOptions + { + AcquireTokenOptions = new AcquireTokenOptions + { + ExtraParameters = new Dictionary + { + [Constants.AgentIdentityKey] = agentAppId, + [Constants.UsernameKey] = AgentTestUsername, + } + } + }; + } + + private TokenAcquirerFactory InitTokenAcquirerFactoryForAgent() + { + TokenAcquirerFactoryTesting.ResetTokenAcquirerFactoryInTest(); + TokenAcquirerFactory tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance(); + + var mockHttpFactory = new MockHttpClientFactory(); + + tokenAcquirerFactory.Services.Configure(options => + { + options.Instance = "https://login.microsoftonline.com/"; + options.TenantId = "10c419d4-4a50-45b2-aa4e-919fb84df24f"; + options.ClientId = "idu773ld-e38d-jud3-45lk-d1b09a74a8ca"; + options.ClientCredentials = [new CredentialDescription() + { + SourceType = CredentialSource.ClientSecret, + ClientSecret = "someSecret" + }]; + }); + + tokenAcquirerFactory.Services.AddSingleton(mockHttpFactory); + + return tokenAcquirerFactory; + } + + /// + /// Adds mock handlers for the 3-leg agent User FIC flow: + /// Handler 1: Leg 1 — blueprint's AcquireTokenForClient (FMI token / T1) + /// Handler 2: Leg 2 — agent's AcquireTokenForClient (instance token / T2) + /// Handler 3: Leg 3 — agent's AcquireTokenByUserFederatedIdentityCredential (user token) + /// Handler 1 also auto-handles instance discovery via the mock factory's re-queue mechanism. + /// + private static void AddAgentUserFicMockHandlers( + MockHttpClientFactory mockHttpClient, + string userAccessToken = "header.payload.signature") + { + // Leg 1: Blueprint FMI token (T1) — client_credentials with fmi_path + mockHttpClient.AddMockHandler(CreateClientCredentialsTokenHandler(accessToken: "t1-fmi-token")); + + // Leg 2: Agent instance token (T2) — client_credentials with T1 as assertion + mockHttpClient.AddMockHandler(CreateClientCredentialsTokenHandler(accessToken: "t2-instance-token")); + + // Leg 3: User token — user_fic grant with T2 as assertion + mockHttpClient.AddMockHandler(CreateUserFicTokenHandler(accessToken: userAccessToken)); + } + + /// + /// Creates a mock handler for a client_credentials response (used for Legs 1 and 2). + /// + private static MockHttpMessageHandler CreateClientCredentialsTokenHandler(string accessToken) + { + return new MockHttpMessageHandler() + { + ExpectedMethod = HttpMethod.Post, + ResponseMessage = MockHttpCreator.CreateSuccessResponseMessage( + "{\"token_type\":\"Bearer\"," + + "\"expires_in\":3599," + + "\"access_token\":\"" + accessToken + "\"," + + "\"client_info\":\"" + EncodeBase64Url( + "{\"uid\":\"" + TestConstants.Uid + "\",\"utid\":\"" + TestConstants.Utid + "\"}") + "\"}"), + }; + } + + /// + /// Creates a mock handler for a user_fic response (Leg 3) with id_token, refresh_token, + /// and client_info so MSAL creates a proper account in the cache. + /// + private static MockHttpMessageHandler CreateUserFicTokenHandler(string accessToken) + { + return new MockHttpMessageHandler() + { + ExpectedMethod = HttpMethod.Post, + ResponseMessage = MockHttpCreator.CreateSuccessResponseMessage( + "{\"token_type\":\"Bearer\"," + + "\"expires_in\":3599," + + "\"scope\":\"https://graph.microsoft.com/.default openid profile offline_access\"," + + "\"access_token\":\"" + accessToken + "\"," + + "\"refresh_token\":\"mock-refresh-token\"," + + "\"client_info\":\"" + EncodeBase64Url( + "{\"uid\":\"" + TestConstants.Uid + "\",\"utid\":\"" + TestConstants.Utid + "\"}") + "\"," + + "\"id_token\":\"" + MockHttpCreator.CreateIdToken(TestConstants.Uid, AgentTestUsername) + "\"}"), + }; + } + + private static string EncodeBase64Url(string input) + { + return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(input)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + #endregion } } From a2676460a581e8558cafe8d58ab7ef985a053d98 Mon Sep 17 00:00:00 2001 From: avdunn Date: Fri, 5 Jun 2026 06:29:05 -0700 Subject: [PATCH 02/20] Add OID UserFIC support, bump MSAL to 4.84.2, remove add-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump MSAL .NET from 4.84.1 to 4.84.2 (adds Guid userObjectId overload for AcquireTokenByUserFederatedIdentityCredential) - Extend TryGetAuthenticationResultForAgentUserFicAsync to handle both UPN-based and OID-based agentic flows via native MSAL APIs - Remove AgentUserIdentityMsalAddIn (ROPC body-rewriting workaround) and its registration in AddAgentIdentities — no longer needed - Remove dead agent identity extraction code from ROPC path - Add 3 OID-specific tests: cache on second call, fresh ClaimsPrincipal per call, and UPN/OID cache isolation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Build.props | 2 +- .../AgentIdentitiesExtension.cs | 7 - .../AgentUserIdentityMsalAddIn.cs | 102 ------------ .../TokenAcquisition.cs | 99 +++++++----- .../TokenAcquisitionTests.cs | 145 +++++++++++++++++- 5 files changed, 203 insertions(+), 152 deletions(-) delete mode 100644 src/Microsoft.Identity.Web.AgentIdentities/AgentUserIdentityMsalAddIn.cs diff --git a/Directory.Build.props b/Directory.Build.props index f88d98466..22fc78031 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -80,7 +80,7 @@ 8.18.0 - 4.84.1 + 4.84.2 12.0.0 3.3.0 4.7.2 diff --git a/src/Microsoft.Identity.Web.AgentIdentities/AgentIdentitiesExtension.cs b/src/Microsoft.Identity.Web.AgentIdentities/AgentIdentitiesExtension.cs index a73f1bfe0..c0e566e45 100644 --- a/src/Microsoft.Identity.Web.AgentIdentities/AgentIdentitiesExtension.cs +++ b/src/Microsoft.Identity.Web.AgentIdentities/AgentIdentitiesExtension.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using Microsoft.Extensions.DependencyInjection; using Microsoft.Identity.Abstractions; -using Microsoft.Identity.Web.AgentIdentities; namespace Microsoft.Identity.Web { @@ -26,12 +25,6 @@ public static IServiceCollection AddAgentIdentities(this IServiceCollection serv // Register the OidcFic services for agent applications to work. services.AddOidcFic(); - // Register a callback to process the agent user identity before acquiring a token. - services.Configure(options => - { - options.OnBeforeTokenAcquisitionForTestUserAsync += AgentUserIdentityMsalAddIn.OnBeforeUserFicForAgentUserIdentityAsync; - }); - return services; } diff --git a/src/Microsoft.Identity.Web.AgentIdentities/AgentUserIdentityMsalAddIn.cs b/src/Microsoft.Identity.Web.AgentIdentities/AgentUserIdentityMsalAddIn.cs deleted file mode 100644 index 259175bbc..000000000 --- a/src/Microsoft.Identity.Web.AgentIdentities/AgentUserIdentityMsalAddIn.cs +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System; -using System.Security.Claims; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Microsoft.Identity.Abstractions; -using Microsoft.Identity.Client; -using Microsoft.Identity.Client.Extensibility; - -namespace Microsoft.Identity.Web.AgentIdentities -{ - internal static class AgentUserIdentityMsalAddIn - { - internal static Task OnBeforeUserFicForAgentUserIdentityAsync( - AcquireTokenByUsernameAndPasswordConfidentialParameterBuilder builder, - AcquireTokenOptions? options, - ClaimsPrincipal user) - { - if (options == null || options.ExtraParameters == null) - { - return Task.CompletedTask; - } - IServiceProvider serviceProvider = (IServiceProvider)options.ExtraParameters![Constants.ExtensionOptionsServiceProviderKey]; - options.ExtraParameters.TryGetValue(Constants.AgentIdentityKey, out object? agentIdentityObject); - options.ExtraParameters.TryGetValue(Constants.UsernameKey, out object? usernameObject); - options.ExtraParameters.TryGetValue(Constants.UserIdKey, out object? userIdObject); - if (agentIdentityObject is string agentIdentity && (usernameObject is string || userIdObject is string)) - { - // Register the MSAL extension that will modify the token request just in time. - MsalAuthenticationExtension extension = new() - { - OnBeforeTokenRequestHandler = async (request) => - { - // Get the services from the service provider. - ITokenAcquirerFactory tokenAcquirerFactory = serviceProvider.GetRequiredService(); - Abstractions.IAuthenticationSchemeInformationProvider authenticationSchemeInformationProvider = - serviceProvider.GetRequiredService(); - IOptionsMonitor optionsMonitor = - serviceProvider.GetRequiredService>(); - - // Get the FIC token for the agent application. - string authenticationScheme = authenticationSchemeInformationProvider.GetEffectiveAuthenticationScheme(options.AuthenticationOptionsName); - ITokenAcquirer agentApplicationTokenAcquirer = tokenAcquirerFactory.GetTokenAcquirer(authenticationScheme); - AcquireTokenResult aaFic = await agentApplicationTokenAcquirer.GetFicTokenAsync(new() { Tenant = options.Tenant, FmiPath = agentIdentity }); // Uses the regular client credentials - string? clientAssertion = aaFic.AccessToken; - - // Get the FIC token for the agent identity. - MicrosoftIdentityApplicationOptions microsoftIdentityApplicationOptions = optionsMonitor.Get(authenticationScheme); - ITokenAcquirer agentIdentityTokenAcquirer = tokenAcquirerFactory.GetTokenAcquirer(new MicrosoftIdentityApplicationOptions - { - ClientId = agentIdentity, - Instance = microsoftIdentityApplicationOptions.Instance, - Authority = microsoftIdentityApplicationOptions.Authority, - TenantId = options.Tenant ?? microsoftIdentityApplicationOptions.TenantId - }); - AcquireTokenResult aidFic = await agentIdentityTokenAcquirer.GetFicTokenAsync(options: new() { Tenant = options.Tenant }, clientAssertion: clientAssertion); // Uses the agent identity - string? userFicAssertion = aidFic.AccessToken; - - // Already in the request: - // - client_id = agentIdentity; - - // User FIC parameters - request.BodyParameters["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; - request.BodyParameters["client_assertion"] = clientAssertion; - - // Handle UPN vs OID: UPN takes precedence if both are present - if (usernameObject is string username && !string.IsNullOrEmpty(username)) - { - request.BodyParameters["username"] = username; - if (request.BodyParameters.ContainsKey("user_id")) - { - request.BodyParameters.Remove("user_id"); - } - } - else if (userIdObject is string userId && !string.IsNullOrEmpty(userId)) - { - request.BodyParameters["user_id"] = userId; - if (request.BodyParameters.ContainsKey("username")) - { - request.BodyParameters.Remove("username"); - } - } - - request.BodyParameters["user_federated_identity_credential"] = userFicAssertion; - request.BodyParameters["grant_type"] = "user_fic"; - request.BodyParameters.Remove("password"); - - if (request.BodyParameters.TryGetValue("client_secret", out var secret)) - { - request.BodyParameters.Remove("client_secret"); - } - } - }; - builder.WithAuthenticationExtension(extension); - } - return Task.CompletedTask; - } - } -} diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 7e3774840..0583a9124 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -68,14 +68,15 @@ class OAuthConstants private readonly ConcurrentDictionary _agentCcaSemaphores = new(); /// - /// Maps (agentAppId, username, tenantId) tuples to MSAL account identifiers for the + /// Maps (agentAppId, user identifier, tenantId) tuples to MSAL account identifiers for the /// native User FIC flow. This is needed because AcquireTokenSilent requires an IAccount, /// which can only be obtained from GetAccountAsync(identifier) using an identifier that /// comes back from a prior token acquisition. In all other ID Web flows, this identifier /// is stored in the ClaimsPrincipal (via GetMsalAccountId / oid+tid claims). In the /// agentic scenario, however, ClaimsPrincipal is typically null or freshly created per /// request (bot/service pattern), so there is no persistent object to write back to. - /// This dictionary fills that role, keyed by "{agentAppId}:{USERNAME}:{TENANTID}". + /// This dictionary fills that role, keyed by "{agentAppId}:{USER_IDENTIFIER}:{TENANTID}" + /// where USER_IDENTIFIER is either the normalized UPN or OID. /// Entries are cleaned up when MSAL evicts the corresponding account from its cache. /// private readonly ConcurrentDictionary _agentUserFicAccountIds = new(); @@ -425,9 +426,8 @@ private async Task GetAuthenticationResultForUserInternalA MergedOptions mergedOptions, TokenAcquisitionOptions? tokenAcquisitionOptions) { - // Handle UPN-based agentic User FIC flow using MSAL's native UserFIC API. + // Handle agentic User FIC flow (both UPN and OID) using MSAL's native UserFIC API. // This bypasses the ROPC piggybacking approach and provides proper cache behavior. - // OID-based agentic flows continue through the existing ROPC + add-in path below. var agentUserFicResult = await TryGetAuthenticationResultForAgentUserFicAsync( application, tenantId, scopes, mergedOptions, tokenAcquisitionOptions).ConfigureAwait(false); if (agentUserFicResult is not null) @@ -437,7 +437,6 @@ private async Task GetAuthenticationResultForUserInternalA string? username = null; string? password = null; - string? agentIdentity = string.Empty; // Case where the user is passed through the Claims identity if (user != null && user.HasClaim(c => c.Type == ClaimConstants.Username) && user.HasClaim(c => c.Type == ClaimConstants.Password)) @@ -446,22 +445,6 @@ private async Task GetAuthenticationResultForUserInternalA password = user.FindFirst(ClaimConstants.Password)?.Value ?? string.Empty; } - // Case of the Agent User identities - var extraParameters = tokenAcquisitionOptions?.ExtraParameters; - if (extraParameters != null && extraParameters.ContainsKey(Constants.AgentIdentityKey) && extraParameters.ContainsKey(Constants.UsernameKey)) - { - // If the agentId is present, we can use it - username = extraParameters[Constants.UsernameKey] as string; - agentIdentity = extraParameters[Constants.AgentIdentityKey] as string; - password = "password"; - } - else if (extraParameters != null && extraParameters.ContainsKey(Constants.AgentIdentityKey) && extraParameters.ContainsKey(Constants.UserIdKey)) - { - username = extraParameters[Constants.UserIdKey]?.ToString(); - agentIdentity = extraParameters[Constants.AgentIdentityKey] as string; - password = "password"; // placeholder removed by add-in - } - if (username == null) { return null; @@ -559,16 +542,17 @@ private async Task GetAuthenticationResultForUserInternalA } /// - /// Handles UPN-based agentic User FIC flow using MSAL's native - /// - /// API. This replaces the ROPC piggybacking approach for UPN scenarios, providing proper - /// token cache behavior via MSAL's built-in cache. + /// Handles agentic User FIC flow using MSAL's native + /// AcquireTokenByUserFederatedIdentityCredential + /// API (UPN overload for username-based flows, OID overload for user object ID flows). + /// This replaces the ROPC piggybacking approach, providing proper token cache behavior + /// via MSAL's built-in cache. /// /// The flow follows the multi-CCA pattern: /// Leg 1: Blueprint CCA acquires FMI token (T1) for the agent — handled transparently /// by the agent CCA's assertion callback (see ). /// Leg 2: Agent CCA acquires instance token (T2) via AcquireTokenForClient. - /// Leg 3: Agent CCA exchanges T2 + username for a user-scoped token via native UserFIC. + /// Leg 3: Agent CCA exchanges T2 + user identifier for a user-scoped token via native UserFIC. /// /// On subsequent calls, AcquireTokenSilent returns the cached token without network calls. /// Unlike other ID Web flows where the MSAL account identifier is stored in the @@ -576,8 +560,8 @@ private async Task GetAuthenticationResultForUserInternalA /// request-scoped ClaimsPrincipal — so account identifiers are tracked in /// instead. /// - /// An if this is a UPN-based agentic flow; - /// null if the request is not an agentic UPN flow (OID or regular ROPC). + /// An if this is an agentic User FIC flow + /// (UPN or OID); null if not an agentic flow (regular ROPC). private async Task TryGetAuthenticationResultForAgentUserFicAsync( IConfidentialClientApplication application, string? tenantId, @@ -587,19 +571,39 @@ private async Task GetAuthenticationResultForUserInternalA { var extraParameters = tokenAcquisitionOptions?.ExtraParameters; - // Only handle UPN-based agentic flows (AgentIdentityKey + UsernameKey). - // OID-based flows (UserIdKey) continue through the existing ROPC + add-in path. + // Detect agentic flow: requires AgentIdentityKey plus either UsernameKey (UPN) or UserIdKey (OID). if (extraParameters is null - || !extraParameters.TryGetValue(Constants.AgentIdentityKey, out object? agentObj) - || !extraParameters.ContainsKey(Constants.UsernameKey)) + || !extraParameters.TryGetValue(Constants.AgentIdentityKey, out object? agentObj)) { return null; } string? agentAppId = agentObj as string; - string? username = extraParameters[Constants.UsernameKey] as string; - if (string.IsNullOrEmpty(agentAppId) || string.IsNullOrEmpty(username)) + if (string.IsNullOrEmpty(agentAppId)) + { + return null; + } + + // Determine user identifier: UPN takes precedence over OID (matching WithAgentUserIdentity behavior). + string? username = null; + Guid? userObjectId = null; + string? userIdentifierForCacheKey = null; + + if (extraParameters.TryGetValue(Constants.UsernameKey, out object? usernameObj) + && usernameObj is string upn && !string.IsNullOrEmpty(upn)) + { + username = upn; + userIdentifierForCacheKey = upn.ToUpperInvariant(); + } + else if (extraParameters.TryGetValue(Constants.UserIdKey, out object? userIdObj) + && userIdObj is string oidStr && Guid.TryParse(oidStr, out Guid parsedOid)) + { + userObjectId = parsedOid; + userIdentifierForCacheKey = parsedOid.ToString("D").ToUpperInvariant(); + } + else { + // Neither UPN nor valid OID — not a user FIC flow we can handle. return null; } @@ -612,7 +616,7 @@ private async Task GetAuthenticationResultForUserInternalA // Try silent retrieval first using a stored account identifier from a prior call. // Include tenantId in the key so cross-tenant calls don't collide. string normalizedTenant = tenantId?.ToUpperInvariant() ?? string.Empty; - string accountLookupKey = $"{agentAppId}:{username!.ToUpperInvariant()}:{normalizedTenant}"; + string accountLookupKey = $"{agentAppId}:{userIdentifierForCacheKey}:{normalizedTenant}"; if (!forceRefresh && _agentUserFicAccountIds.TryGetValue(accountLookupKey, out string? cachedAccountId) && !string.IsNullOrEmpty(cachedAccountId)) @@ -654,12 +658,25 @@ private async Task GetAuthenticationResultForUserInternalA var leg2 = await leg2Builder.ExecuteAsync().ConfigureAwait(false); - // Leg 3: Exchange T2 + username for a user-scoped token via native UserFIC. - var leg3Builder = ((IByUserFederatedIdentityCredential)agentCca) - .AcquireTokenByUserFederatedIdentityCredential( - scopes.Except(_scopesRequestedByMsal), - username, - leg2.AccessToken); + // Leg 3: Exchange T2 + user identifier for a user-scoped token via native UserFIC. + // Uses the UPN overload when username is available, OID overload otherwise. + AcquireTokenByUserFederatedIdentityCredentialParameterBuilder leg3Builder; + if (username is not null) + { + leg3Builder = ((IByUserFederatedIdentityCredential)agentCca) + .AcquireTokenByUserFederatedIdentityCredential( + scopes.Except(_scopesRequestedByMsal), + username, + leg2.AccessToken); + } + else + { + leg3Builder = ((IByUserFederatedIdentityCredential)agentCca) + .AcquireTokenByUserFederatedIdentityCredential( + scopes.Except(_scopesRequestedByMsal), + userObjectId!.Value, + leg2.AccessToken); + } if (!string.IsNullOrEmpty(tenantId)) { diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs index acd4560bf..273d0df53 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs @@ -542,6 +542,11 @@ public async Task AgentUserIdentity_NativeUserFic_CacheWorksWithNewClaimsPrincip } private static AuthorizationHeaderProviderOptions CreateAgentIdentityOptions(string agentAppId) + { + return CreateAgentIdentityOptionsWithUpn(agentAppId, AgentTestUsername); + } + + private static AuthorizationHeaderProviderOptions CreateAgentIdentityOptionsWithUpn(string agentAppId, string username) { return new AuthorizationHeaderProviderOptions { @@ -550,7 +555,22 @@ private static AuthorizationHeaderProviderOptions CreateAgentIdentityOptions(str ExtraParameters = new Dictionary { [Constants.AgentIdentityKey] = agentAppId, - [Constants.UsernameKey] = AgentTestUsername, + [Constants.UsernameKey] = username, + } + } + }; + } + + private static AuthorizationHeaderProviderOptions CreateAgentIdentityOptionsWithOid(string agentAppId, Guid userObjectId) + { + return new AuthorizationHeaderProviderOptions + { + AcquireTokenOptions = new AcquireTokenOptions + { + ExtraParameters = new Dictionary + { + [Constants.AgentIdentityKey] = agentAppId, + [Constants.UserIdKey] = userObjectId.ToString("D"), } } }; @@ -645,6 +665,129 @@ private static string EncodeBase64Url(string input) .TrimEnd('=').Replace('+', '-').Replace('/', '_'); } + // --- OID-based User FIC tests --- + + private static readonly Guid AgentTestUserOid = new Guid("00000000-1111-2222-3333-444444444444"); + + /// + /// Verifies that OID-based agentic User FIC flow uses the native path and caches properly. + /// Same pattern as UPN but uses Guid userObjectId overload. + /// + [Fact] + public async Task AgentUserIdentity_NativeUserFic_OidUsesCacheOnSecondCall() + { + // Arrange + string agentAppId = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "user-token-oid-1"); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + var options = CreateAgentIdentityOptionsWithOid(agentAppId, AgentTestUserOid); + + // Act — first call: full 3-leg flow + string result1 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: null); + + // Act — second call: should use AcquireTokenSilent (no mock handlers left) + string result2 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: null); + + // Assert — both return the same cached token + Assert.Equal("Bearer user-token-oid-1", result1); + Assert.Equal("Bearer user-token-oid-1", result2); + } + + /// + /// Verifies that OID-based flow works with fresh ClaimsPrincipal instances per call. + /// + [Fact] + public async Task AgentUserIdentity_NativeUserFic_OidCacheWorksWithNewClaimsPrincipalPerCall() + { + // Arrange + string agentAppId = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "user-token-oid-2"); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + var options = CreateAgentIdentityOptionsWithOid(agentAppId, AgentTestUserOid); + + // Act — each call gets a fresh ClaimsPrincipal (simulates request-scoped DI) + string result1 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: new System.Security.Claims.ClaimsPrincipal( + new Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity())); + + string result2 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: new System.Security.Claims.ClaimsPrincipal( + new Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity())); + + // Assert + Assert.Equal("Bearer user-token-oid-2", result1); + Assert.Equal("Bearer user-token-oid-2", result2); + } + + /// + /// Verifies that UPN and OID flows for the same agent produce separate cached tokens, + /// ensuring cache isolation between the two identifier types. + /// + [Fact] + public async Task AgentUserIdentity_NativeUserFic_UpnAndOidCachesAreIsolated() + { + // Arrange — same agent app ID for both flows + string agentAppId = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + + // UPN flow handlers (3 legs) + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "upn-user-token"); + // OID flow handlers (3 legs — Leg 1 may be cached from UPN flow, but Leg 2 + Leg 3 are needed) + // Note: Leg 1 (blueprint FMI) is cached in the blueprint CCA, but Leg 2 uses the agent CCA + // which also caches T2. Since OID and UPN go to the same agent CCA, Leg 2 may be cached. + // We still need a Leg 3 handler for the OID grant type. + mockHttpClient!.AddMockHandler(CreateUserFicTokenHandler(accessToken: "oid-user-token")); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + var upnOptions = CreateAgentIdentityOptionsWithUpn(agentAppId, AgentTestUsername); + var oidOptions = CreateAgentIdentityOptionsWithOid(agentAppId, AgentTestUserOid); + + // Act — UPN flow first + string upnResult = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: upnOptions, + claimsPrincipal: null); + + // Act — OID flow for same agent + string oidResult = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: oidOptions, + claimsPrincipal: null); + + // Assert — different tokens, not sharing cache entries + Assert.Equal("Bearer upn-user-token", upnResult); + Assert.Equal("Bearer oid-user-token", oidResult); + } + #endregion } } From 9821f5781952ad1a0082d59b5c0954b2e248ca8a Mon Sep 17 00:00:00 2001 From: avdunn Date: Fri, 5 Jun 2026 07:43:58 -0700 Subject: [PATCH 03/20] Better handling of national cloud endpoints for FIC scope --- .../Constants.cs | 6 ++ .../TokenAcquisition.cs | 56 ++++++++++++++++--- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/Constants.cs b/src/Microsoft.Identity.Web.TokenAcquisition/Constants.cs index b7c9f5fcb..42f507c19 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/Constants.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/Constants.cs @@ -236,6 +236,12 @@ public static class Constants * Treat as a public member. */ internal const string MicrosoftIdentityOptionsParameter = "IDWEB_FMI_MICROSOFT_IDENTITY_OPTIONS"; + /* + * Used by Microsoft.Identity.Web.AgentIdentities + * Any changes to this member (including removal) can cause runtime failures. + * Treat as a public member. + */ + internal const string TokenExchangeUrlKey = "IDWEB_TOKEN_EXCHANGE_URL"; /// /// Error codes indicating certificate or signed assertion issues that warrant retry with a new certificate. diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 0583a9124..d3765206e 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -63,6 +63,9 @@ class OAuthConstants /// Caches agent CCAs for the native User FIC flow. Each agent CCA uses an assertion /// callback that chains to the blueprint CCA for Leg 1 (FMI token acquisition). /// Key format: "{agentAppId}:{authenticationScheme}". + /// The authenticationScheme is included because different schemes may resolve to + /// different blueprint credentials (e.g. certificates), and the agent CCA's assertion + /// callback captures the scheme to chain to the correct blueprint CCA. /// private readonly ConcurrentDictionary _agentUserFicCcas = new(); private readonly ConcurrentDictionary _agentCcaSemaphores = new(); @@ -80,7 +83,18 @@ class OAuthConstants /// Entries are cleaned up when MSAL evicts the corresponding account from its cache. /// private readonly ConcurrentDictionary _agentUserFicAccountIds = new(); - private static readonly string[] s_ficScopes = new[] { "api://AzureADTokenExchange/.default" }; + + /// + /// Default FIC token exchange URL for the public cloud. For other clouds, callers can + /// override via ExtraParameters[Constants.TokenExchangeUrlKey]: + /// + /// api://AzureADTokenExchangepublic cloud (default) + /// api://AzureADTokenExchangeChinaChina cloud + /// api://AzureADTokenExchangeFranceBleu + /// api://AzureADTokenExchangeGermanyGerman cloud + /// + /// + private const string DefaultTokenExchangeUrl = "api://AzureADTokenExchange"; private const string TokenBindingParameterName = "IsTokenBinding"; private const int MaxCertificateRetries = 1; @@ -608,8 +622,12 @@ private async Task GetAuthenticationResultForUserInternalA } string? authScheme = tokenAcquisitionOptions?.AuthenticationOptionsName; + + // Resolve the FIC token exchange scope, allowing national cloud overrides. + string[] ficScopes = ResolveFicScopes(extraParameters); + var agentCca = await GetOrBuildAgentUserFicCcaAsync( - agentAppId!, application.Authority, authScheme).ConfigureAwait(false); + agentAppId!, application.Authority, authScheme, ficScopes).ConfigureAwait(false); bool forceRefresh = tokenAcquisitionOptions?.ForceRefresh ?? false; @@ -650,7 +668,7 @@ private async Task GetAuthenticationResultForUserInternalA // Leg 2: Get the agent's instance token (T2). // The assertion callback handles Leg 1 (blueprint → T1) transparently. - var leg2Builder = agentCca.AcquireTokenForClient(s_ficScopes); + var leg2Builder = agentCca.AcquireTokenForClient(ficScopes); if (!string.IsNullOrEmpty(tenantId)) { leg2Builder.WithTenantId(tenantId); @@ -705,7 +723,8 @@ private async Task GetAuthenticationResultForUserInternalA private async Task GetOrBuildAgentUserFicCcaAsync( string agentAppId, string authority, - string? authenticationScheme) + string? authenticationScheme, + string[] ficScopes) { // Include authenticationScheme in the CCA cache key so different schemes // (pointing to different blueprint credentials) get separate CCAs. @@ -725,8 +744,9 @@ private async Task GetOrBuildAgentUserFicCcaAsyn return app; } - // Capture authenticationScheme for the assertion callback closure. + // Capture authenticationScheme and ficScopes for the assertion callback closure. string? capturedAuthScheme = authenticationScheme; + string[] capturedFicScopes = ficScopes; var newApp = ConfidentialClientApplicationBuilder .Create(agentAppId) @@ -740,7 +760,7 @@ private async Task GetOrBuildAgentUserFicCcaAsyn blueprintOptions, isTokenBinding: false).ConfigureAwait(false); var leg1 = await blueprintCca - .AcquireTokenForClient(s_ficScopes) + .AcquireTokenForClient(capturedFicScopes) .WithFmiPath(agentAppId) .WithSendX5C(blueprintOptions.SendX5C) .ExecuteAsync(options.CancellationToken) @@ -750,7 +770,6 @@ private async Task GetOrBuildAgentUserFicCcaAsyn }) .WithAuthority(authority) .WithHttpClientFactory(_httpClientFactory) - .WithInstanceDiscovery(false) // Blueprint already validated the authority. .WithExperimentalFeatures() .Build(); @@ -763,6 +782,29 @@ private async Task GetOrBuildAgentUserFicCcaAsyn } } + /// + /// Resolves the FIC token exchange scope from ExtraParameters, falling back to the + /// public cloud default. Matches the override pattern used by + /// and OidcIdpSignedAssertionProvider. + /// + private static string[] ResolveFicScopes(IDictionary? extraParameters) + { + string tokenExchangeUrl = DefaultTokenExchangeUrl; + if (extraParameters is not null + && extraParameters.TryGetValue(Constants.TokenExchangeUrlKey, out object? urlObj) + && urlObj is string customUrl + && !string.IsNullOrEmpty(customUrl)) + { + tokenExchangeUrl = customUrl; + } + + string scope = tokenExchangeUrl.EndsWith("/.default", StringComparison.OrdinalIgnoreCase) + ? tokenExchangeUrl + : tokenExchangeUrl + "/.default"; + + return new[] { scope }; + } + private void LogAuthResult(AuthenticationResult? authenticationResult) { if (authenticationResult != null) From 595247065634768183f9b2908ec432169fdc8d06 Mon Sep 17 00:00:00 2001 From: avdunn Date: Thu, 4 Jun 2026 09:46:26 -0700 Subject: [PATCH 04/20] Fix #3840: Use native MSAL UserFIC API for agentic UPN flows Replace ROPC piggybacking with MSAL's native AcquireTokenByUserFederatedIdentityCredential API using the multi-CCA pattern (blueprint + per-agent CCAs with assertion callbacks). This enables proper token caching for agentic User FIC flows when ClaimsPrincipal is null, eliminating 2-4 unnecessary network round-trips per bot message. Phase 1: UPN-based flows only. OID-based flows remain on the existing ROPC+add-in path pending MSAL .NET support for the OID overload. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TokenAcquisition.cs | 220 +++++++++++++++++ .../TokenAcquisitionTests.cs | 229 ++++++++++++++++++ 2 files changed, 449 insertions(+) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index ddc96fd65..7e3774840 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -59,6 +59,28 @@ class OAuthConstants private readonly ConcurrentDictionary _applicationsByAuthorityClientId = new(); private readonly ConcurrentDictionary _appSemaphores = new(); + /// + /// Caches agent CCAs for the native User FIC flow. Each agent CCA uses an assertion + /// callback that chains to the blueprint CCA for Leg 1 (FMI token acquisition). + /// Key format: "{agentAppId}:{authenticationScheme}". + /// + private readonly ConcurrentDictionary _agentUserFicCcas = new(); + private readonly ConcurrentDictionary _agentCcaSemaphores = new(); + + /// + /// Maps (agentAppId, username, tenantId) tuples to MSAL account identifiers for the + /// native User FIC flow. This is needed because AcquireTokenSilent requires an IAccount, + /// which can only be obtained from GetAccountAsync(identifier) using an identifier that + /// comes back from a prior token acquisition. In all other ID Web flows, this identifier + /// is stored in the ClaimsPrincipal (via GetMsalAccountId / oid+tid claims). In the + /// agentic scenario, however, ClaimsPrincipal is typically null or freshly created per + /// request (bot/service pattern), so there is no persistent object to write back to. + /// This dictionary fills that role, keyed by "{agentAppId}:{USERNAME}:{TENANTID}". + /// Entries are cleaned up when MSAL evicts the corresponding account from its cache. + /// + private readonly ConcurrentDictionary _agentUserFicAccountIds = new(); + private static readonly string[] s_ficScopes = new[] { "api://AzureADTokenExchange/.default" }; + private const string TokenBindingParameterName = "IsTokenBinding"; private const int MaxCertificateRetries = 1; protected readonly IMsalHttpClientFactory _httpClientFactory; @@ -403,6 +425,16 @@ private async Task GetAuthenticationResultForUserInternalA MergedOptions mergedOptions, TokenAcquisitionOptions? tokenAcquisitionOptions) { + // Handle UPN-based agentic User FIC flow using MSAL's native UserFIC API. + // This bypasses the ROPC piggybacking approach and provides proper cache behavior. + // OID-based agentic flows continue through the existing ROPC + add-in path below. + var agentUserFicResult = await TryGetAuthenticationResultForAgentUserFicAsync( + application, tenantId, scopes, mergedOptions, tokenAcquisitionOptions).ConfigureAwait(false); + if (agentUserFicResult is not null) + { + return agentUserFicResult; + } + string? username = null; string? password = null; string? agentIdentity = string.Empty; @@ -526,6 +558,194 @@ private async Task GetAuthenticationResultForUserInternalA return authenticationResult; } + /// + /// Handles UPN-based agentic User FIC flow using MSAL's native + /// + /// API. This replaces the ROPC piggybacking approach for UPN scenarios, providing proper + /// token cache behavior via MSAL's built-in cache. + /// + /// The flow follows the multi-CCA pattern: + /// Leg 1: Blueprint CCA acquires FMI token (T1) for the agent — handled transparently + /// by the agent CCA's assertion callback (see ). + /// Leg 2: Agent CCA acquires instance token (T2) via AcquireTokenForClient. + /// Leg 3: Agent CCA exchanges T2 + username for a user-scoped token via native UserFIC. + /// + /// On subsequent calls, AcquireTokenSilent returns the cached token without network calls. + /// Unlike other ID Web flows where the MSAL account identifier is stored in the + /// ClaimsPrincipal (via oid/tid claims), the agentic scenario typically has a null or + /// request-scoped ClaimsPrincipal — so account identifiers are tracked in + /// instead. + /// + /// An if this is a UPN-based agentic flow; + /// null if the request is not an agentic UPN flow (OID or regular ROPC). + private async Task TryGetAuthenticationResultForAgentUserFicAsync( + IConfidentialClientApplication application, + string? tenantId, + IEnumerable scopes, + MergedOptions mergedOptions, + TokenAcquisitionOptions? tokenAcquisitionOptions) + { + var extraParameters = tokenAcquisitionOptions?.ExtraParameters; + + // Only handle UPN-based agentic flows (AgentIdentityKey + UsernameKey). + // OID-based flows (UserIdKey) continue through the existing ROPC + add-in path. + if (extraParameters is null + || !extraParameters.TryGetValue(Constants.AgentIdentityKey, out object? agentObj) + || !extraParameters.ContainsKey(Constants.UsernameKey)) + { + return null; + } + + string? agentAppId = agentObj as string; + string? username = extraParameters[Constants.UsernameKey] as string; + if (string.IsNullOrEmpty(agentAppId) || string.IsNullOrEmpty(username)) + { + return null; + } + + string? authScheme = tokenAcquisitionOptions?.AuthenticationOptionsName; + var agentCca = await GetOrBuildAgentUserFicCcaAsync( + agentAppId!, application.Authority, authScheme).ConfigureAwait(false); + + bool forceRefresh = tokenAcquisitionOptions?.ForceRefresh ?? false; + + // Try silent retrieval first using a stored account identifier from a prior call. + // Include tenantId in the key so cross-tenant calls don't collide. + string normalizedTenant = tenantId?.ToUpperInvariant() ?? string.Empty; + string accountLookupKey = $"{agentAppId}:{username!.ToUpperInvariant()}:{normalizedTenant}"; + if (!forceRefresh + && _agentUserFicAccountIds.TryGetValue(accountLookupKey, out string? cachedAccountId) + && !string.IsNullOrEmpty(cachedAccountId)) + { + var account = await agentCca.GetAccountAsync(cachedAccountId).ConfigureAwait(false); + if (account is not null) + { + try + { + var silentBuilder = agentCca.AcquireTokenSilent( + scopes.Except(_scopesRequestedByMsal), + account); + if (!string.IsNullOrEmpty(tenantId)) + { + silentBuilder.WithTenantId(tenantId); + } + + return await silentBuilder.ExecuteAsync().ConfigureAwait(false); + } + catch (MsalUiRequiredException ex) + { + Logger.TokenAcquisitionError(_logger, ex.Message, ex); + } + } + else + { + // Account was evicted from MSAL's cache — remove stale mapping. + _agentUserFicAccountIds.TryRemove(accountLookupKey, out _); + } + } + + // Leg 2: Get the agent's instance token (T2). + // The assertion callback handles Leg 1 (blueprint → T1) transparently. + var leg2Builder = agentCca.AcquireTokenForClient(s_ficScopes); + if (!string.IsNullOrEmpty(tenantId)) + { + leg2Builder.WithTenantId(tenantId); + } + + var leg2 = await leg2Builder.ExecuteAsync().ConfigureAwait(false); + + // Leg 3: Exchange T2 + username for a user-scoped token via native UserFIC. + var leg3Builder = ((IByUserFederatedIdentityCredential)agentCca) + .AcquireTokenByUserFederatedIdentityCredential( + scopes.Except(_scopesRequestedByMsal), + username, + leg2.AccessToken); + + if (!string.IsNullOrEmpty(tenantId)) + { + leg3Builder.WithTenantId(tenantId); + } + + var result = await leg3Builder.ExecuteAsync().ConfigureAwait(false); + + // Store the account identifier for subsequent silent lookups. + // This parallels how other ID Web flows write oid/tid claims back into the + // ClaimsPrincipal after acquisition (see line ~541 in the ROPC path). Here, + // ClaimsPrincipal is unavailable, so we use _agentUserFicAccountIds instead. + if (result.Account?.HomeAccountId is not null) + { + _agentUserFicAccountIds[accountLookupKey] = result.Account.HomeAccountId.Identifier; + } + + return result; + } + + /// + /// Gets or builds an agent CCA for the native User FIC flow. Each agent CCA uses an + /// assertion callback that chains back to the blueprint CCA for Leg 1 (FMI token). + /// The agent CCA's in-memory cache provides natural token isolation per agent. + /// + private async Task GetOrBuildAgentUserFicCcaAsync( + string agentAppId, + string authority, + string? authenticationScheme) + { + // Include authenticationScheme in the CCA cache key so different schemes + // (pointing to different blueprint credentials) get separate CCAs. + string ccaCacheKey = $"{agentAppId}:{authenticationScheme ?? string.Empty}"; + + if (_agentUserFicCcas.TryGetValue(ccaCacheKey, out var existing)) + { + return existing; + } + + var semaphore = _agentCcaSemaphores.GetOrAdd(ccaCacheKey, _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync().ConfigureAwait(false); + try + { + if (_agentUserFicCcas.TryGetValue(ccaCacheKey, out var app)) + { + return app; + } + + // Capture authenticationScheme for the assertion callback closure. + string? capturedAuthScheme = authenticationScheme; + + var newApp = ConfidentialClientApplicationBuilder + .Create(agentAppId) + .WithClientAssertion(async (AssertionRequestOptions options) => + { + // Leg 1: Blueprint acquires FMI token (T1) for this agent. + // AcquireTokenForClient checks cache first — only the first call + // or an expired T1 hits the network. + MergedOptions blueprintOptions = _tokenAcquisitionHost.GetOptions(capturedAuthScheme, out _); + var blueprintCca = await GetOrBuildConfidentialClientApplicationAsync( + blueprintOptions, isTokenBinding: false).ConfigureAwait(false); + + var leg1 = await blueprintCca + .AcquireTokenForClient(s_ficScopes) + .WithFmiPath(agentAppId) + .WithSendX5C(blueprintOptions.SendX5C) + .ExecuteAsync(options.CancellationToken) + .ConfigureAwait(false); + + return leg1.AccessToken; + }) + .WithAuthority(authority) + .WithHttpClientFactory(_httpClientFactory) + .WithInstanceDiscovery(false) // Blueprint already validated the authority. + .WithExperimentalFeatures() + .Build(); + + _agentUserFicCcas[ccaCacheKey] = newApp; + return newApp; + } + finally + { + semaphore.Release(); + } + } + private void LogAuthResult(AuthenticationResult? authenticationResult) { if (authenticationResult != null) diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs index ef66fa427..acd4560bf 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs @@ -417,5 +417,234 @@ private static System.Security.Cryptography.X509Certificates.X509Certificate2 Cr return certificate; } + + #region Agent User Identity Cache Tests (Issue #3840) + + private const string AgentTestUsername = "testuser@contoso.com"; + + /// + /// Verifies the fix for GitHub issue #3840: the native User FIC flow uses the + /// multi-CCA pattern (blueprint + agent CCA) and caches user tokens properly. + /// The second call should use AcquireTokenSilent — no additional network calls. + /// + [Fact] + public async Task AgentUserIdentity_NativeUserFic_UsesCacheOnSecondCall() + { + // Arrange — use a unique agent app ID to avoid MSAL shared cache interference + string agentAppId = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + + // First call needs 3 handlers: Leg 1 (blueprint FMI), Leg 2 (agent instance), Leg 3 (user_fic). + // Second call needs 0 handlers (silent cache hit). + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "user-token-1"); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + var options = CreateAgentIdentityOptions(agentAppId); + + // Act — first call: full 3-leg flow (Leg 1 → T1, Leg 2 → T2, Leg 3 → user token) + string result1 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: null); + + // Act — second call: should use AcquireTokenSilent (no mock handlers left) + string result2 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: null); + + // Assert — both return the same token, second from cache + Assert.Equal("Bearer user-token-1", result1); + Assert.Equal("Bearer user-token-1", result2); + } + + /// + /// Verifies that the native User FIC path works even when ClaimsPrincipal is non-null. + /// The UPN agentic flow always uses the native path regardless of ClaimsPrincipal state. + /// + [Fact] + public async Task AgentUserIdentity_NativeUserFic_WorksWithNonNullClaimsPrincipal() + { + // Arrange + string agentAppId = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "user-token-1"); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + var claimsPrincipal = new System.Security.Claims.ClaimsPrincipal( + new Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity()); + var options = CreateAgentIdentityOptions(agentAppId); + + // Act — first call with non-null ClaimsPrincipal + string result1 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: claimsPrincipal); + + // Act — second call: should still use cache + string result2 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: claimsPrincipal); + + // Assert + Assert.Equal("Bearer user-token-1", result1); + Assert.Equal("Bearer user-token-1", result2); + } + + /// + /// Verifies cache works with new ClaimsPrincipal instances per call. Unlike the + /// old ROPC path, the native User FIC path does not depend on ClaimsPrincipal + /// for cache lookups — the account identifier is stored internally. + /// + [Fact] + public async Task AgentUserIdentity_NativeUserFic_CacheWorksWithNewClaimsPrincipalPerCall() + { + // Arrange + string agentAppId = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "user-token-1"); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + var options = CreateAgentIdentityOptions(agentAppId); + + // Act — each call gets a fresh ClaimsPrincipal (simulates request-scoped DI) + string result1 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: new System.Security.Claims.ClaimsPrincipal( + new Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity())); + + string result2 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: new System.Security.Claims.ClaimsPrincipal( + new Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity())); + + // Assert — both return the same cached token (unlike old ROPC path) + Assert.Equal("Bearer user-token-1", result1); + Assert.Equal("Bearer user-token-1", result2); + } + + private static AuthorizationHeaderProviderOptions CreateAgentIdentityOptions(string agentAppId) + { + return new AuthorizationHeaderProviderOptions + { + AcquireTokenOptions = new AcquireTokenOptions + { + ExtraParameters = new Dictionary + { + [Constants.AgentIdentityKey] = agentAppId, + [Constants.UsernameKey] = AgentTestUsername, + } + } + }; + } + + private TokenAcquirerFactory InitTokenAcquirerFactoryForAgent() + { + TokenAcquirerFactoryTesting.ResetTokenAcquirerFactoryInTest(); + TokenAcquirerFactory tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance(); + + var mockHttpFactory = new MockHttpClientFactory(); + + tokenAcquirerFactory.Services.Configure(options => + { + options.Instance = "https://login.microsoftonline.com/"; + options.TenantId = "10c419d4-4a50-45b2-aa4e-919fb84df24f"; + options.ClientId = "idu773ld-e38d-jud3-45lk-d1b09a74a8ca"; + options.ClientCredentials = [new CredentialDescription() + { + SourceType = CredentialSource.ClientSecret, + ClientSecret = "someSecret" + }]; + }); + + tokenAcquirerFactory.Services.AddSingleton(mockHttpFactory); + + return tokenAcquirerFactory; + } + + /// + /// Adds mock handlers for the 3-leg agent User FIC flow: + /// Handler 1: Leg 1 — blueprint's AcquireTokenForClient (FMI token / T1) + /// Handler 2: Leg 2 — agent's AcquireTokenForClient (instance token / T2) + /// Handler 3: Leg 3 — agent's AcquireTokenByUserFederatedIdentityCredential (user token) + /// Handler 1 also auto-handles instance discovery via the mock factory's re-queue mechanism. + /// + private static void AddAgentUserFicMockHandlers( + MockHttpClientFactory mockHttpClient, + string userAccessToken = "header.payload.signature") + { + // Leg 1: Blueprint FMI token (T1) — client_credentials with fmi_path + mockHttpClient.AddMockHandler(CreateClientCredentialsTokenHandler(accessToken: "t1-fmi-token")); + + // Leg 2: Agent instance token (T2) — client_credentials with T1 as assertion + mockHttpClient.AddMockHandler(CreateClientCredentialsTokenHandler(accessToken: "t2-instance-token")); + + // Leg 3: User token — user_fic grant with T2 as assertion + mockHttpClient.AddMockHandler(CreateUserFicTokenHandler(accessToken: userAccessToken)); + } + + /// + /// Creates a mock handler for a client_credentials response (used for Legs 1 and 2). + /// + private static MockHttpMessageHandler CreateClientCredentialsTokenHandler(string accessToken) + { + return new MockHttpMessageHandler() + { + ExpectedMethod = HttpMethod.Post, + ResponseMessage = MockHttpCreator.CreateSuccessResponseMessage( + "{\"token_type\":\"Bearer\"," + + "\"expires_in\":3599," + + "\"access_token\":\"" + accessToken + "\"," + + "\"client_info\":\"" + EncodeBase64Url( + "{\"uid\":\"" + TestConstants.Uid + "\",\"utid\":\"" + TestConstants.Utid + "\"}") + "\"}"), + }; + } + + /// + /// Creates a mock handler for a user_fic response (Leg 3) with id_token, refresh_token, + /// and client_info so MSAL creates a proper account in the cache. + /// + private static MockHttpMessageHandler CreateUserFicTokenHandler(string accessToken) + { + return new MockHttpMessageHandler() + { + ExpectedMethod = HttpMethod.Post, + ResponseMessage = MockHttpCreator.CreateSuccessResponseMessage( + "{\"token_type\":\"Bearer\"," + + "\"expires_in\":3599," + + "\"scope\":\"https://graph.microsoft.com/.default openid profile offline_access\"," + + "\"access_token\":\"" + accessToken + "\"," + + "\"refresh_token\":\"mock-refresh-token\"," + + "\"client_info\":\"" + EncodeBase64Url( + "{\"uid\":\"" + TestConstants.Uid + "\",\"utid\":\"" + TestConstants.Utid + "\"}") + "\"," + + "\"id_token\":\"" + MockHttpCreator.CreateIdToken(TestConstants.Uid, AgentTestUsername) + "\"}"), + }; + } + + private static string EncodeBase64Url(string input) + { + return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(input)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + #endregion } } From aa4b404b6992571559379dfeb8fd943edc72a061 Mon Sep 17 00:00:00 2001 From: avdunn Date: Fri, 5 Jun 2026 06:29:05 -0700 Subject: [PATCH 05/20] Add OID UserFIC support, bump MSAL to 4.84.2, remove add-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump MSAL .NET from 4.84.1 to 4.84.2 (adds Guid userObjectId overload for AcquireTokenByUserFederatedIdentityCredential) - Extend TryGetAuthenticationResultForAgentUserFicAsync to handle both UPN-based and OID-based agentic flows via native MSAL APIs - Remove AgentUserIdentityMsalAddIn (ROPC body-rewriting workaround) and its registration in AddAgentIdentities — no longer needed - Remove dead agent identity extraction code from ROPC path - Add 3 OID-specific tests: cache on second call, fresh ClaimsPrincipal per call, and UPN/OID cache isolation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentIdentitiesExtension.cs | 7 - .../AgentUserIdentityMsalAddIn.cs | 102 ------------ .../TokenAcquisition.cs | 99 +++++++----- .../TokenAcquisitionTests.cs | 145 +++++++++++++++++- 4 files changed, 202 insertions(+), 151 deletions(-) delete mode 100644 src/Microsoft.Identity.Web.AgentIdentities/AgentUserIdentityMsalAddIn.cs diff --git a/src/Microsoft.Identity.Web.AgentIdentities/AgentIdentitiesExtension.cs b/src/Microsoft.Identity.Web.AgentIdentities/AgentIdentitiesExtension.cs index a73f1bfe0..c0e566e45 100644 --- a/src/Microsoft.Identity.Web.AgentIdentities/AgentIdentitiesExtension.cs +++ b/src/Microsoft.Identity.Web.AgentIdentities/AgentIdentitiesExtension.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using Microsoft.Extensions.DependencyInjection; using Microsoft.Identity.Abstractions; -using Microsoft.Identity.Web.AgentIdentities; namespace Microsoft.Identity.Web { @@ -26,12 +25,6 @@ public static IServiceCollection AddAgentIdentities(this IServiceCollection serv // Register the OidcFic services for agent applications to work. services.AddOidcFic(); - // Register a callback to process the agent user identity before acquiring a token. - services.Configure(options => - { - options.OnBeforeTokenAcquisitionForTestUserAsync += AgentUserIdentityMsalAddIn.OnBeforeUserFicForAgentUserIdentityAsync; - }); - return services; } diff --git a/src/Microsoft.Identity.Web.AgentIdentities/AgentUserIdentityMsalAddIn.cs b/src/Microsoft.Identity.Web.AgentIdentities/AgentUserIdentityMsalAddIn.cs deleted file mode 100644 index 259175bbc..000000000 --- a/src/Microsoft.Identity.Web.AgentIdentities/AgentUserIdentityMsalAddIn.cs +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System; -using System.Security.Claims; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Microsoft.Identity.Abstractions; -using Microsoft.Identity.Client; -using Microsoft.Identity.Client.Extensibility; - -namespace Microsoft.Identity.Web.AgentIdentities -{ - internal static class AgentUserIdentityMsalAddIn - { - internal static Task OnBeforeUserFicForAgentUserIdentityAsync( - AcquireTokenByUsernameAndPasswordConfidentialParameterBuilder builder, - AcquireTokenOptions? options, - ClaimsPrincipal user) - { - if (options == null || options.ExtraParameters == null) - { - return Task.CompletedTask; - } - IServiceProvider serviceProvider = (IServiceProvider)options.ExtraParameters![Constants.ExtensionOptionsServiceProviderKey]; - options.ExtraParameters.TryGetValue(Constants.AgentIdentityKey, out object? agentIdentityObject); - options.ExtraParameters.TryGetValue(Constants.UsernameKey, out object? usernameObject); - options.ExtraParameters.TryGetValue(Constants.UserIdKey, out object? userIdObject); - if (agentIdentityObject is string agentIdentity && (usernameObject is string || userIdObject is string)) - { - // Register the MSAL extension that will modify the token request just in time. - MsalAuthenticationExtension extension = new() - { - OnBeforeTokenRequestHandler = async (request) => - { - // Get the services from the service provider. - ITokenAcquirerFactory tokenAcquirerFactory = serviceProvider.GetRequiredService(); - Abstractions.IAuthenticationSchemeInformationProvider authenticationSchemeInformationProvider = - serviceProvider.GetRequiredService(); - IOptionsMonitor optionsMonitor = - serviceProvider.GetRequiredService>(); - - // Get the FIC token for the agent application. - string authenticationScheme = authenticationSchemeInformationProvider.GetEffectiveAuthenticationScheme(options.AuthenticationOptionsName); - ITokenAcquirer agentApplicationTokenAcquirer = tokenAcquirerFactory.GetTokenAcquirer(authenticationScheme); - AcquireTokenResult aaFic = await agentApplicationTokenAcquirer.GetFicTokenAsync(new() { Tenant = options.Tenant, FmiPath = agentIdentity }); // Uses the regular client credentials - string? clientAssertion = aaFic.AccessToken; - - // Get the FIC token for the agent identity. - MicrosoftIdentityApplicationOptions microsoftIdentityApplicationOptions = optionsMonitor.Get(authenticationScheme); - ITokenAcquirer agentIdentityTokenAcquirer = tokenAcquirerFactory.GetTokenAcquirer(new MicrosoftIdentityApplicationOptions - { - ClientId = agentIdentity, - Instance = microsoftIdentityApplicationOptions.Instance, - Authority = microsoftIdentityApplicationOptions.Authority, - TenantId = options.Tenant ?? microsoftIdentityApplicationOptions.TenantId - }); - AcquireTokenResult aidFic = await agentIdentityTokenAcquirer.GetFicTokenAsync(options: new() { Tenant = options.Tenant }, clientAssertion: clientAssertion); // Uses the agent identity - string? userFicAssertion = aidFic.AccessToken; - - // Already in the request: - // - client_id = agentIdentity; - - // User FIC parameters - request.BodyParameters["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; - request.BodyParameters["client_assertion"] = clientAssertion; - - // Handle UPN vs OID: UPN takes precedence if both are present - if (usernameObject is string username && !string.IsNullOrEmpty(username)) - { - request.BodyParameters["username"] = username; - if (request.BodyParameters.ContainsKey("user_id")) - { - request.BodyParameters.Remove("user_id"); - } - } - else if (userIdObject is string userId && !string.IsNullOrEmpty(userId)) - { - request.BodyParameters["user_id"] = userId; - if (request.BodyParameters.ContainsKey("username")) - { - request.BodyParameters.Remove("username"); - } - } - - request.BodyParameters["user_federated_identity_credential"] = userFicAssertion; - request.BodyParameters["grant_type"] = "user_fic"; - request.BodyParameters.Remove("password"); - - if (request.BodyParameters.TryGetValue("client_secret", out var secret)) - { - request.BodyParameters.Remove("client_secret"); - } - } - }; - builder.WithAuthenticationExtension(extension); - } - return Task.CompletedTask; - } - } -} diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 7e3774840..0583a9124 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -68,14 +68,15 @@ class OAuthConstants private readonly ConcurrentDictionary _agentCcaSemaphores = new(); /// - /// Maps (agentAppId, username, tenantId) tuples to MSAL account identifiers for the + /// Maps (agentAppId, user identifier, tenantId) tuples to MSAL account identifiers for the /// native User FIC flow. This is needed because AcquireTokenSilent requires an IAccount, /// which can only be obtained from GetAccountAsync(identifier) using an identifier that /// comes back from a prior token acquisition. In all other ID Web flows, this identifier /// is stored in the ClaimsPrincipal (via GetMsalAccountId / oid+tid claims). In the /// agentic scenario, however, ClaimsPrincipal is typically null or freshly created per /// request (bot/service pattern), so there is no persistent object to write back to. - /// This dictionary fills that role, keyed by "{agentAppId}:{USERNAME}:{TENANTID}". + /// This dictionary fills that role, keyed by "{agentAppId}:{USER_IDENTIFIER}:{TENANTID}" + /// where USER_IDENTIFIER is either the normalized UPN or OID. /// Entries are cleaned up when MSAL evicts the corresponding account from its cache. /// private readonly ConcurrentDictionary _agentUserFicAccountIds = new(); @@ -425,9 +426,8 @@ private async Task GetAuthenticationResultForUserInternalA MergedOptions mergedOptions, TokenAcquisitionOptions? tokenAcquisitionOptions) { - // Handle UPN-based agentic User FIC flow using MSAL's native UserFIC API. + // Handle agentic User FIC flow (both UPN and OID) using MSAL's native UserFIC API. // This bypasses the ROPC piggybacking approach and provides proper cache behavior. - // OID-based agentic flows continue through the existing ROPC + add-in path below. var agentUserFicResult = await TryGetAuthenticationResultForAgentUserFicAsync( application, tenantId, scopes, mergedOptions, tokenAcquisitionOptions).ConfigureAwait(false); if (agentUserFicResult is not null) @@ -437,7 +437,6 @@ private async Task GetAuthenticationResultForUserInternalA string? username = null; string? password = null; - string? agentIdentity = string.Empty; // Case where the user is passed through the Claims identity if (user != null && user.HasClaim(c => c.Type == ClaimConstants.Username) && user.HasClaim(c => c.Type == ClaimConstants.Password)) @@ -446,22 +445,6 @@ private async Task GetAuthenticationResultForUserInternalA password = user.FindFirst(ClaimConstants.Password)?.Value ?? string.Empty; } - // Case of the Agent User identities - var extraParameters = tokenAcquisitionOptions?.ExtraParameters; - if (extraParameters != null && extraParameters.ContainsKey(Constants.AgentIdentityKey) && extraParameters.ContainsKey(Constants.UsernameKey)) - { - // If the agentId is present, we can use it - username = extraParameters[Constants.UsernameKey] as string; - agentIdentity = extraParameters[Constants.AgentIdentityKey] as string; - password = "password"; - } - else if (extraParameters != null && extraParameters.ContainsKey(Constants.AgentIdentityKey) && extraParameters.ContainsKey(Constants.UserIdKey)) - { - username = extraParameters[Constants.UserIdKey]?.ToString(); - agentIdentity = extraParameters[Constants.AgentIdentityKey] as string; - password = "password"; // placeholder removed by add-in - } - if (username == null) { return null; @@ -559,16 +542,17 @@ private async Task GetAuthenticationResultForUserInternalA } /// - /// Handles UPN-based agentic User FIC flow using MSAL's native - /// - /// API. This replaces the ROPC piggybacking approach for UPN scenarios, providing proper - /// token cache behavior via MSAL's built-in cache. + /// Handles agentic User FIC flow using MSAL's native + /// AcquireTokenByUserFederatedIdentityCredential + /// API (UPN overload for username-based flows, OID overload for user object ID flows). + /// This replaces the ROPC piggybacking approach, providing proper token cache behavior + /// via MSAL's built-in cache. /// /// The flow follows the multi-CCA pattern: /// Leg 1: Blueprint CCA acquires FMI token (T1) for the agent — handled transparently /// by the agent CCA's assertion callback (see ). /// Leg 2: Agent CCA acquires instance token (T2) via AcquireTokenForClient. - /// Leg 3: Agent CCA exchanges T2 + username for a user-scoped token via native UserFIC. + /// Leg 3: Agent CCA exchanges T2 + user identifier for a user-scoped token via native UserFIC. /// /// On subsequent calls, AcquireTokenSilent returns the cached token without network calls. /// Unlike other ID Web flows where the MSAL account identifier is stored in the @@ -576,8 +560,8 @@ private async Task GetAuthenticationResultForUserInternalA /// request-scoped ClaimsPrincipal — so account identifiers are tracked in /// instead. /// - /// An if this is a UPN-based agentic flow; - /// null if the request is not an agentic UPN flow (OID or regular ROPC). + /// An if this is an agentic User FIC flow + /// (UPN or OID); null if not an agentic flow (regular ROPC). private async Task TryGetAuthenticationResultForAgentUserFicAsync( IConfidentialClientApplication application, string? tenantId, @@ -587,19 +571,39 @@ private async Task GetAuthenticationResultForUserInternalA { var extraParameters = tokenAcquisitionOptions?.ExtraParameters; - // Only handle UPN-based agentic flows (AgentIdentityKey + UsernameKey). - // OID-based flows (UserIdKey) continue through the existing ROPC + add-in path. + // Detect agentic flow: requires AgentIdentityKey plus either UsernameKey (UPN) or UserIdKey (OID). if (extraParameters is null - || !extraParameters.TryGetValue(Constants.AgentIdentityKey, out object? agentObj) - || !extraParameters.ContainsKey(Constants.UsernameKey)) + || !extraParameters.TryGetValue(Constants.AgentIdentityKey, out object? agentObj)) { return null; } string? agentAppId = agentObj as string; - string? username = extraParameters[Constants.UsernameKey] as string; - if (string.IsNullOrEmpty(agentAppId) || string.IsNullOrEmpty(username)) + if (string.IsNullOrEmpty(agentAppId)) + { + return null; + } + + // Determine user identifier: UPN takes precedence over OID (matching WithAgentUserIdentity behavior). + string? username = null; + Guid? userObjectId = null; + string? userIdentifierForCacheKey = null; + + if (extraParameters.TryGetValue(Constants.UsernameKey, out object? usernameObj) + && usernameObj is string upn && !string.IsNullOrEmpty(upn)) + { + username = upn; + userIdentifierForCacheKey = upn.ToUpperInvariant(); + } + else if (extraParameters.TryGetValue(Constants.UserIdKey, out object? userIdObj) + && userIdObj is string oidStr && Guid.TryParse(oidStr, out Guid parsedOid)) + { + userObjectId = parsedOid; + userIdentifierForCacheKey = parsedOid.ToString("D").ToUpperInvariant(); + } + else { + // Neither UPN nor valid OID — not a user FIC flow we can handle. return null; } @@ -612,7 +616,7 @@ private async Task GetAuthenticationResultForUserInternalA // Try silent retrieval first using a stored account identifier from a prior call. // Include tenantId in the key so cross-tenant calls don't collide. string normalizedTenant = tenantId?.ToUpperInvariant() ?? string.Empty; - string accountLookupKey = $"{agentAppId}:{username!.ToUpperInvariant()}:{normalizedTenant}"; + string accountLookupKey = $"{agentAppId}:{userIdentifierForCacheKey}:{normalizedTenant}"; if (!forceRefresh && _agentUserFicAccountIds.TryGetValue(accountLookupKey, out string? cachedAccountId) && !string.IsNullOrEmpty(cachedAccountId)) @@ -654,12 +658,25 @@ private async Task GetAuthenticationResultForUserInternalA var leg2 = await leg2Builder.ExecuteAsync().ConfigureAwait(false); - // Leg 3: Exchange T2 + username for a user-scoped token via native UserFIC. - var leg3Builder = ((IByUserFederatedIdentityCredential)agentCca) - .AcquireTokenByUserFederatedIdentityCredential( - scopes.Except(_scopesRequestedByMsal), - username, - leg2.AccessToken); + // Leg 3: Exchange T2 + user identifier for a user-scoped token via native UserFIC. + // Uses the UPN overload when username is available, OID overload otherwise. + AcquireTokenByUserFederatedIdentityCredentialParameterBuilder leg3Builder; + if (username is not null) + { + leg3Builder = ((IByUserFederatedIdentityCredential)agentCca) + .AcquireTokenByUserFederatedIdentityCredential( + scopes.Except(_scopesRequestedByMsal), + username, + leg2.AccessToken); + } + else + { + leg3Builder = ((IByUserFederatedIdentityCredential)agentCca) + .AcquireTokenByUserFederatedIdentityCredential( + scopes.Except(_scopesRequestedByMsal), + userObjectId!.Value, + leg2.AccessToken); + } if (!string.IsNullOrEmpty(tenantId)) { diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs index acd4560bf..273d0df53 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs @@ -542,6 +542,11 @@ public async Task AgentUserIdentity_NativeUserFic_CacheWorksWithNewClaimsPrincip } private static AuthorizationHeaderProviderOptions CreateAgentIdentityOptions(string agentAppId) + { + return CreateAgentIdentityOptionsWithUpn(agentAppId, AgentTestUsername); + } + + private static AuthorizationHeaderProviderOptions CreateAgentIdentityOptionsWithUpn(string agentAppId, string username) { return new AuthorizationHeaderProviderOptions { @@ -550,7 +555,22 @@ private static AuthorizationHeaderProviderOptions CreateAgentIdentityOptions(str ExtraParameters = new Dictionary { [Constants.AgentIdentityKey] = agentAppId, - [Constants.UsernameKey] = AgentTestUsername, + [Constants.UsernameKey] = username, + } + } + }; + } + + private static AuthorizationHeaderProviderOptions CreateAgentIdentityOptionsWithOid(string agentAppId, Guid userObjectId) + { + return new AuthorizationHeaderProviderOptions + { + AcquireTokenOptions = new AcquireTokenOptions + { + ExtraParameters = new Dictionary + { + [Constants.AgentIdentityKey] = agentAppId, + [Constants.UserIdKey] = userObjectId.ToString("D"), } } }; @@ -645,6 +665,129 @@ private static string EncodeBase64Url(string input) .TrimEnd('=').Replace('+', '-').Replace('/', '_'); } + // --- OID-based User FIC tests --- + + private static readonly Guid AgentTestUserOid = new Guid("00000000-1111-2222-3333-444444444444"); + + /// + /// Verifies that OID-based agentic User FIC flow uses the native path and caches properly. + /// Same pattern as UPN but uses Guid userObjectId overload. + /// + [Fact] + public async Task AgentUserIdentity_NativeUserFic_OidUsesCacheOnSecondCall() + { + // Arrange + string agentAppId = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "user-token-oid-1"); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + var options = CreateAgentIdentityOptionsWithOid(agentAppId, AgentTestUserOid); + + // Act — first call: full 3-leg flow + string result1 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: null); + + // Act — second call: should use AcquireTokenSilent (no mock handlers left) + string result2 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: null); + + // Assert — both return the same cached token + Assert.Equal("Bearer user-token-oid-1", result1); + Assert.Equal("Bearer user-token-oid-1", result2); + } + + /// + /// Verifies that OID-based flow works with fresh ClaimsPrincipal instances per call. + /// + [Fact] + public async Task AgentUserIdentity_NativeUserFic_OidCacheWorksWithNewClaimsPrincipalPerCall() + { + // Arrange + string agentAppId = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "user-token-oid-2"); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + var options = CreateAgentIdentityOptionsWithOid(agentAppId, AgentTestUserOid); + + // Act — each call gets a fresh ClaimsPrincipal (simulates request-scoped DI) + string result1 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: new System.Security.Claims.ClaimsPrincipal( + new Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity())); + + string result2 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: new System.Security.Claims.ClaimsPrincipal( + new Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity())); + + // Assert + Assert.Equal("Bearer user-token-oid-2", result1); + Assert.Equal("Bearer user-token-oid-2", result2); + } + + /// + /// Verifies that UPN and OID flows for the same agent produce separate cached tokens, + /// ensuring cache isolation between the two identifier types. + /// + [Fact] + public async Task AgentUserIdentity_NativeUserFic_UpnAndOidCachesAreIsolated() + { + // Arrange — same agent app ID for both flows + string agentAppId = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + + // UPN flow handlers (3 legs) + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "upn-user-token"); + // OID flow handlers (3 legs — Leg 1 may be cached from UPN flow, but Leg 2 + Leg 3 are needed) + // Note: Leg 1 (blueprint FMI) is cached in the blueprint CCA, but Leg 2 uses the agent CCA + // which also caches T2. Since OID and UPN go to the same agent CCA, Leg 2 may be cached. + // We still need a Leg 3 handler for the OID grant type. + mockHttpClient!.AddMockHandler(CreateUserFicTokenHandler(accessToken: "oid-user-token")); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + var upnOptions = CreateAgentIdentityOptionsWithUpn(agentAppId, AgentTestUsername); + var oidOptions = CreateAgentIdentityOptionsWithOid(agentAppId, AgentTestUserOid); + + // Act — UPN flow first + string upnResult = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: upnOptions, + claimsPrincipal: null); + + // Act — OID flow for same agent + string oidResult = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: oidOptions, + claimsPrincipal: null); + + // Assert — different tokens, not sharing cache entries + Assert.Equal("Bearer upn-user-token", upnResult); + Assert.Equal("Bearer oid-user-token", oidResult); + } + #endregion } } From e4b3f16bf1f4007961ccfe186d6640335637c1d7 Mon Sep 17 00:00:00 2001 From: avdunn Date: Fri, 5 Jun 2026 07:43:58 -0700 Subject: [PATCH 06/20] Better handling of national cloud endpoints for FIC scope --- .../Constants.cs | 6 ++ .../TokenAcquisition.cs | 56 ++++++++++++++++--- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/Constants.cs b/src/Microsoft.Identity.Web.TokenAcquisition/Constants.cs index b7c9f5fcb..42f507c19 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/Constants.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/Constants.cs @@ -236,6 +236,12 @@ public static class Constants * Treat as a public member. */ internal const string MicrosoftIdentityOptionsParameter = "IDWEB_FMI_MICROSOFT_IDENTITY_OPTIONS"; + /* + * Used by Microsoft.Identity.Web.AgentIdentities + * Any changes to this member (including removal) can cause runtime failures. + * Treat as a public member. + */ + internal const string TokenExchangeUrlKey = "IDWEB_TOKEN_EXCHANGE_URL"; /// /// Error codes indicating certificate or signed assertion issues that warrant retry with a new certificate. diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 0583a9124..d3765206e 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -63,6 +63,9 @@ class OAuthConstants /// Caches agent CCAs for the native User FIC flow. Each agent CCA uses an assertion /// callback that chains to the blueprint CCA for Leg 1 (FMI token acquisition). /// Key format: "{agentAppId}:{authenticationScheme}". + /// The authenticationScheme is included because different schemes may resolve to + /// different blueprint credentials (e.g. certificates), and the agent CCA's assertion + /// callback captures the scheme to chain to the correct blueprint CCA. /// private readonly ConcurrentDictionary _agentUserFicCcas = new(); private readonly ConcurrentDictionary _agentCcaSemaphores = new(); @@ -80,7 +83,18 @@ class OAuthConstants /// Entries are cleaned up when MSAL evicts the corresponding account from its cache. /// private readonly ConcurrentDictionary _agentUserFicAccountIds = new(); - private static readonly string[] s_ficScopes = new[] { "api://AzureADTokenExchange/.default" }; + + /// + /// Default FIC token exchange URL for the public cloud. For other clouds, callers can + /// override via ExtraParameters[Constants.TokenExchangeUrlKey]: + /// + /// api://AzureADTokenExchangepublic cloud (default) + /// api://AzureADTokenExchangeChinaChina cloud + /// api://AzureADTokenExchangeFranceBleu + /// api://AzureADTokenExchangeGermanyGerman cloud + /// + /// + private const string DefaultTokenExchangeUrl = "api://AzureADTokenExchange"; private const string TokenBindingParameterName = "IsTokenBinding"; private const int MaxCertificateRetries = 1; @@ -608,8 +622,12 @@ private async Task GetAuthenticationResultForUserInternalA } string? authScheme = tokenAcquisitionOptions?.AuthenticationOptionsName; + + // Resolve the FIC token exchange scope, allowing national cloud overrides. + string[] ficScopes = ResolveFicScopes(extraParameters); + var agentCca = await GetOrBuildAgentUserFicCcaAsync( - agentAppId!, application.Authority, authScheme).ConfigureAwait(false); + agentAppId!, application.Authority, authScheme, ficScopes).ConfigureAwait(false); bool forceRefresh = tokenAcquisitionOptions?.ForceRefresh ?? false; @@ -650,7 +668,7 @@ private async Task GetAuthenticationResultForUserInternalA // Leg 2: Get the agent's instance token (T2). // The assertion callback handles Leg 1 (blueprint → T1) transparently. - var leg2Builder = agentCca.AcquireTokenForClient(s_ficScopes); + var leg2Builder = agentCca.AcquireTokenForClient(ficScopes); if (!string.IsNullOrEmpty(tenantId)) { leg2Builder.WithTenantId(tenantId); @@ -705,7 +723,8 @@ private async Task GetAuthenticationResultForUserInternalA private async Task GetOrBuildAgentUserFicCcaAsync( string agentAppId, string authority, - string? authenticationScheme) + string? authenticationScheme, + string[] ficScopes) { // Include authenticationScheme in the CCA cache key so different schemes // (pointing to different blueprint credentials) get separate CCAs. @@ -725,8 +744,9 @@ private async Task GetOrBuildAgentUserFicCcaAsyn return app; } - // Capture authenticationScheme for the assertion callback closure. + // Capture authenticationScheme and ficScopes for the assertion callback closure. string? capturedAuthScheme = authenticationScheme; + string[] capturedFicScopes = ficScopes; var newApp = ConfidentialClientApplicationBuilder .Create(agentAppId) @@ -740,7 +760,7 @@ private async Task GetOrBuildAgentUserFicCcaAsyn blueprintOptions, isTokenBinding: false).ConfigureAwait(false); var leg1 = await blueprintCca - .AcquireTokenForClient(s_ficScopes) + .AcquireTokenForClient(capturedFicScopes) .WithFmiPath(agentAppId) .WithSendX5C(blueprintOptions.SendX5C) .ExecuteAsync(options.CancellationToken) @@ -750,7 +770,6 @@ private async Task GetOrBuildAgentUserFicCcaAsyn }) .WithAuthority(authority) .WithHttpClientFactory(_httpClientFactory) - .WithInstanceDiscovery(false) // Blueprint already validated the authority. .WithExperimentalFeatures() .Build(); @@ -763,6 +782,29 @@ private async Task GetOrBuildAgentUserFicCcaAsyn } } + /// + /// Resolves the FIC token exchange scope from ExtraParameters, falling back to the + /// public cloud default. Matches the override pattern used by + /// and OidcIdpSignedAssertionProvider. + /// + private static string[] ResolveFicScopes(IDictionary? extraParameters) + { + string tokenExchangeUrl = DefaultTokenExchangeUrl; + if (extraParameters is not null + && extraParameters.TryGetValue(Constants.TokenExchangeUrlKey, out object? urlObj) + && urlObj is string customUrl + && !string.IsNullOrEmpty(customUrl)) + { + tokenExchangeUrl = customUrl; + } + + string scope = tokenExchangeUrl.EndsWith("/.default", StringComparison.OrdinalIgnoreCase) + ? tokenExchangeUrl + : tokenExchangeUrl + "/.default"; + + return new[] { scope }; + } + private void LogAuthResult(AuthenticationResult? authenticationResult) { if (authenticationResult != null) From 81272630c3172c392de32e30ba3660e0d17c9847 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 16 Jun 2026 13:36:26 -0700 Subject: [PATCH 07/20] Add agent CCA instance eviction strategy --- .../TokenAcquisition.cs | 130 ++++++++- .../TokenAcquisitionTests.cs | 246 +++++++++++++++++- 2 files changed, 369 insertions(+), 7 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index d3765206e..0b6c6b21d 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -59,6 +59,47 @@ class OAuthConstants private readonly ConcurrentDictionary _applicationsByAuthorityClientId = new(); private readonly ConcurrentDictionary _appSemaphores = new(); + /// + /// Wraps an agent CCA with a last-access timestamp for idle-based eviction. + /// The sweep timer removes entries that haven't been accessed within + /// . + /// + internal sealed class AgentCcaEntry + { + public IConfidentialClientApplication Cca { get; } + private long _lastAccessedTicks; + + public AgentCcaEntry(IConfidentialClientApplication cca) + { + Cca = cca; + _lastAccessedTicks = Environment.TickCount64; + } + + public long LastAccessedTicks => Volatile.Read(ref _lastAccessedTicks); + + public void Touch() => Volatile.Write(ref _lastAccessedTicks, Environment.TickCount64); + + public bool IsExpired(long maxIdleMs) + { + return (Environment.TickCount64 - LastAccessedTicks) > maxIdleMs; + } + } + + /// + /// Maximum time (in milliseconds) an agent CCA can remain idle before being + /// eligible for eviction by the sweep timer. Default: 8 hours. + /// Aligns with typical token lifetimes — a CCA idle for this long likely has + /// mostly-expired tokens, making eviction nearly free. + /// Internal for testing — production code uses the default. + /// + internal long AgentCcaMaxIdleMilliseconds { get; set; } = (long)TimeSpan.FromHours(8).TotalMilliseconds; + + /// + /// Interval between background sweep runs. Default: 30 minutes. + /// Internal for testing — production code uses the default. + /// + internal TimeSpan AgentCcaSweepInterval { get; set; } = TimeSpan.FromMinutes(30); + /// /// Caches agent CCAs for the native User FIC flow. Each agent CCA uses an assertion /// callback that chains to the blueprint CCA for Leg 1 (FMI token acquisition). @@ -66,9 +107,11 @@ class OAuthConstants /// The authenticationScheme is included because different schemes may resolve to /// different blueprint credentials (e.g. certificates), and the agent CCA's assertion /// callback captures the scheme to chain to the correct blueprint CCA. + /// Entries are wrapped in for idle-based eviction. /// - private readonly ConcurrentDictionary _agentUserFicCcas = new(); + internal readonly ConcurrentDictionary _agentUserFicCcas = new(); private readonly ConcurrentDictionary _agentCcaSemaphores = new(); + private Timer? _agentCcaSweepTimer; /// /// Maps (agentAppId, user identifier, tenantId) tuples to MSAL account identifiers for the @@ -82,7 +125,7 @@ class OAuthConstants /// where USER_IDENTIFIER is either the normalized UPN or OID. /// Entries are cleaned up when MSAL evicts the corresponding account from its cache. /// - private readonly ConcurrentDictionary _agentUserFicAccountIds = new(); + internal readonly ConcurrentDictionary _agentUserFicAccountIds = new(); /// /// Default FIC token exchange URL for the public cloud. For other clouds, callers can @@ -719,6 +762,7 @@ private async Task GetAuthenticationResultForUserInternalA /// Gets or builds an agent CCA for the native User FIC flow. Each agent CCA uses an /// assertion callback that chains back to the blueprint CCA for Leg 1 (FMI token). /// The agent CCA's in-memory cache provides natural token isolation per agent. + /// Entries are tracked with last-access timestamps for idle-based eviction. /// private async Task GetOrBuildAgentUserFicCcaAsync( string agentAppId, @@ -732,16 +776,18 @@ private async Task GetOrBuildAgentUserFicCcaAsyn if (_agentUserFicCcas.TryGetValue(ccaCacheKey, out var existing)) { - return existing; + existing.Touch(); + return existing.Cca; } var semaphore = _agentCcaSemaphores.GetOrAdd(ccaCacheKey, _ => new SemaphoreSlim(1, 1)); await semaphore.WaitAsync().ConfigureAwait(false); try { - if (_agentUserFicCcas.TryGetValue(ccaCacheKey, out var app)) + if (_agentUserFicCcas.TryGetValue(ccaCacheKey, out var entry)) { - return app; + entry.Touch(); + return entry.Cca; } // Capture authenticationScheme and ficScopes for the assertion callback closure. @@ -773,7 +819,11 @@ private async Task GetOrBuildAgentUserFicCcaAsyn .WithExperimentalFeatures() .Build(); - _agentUserFicCcas[ccaCacheKey] = newApp; + _agentUserFicCcas[ccaCacheKey] = new AgentCcaEntry(newApp); + + // Start the sweep timer on first agent CCA creation (lazy initialization). + EnsureAgentCcaSweepTimerStarted(); + return newApp; } finally @@ -782,6 +832,74 @@ private async Task GetOrBuildAgentUserFicCcaAsyn } } + /// + /// Lazily starts the background sweep timer on first agent CCA creation. + /// Thread-safe via Interlocked.CompareExchange. + /// + private void EnsureAgentCcaSweepTimerStarted() + { + if (_agentCcaSweepTimer is not null) + { + return; + } + + var newTimer = new Timer( + _ => SweepExpiredAgentCcas(), + null, + Timeout.InfiniteTimeSpan, // Don't start immediately + Timeout.InfiniteTimeSpan); + + if (Interlocked.CompareExchange(ref _agentCcaSweepTimer, newTimer, null) is not null) + { + // Another thread already created the timer — dispose ours. + newTimer.Dispose(); + } + else + { + // We won the race — start the timer. + _agentCcaSweepTimer!.Change(AgentCcaSweepInterval, AgentCcaSweepInterval); + } + } + + /// + /// Removes agent CCA entries that have been idle for longer than + /// . Also cleans up companion + /// entries for evicted agents. + /// Called by the background sweep timer and can be invoked manually in tests. + /// + internal int SweepExpiredAgentCcas() + { + int evictedCount = 0; + + foreach (var kvp in _agentUserFicCcas) + { + if (kvp.Value.IsExpired(AgentCcaMaxIdleMilliseconds)) + { + if (_agentUserFicCcas.TryRemove(kvp.Key, out _)) + { + _agentCcaSemaphores.TryRemove(kvp.Key, out _); + + // Clean up companion account ID entries for this agent CCA. + // Account IDs are keyed as "{agentAppId}:{USER_IDENTIFIER}:{TENANTID}", + // and the CCA key is "{agentAppId}:{authenticationScheme}". + // Extract the agentAppId prefix to match related account entries. + string agentAppIdPrefix = kvp.Key.Split(':')[0] + ":"; + foreach (var accountKvp in _agentUserFicAccountIds) + { + if (accountKvp.Key.StartsWith(agentAppIdPrefix, StringComparison.Ordinal)) + { + _agentUserFicAccountIds.TryRemove(accountKvp.Key, out _); + } + } + + evictedCount++; + } + } + } + + return evictedCount; + } + /// /// Resolves the FIC token exchange scope from ExtraParameters, falling back to the /// public cloud default. Matches the override pattern used by diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs index 273d0df53..71cad0a42 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System; @@ -789,5 +789,249 @@ public async Task AgentUserIdentity_NativeUserFic_UpnAndOidCachesAreIsolated() } #endregion + + #region Agent CCA Sweep Eviction Tests + + /// + /// Verifies that the sweep timer removes agent CCA entries that have been + /// idle for longer than the configured maximum idle time. + /// + [Fact] + public async Task AgentCcaSweep_RemovesExpiredEntries() + { + // Arrange + string agentAppId = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); + + // Override the idle threshold to a very short value for testing. + tokenAcquisition.AgentCcaMaxIdleMilliseconds = 50; + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "sweep-token-1"); + + var options = CreateAgentIdentityOptions(agentAppId); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + // Act — first call populates the agent CCA and account IDs + await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: null); + + Assert.True(tokenAcquisition._agentUserFicCcas.Count == 1); + Assert.NotEmpty(tokenAcquisition._agentUserFicAccountIds); + + // Wait for the entry to expire + await Task.Delay(TimeSpan.FromMilliseconds(100)); + + // Act — manual sweep + int evicted = tokenAcquisition.SweepExpiredAgentCcas(); + + // Assert + Assert.Equal(1, evicted); + Assert.True(tokenAcquisition._agentUserFicCcas.Count == 0); + Assert.True(tokenAcquisition._agentUserFicAccountIds.IsEmpty); + } + + /// + /// Verifies that the sweep does not remove agent CCA entries that have been + /// recently accessed (i.e., the touch mechanism resets the idle timer). + /// + [Fact] + public async Task AgentCcaSweep_DoesNotRemoveRecentlyAccessedEntries() + { + // Arrange + string agentAppId = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); + tokenAcquisition.AgentCcaMaxIdleMilliseconds = 200; + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "sweep-token-2"); + + var options = CreateAgentIdentityOptions(agentAppId); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + // Act — first call populates + await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: null); + + // Touch via second call (cache hit, no new handlers needed) + await Task.Delay(TimeSpan.FromMilliseconds(100)); + await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: null); + + // Sweep should find nothing expired (entry was just touched) + int evicted = tokenAcquisition.SweepExpiredAgentCcas(); + + // Assert + Assert.Equal(0, evicted); + Assert.True(tokenAcquisition._agentUserFicCcas.Count == 1); + } + + /// + /// Verifies that the sweep cleans up companion _agentUserFicAccountIds entries + /// when an agent CCA is evicted. + /// + [Fact] + public async Task AgentCcaSweep_CleansUpCompanionAccountIds() + { + // Arrange — two agents, each with their own user tokens + string agentAppId1 = Guid.NewGuid().ToString("N"); + string agentAppId2 = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); + tokenAcquisition.AgentCcaMaxIdleMilliseconds = 50; + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + + // Agent 1 flow + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "agent1-user-token"); + var options1 = CreateAgentIdentityOptions(agentAppId1); + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options1, + claimsPrincipal: null); + + // Agent 2 flow + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "agent2-user-token"); + var options2 = CreateAgentIdentityOptions(agentAppId2); + await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options2, + claimsPrincipal: null); + + Assert.True(tokenAcquisition._agentUserFicCcas.Count == 2); + int accountIdsBefore = tokenAcquisition._agentUserFicAccountIds.Count; + Assert.True(accountIdsBefore >= 2, "Should have account IDs for both agents."); + + // Wait for both to expire + await Task.Delay(TimeSpan.FromMilliseconds(100)); + + // Act + int evicted = tokenAcquisition.SweepExpiredAgentCcas(); + + // Assert — both CCAs evicted, all companion account IDs cleaned up + Assert.Equal(2, evicted); + Assert.True(tokenAcquisition._agentUserFicCcas.Count == 0); + Assert.True(tokenAcquisition._agentUserFicAccountIds.IsEmpty); + } + + /// + /// Verifies that when only one of two agents expires, the sweep only evicts + /// the expired agent and leaves the other intact (including its account IDs). + /// + [Fact] + public async Task AgentCcaSweep_SelectiveEviction_OnlyRemovesExpiredAgent() + { + // Arrange + string agentAppIdOld = Guid.NewGuid().ToString("N"); + string agentAppIdNew = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); + tokenAcquisition.AgentCcaMaxIdleMilliseconds = 150; + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + + // Create old agent first + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "old-agent-token"); + var optionsOld = CreateAgentIdentityOptions(agentAppIdOld); + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: optionsOld, + claimsPrincipal: null); + + // Wait, then create new agent (so old agent is stale, new is fresh) + await Task.Delay(TimeSpan.FromMilliseconds(100)); + + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "new-agent-token"); + var optionsNew = CreateAgentIdentityOptions(agentAppIdNew); + await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: optionsNew, + claimsPrincipal: null); + + Assert.True(tokenAcquisition._agentUserFicCcas.Count == 2); + + // Wait for old agent to expire (but not the new one) + await Task.Delay(TimeSpan.FromMilliseconds(100)); + + // Act + int evicted = tokenAcquisition.SweepExpiredAgentCcas(); + + // Assert — only old agent evicted + Assert.Equal(1, evicted); + Assert.True(tokenAcquisition._agentUserFicCcas.Count == 1); + + // New agent's account IDs should still be present + bool hasNewAgentAccountId = false; + foreach (var kvp in tokenAcquisition._agentUserFicAccountIds) + { + if (kvp.Key.StartsWith(agentAppIdNew + ":", StringComparison.Ordinal)) + { + hasNewAgentAccountId = true; + break; + } + } + Assert.True(hasNewAgentAccountId, "New agent's account IDs should survive the sweep."); + } + + /// + /// Verifies that SweepExpiredAgentCcas returns zero when no entries are expired. + /// + [Fact] + public async Task AgentCcaSweep_ReturnsZero_WhenNothingExpired() + { + // Arrange + string agentAppId = Guid.NewGuid().ToString("N"); + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + + var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); + // Long idle threshold — nothing will expire + tokenAcquisition.AgentCcaMaxIdleMilliseconds = (long)TimeSpan.FromHours(1).TotalMilliseconds; + + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "fresh-token"); + + var options = CreateAgentIdentityOptions(agentAppId); + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: options, + claimsPrincipal: null); + + // Act + int evicted = tokenAcquisition.SweepExpiredAgentCcas(); + + // Assert + Assert.Equal(0, evicted); + Assert.True(tokenAcquisition._agentUserFicCcas.Count == 1); + } + + #endregion } } From 3cb9ae8fb7749aee7ddc8bba7c11d154321dd1fc Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 16 Jun 2026 14:20:32 -0700 Subject: [PATCH 08/20] PR feedback --- .../TokenAcquisition.cs | 87 +++++++++++++++++-- .../TokenAcquisitionTests.cs | 62 +++++++++++-- 2 files changed, 139 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 0b6c6b21d..a5e8b6f57 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -111,7 +111,7 @@ public bool IsExpired(long maxIdleMs) /// internal readonly ConcurrentDictionary _agentUserFicCcas = new(); private readonly ConcurrentDictionary _agentCcaSemaphores = new(); - private Timer? _agentCcaSweepTimer; + private Timer? _agentCcaSweepTimer; // Not explicitly disposed — TokenAcquisition is a DI singleton with process lifetime. /// /// Maps (agentAppId, user identifier, tenantId) tuples to MSAL account identifiers for the @@ -676,6 +676,10 @@ private async Task GetAuthenticationResultForUserInternalA // Try silent retrieval first using a stored account identifier from a prior call. // Include tenantId in the key so cross-tenant calls don't collide. + // authenticationScheme is intentionally excluded: a given (agent, user, tenant) + // tuple maps to a single MSAL account identity regardless of which auth scheme + // was used. The CCA selected above is already scheme-specific, and GetAccountAsync + // returns the same account from any CCA that shares the user's cache partition. string normalizedTenant = tenantId?.ToUpperInvariant() ?? string.Empty; string accountLookupKey = $"{agentAppId}:{userIdentifierForCacheKey}:{normalizedTenant}"; if (!forceRefresh @@ -697,8 +701,11 @@ private async Task GetAuthenticationResultForUserInternalA return await silentBuilder.ExecuteAsync().ConfigureAwait(false); } - catch (MsalUiRequiredException ex) + catch (MsalException ex) { + // Catch all MSAL exceptions (not just MsalUiRequiredException) so that + // any silent failure (cache errors, client errors, etc.) falls back to + // the full 3-leg acquisition rather than propagating up as a hard failure. Logger.TokenAcquisitionError(_logger, ex.Message, ex); } } @@ -772,6 +779,9 @@ private async Task GetOrBuildAgentUserFicCcaAsyn { // Include authenticationScheme in the CCA cache key so different schemes // (pointing to different blueprint credentials) get separate CCAs. + // Authority is intentionally excluded: within a single TokenAcquisition instance, + // the authority is derived from the one configured MergedOptions and does not vary + // per call. Multi-authority scenarios would require separate TokenAcquisition instances. string ccaCacheKey = $"{agentAppId}:{authenticationScheme ?? string.Empty}"; if (_agentUserFicCcas.TryGetValue(ccaCacheKey, out var existing)) @@ -805,10 +815,24 @@ private async Task GetOrBuildAgentUserFicCcaAsyn var blueprintCca = await GetOrBuildConfidentialClientApplicationAsync( blueprintOptions, isTokenBinding: false).ConfigureAwait(false); - var leg1 = await blueprintCca + var leg1Builder = blueprintCca .AcquireTokenForClient(capturedFicScopes) .WithFmiPath(agentAppId) - .WithSendX5C(blueprintOptions.SendX5C) + .WithSendX5C(blueprintOptions.SendX5C); + + // Propagate tenant override to Leg 1 when the caller specifies a tenant + // (e.g., via WithTenantId on Leg 2/3). Extract tenant from the token + // endpoint provided by MSAL, matching the pattern used by + // OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance. + string? leg1Tenant = ExtractTenantFromTokenEndpointIfSameInstance( + options.TokenEndpoint, + blueprintOptions.Instance); + if (!string.IsNullOrEmpty(leg1Tenant)) + { + leg1Builder.WithTenantId(leg1Tenant); + } + + var leg1 = await leg1Builder .ExecuteAsync(options.CancellationToken) .ConfigureAwait(false); @@ -877,7 +901,10 @@ internal int SweepExpiredAgentCcas() { if (_agentUserFicCcas.TryRemove(kvp.Key, out _)) { - _agentCcaSemaphores.TryRemove(kvp.Key, out _); + if (_agentCcaSemaphores.TryRemove(kvp.Key, out var semaphore)) + { + semaphore.Dispose(); + } // Clean up companion account ID entries for this agent CCA. // Account IDs are keyed as "{agentAppId}:{USER_IDENTIFIER}:{TENANTID}", @@ -923,6 +950,56 @@ private static string[] ResolveFicScopes(IDictionary? extraParam return new[] { scope }; } + /// + /// Extracts the tenant ID from an OAuth2 token endpoint URL when the endpoint belongs + /// to the same cloud instance as the configured authority. Returns null if the hosts + /// don't match (cross-cloud) or the URL format is unrecognized. + /// Token endpoint format: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token + /// + /// + /// This is the same logic as OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance, + /// duplicated here because that method is internal to the OidcFIC project. + /// + internal static string? ExtractTenantFromTokenEndpointIfSameInstance( + string? tokenEndpoint, string? configuredInstance) + { + if (string.IsNullOrEmpty(tokenEndpoint) || string.IsNullOrEmpty(configuredInstance)) + { + return null; + } + + try + { + var endpointUri = new Uri(tokenEndpoint!); + var instanceUri = new Uri(configuredInstance!.TrimEnd('/')); + + if (!string.Equals(endpointUri.Host, instanceUri.Host, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var pathSegments = endpointUri.AbsolutePath.Split( + new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + + if (pathSegments.Length >= 2) + { + for (int i = 1; i < pathSegments.Length; i++) + { + if (string.Equals(pathSegments[i], "oauth2", StringComparison.OrdinalIgnoreCase)) + { + return pathSegments[0]; + } + } + } + } + catch (UriFormatException) + { + // Invalid URI — fall through to return null. + } + + return null; + } + private void LogAuthResult(AuthenticationResult? authenticationResult) { if (authenticationResult != null) diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs index 71cad0a42..01233f8d6 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs @@ -948,7 +948,10 @@ public async Task AgentCcaSweep_SelectiveEviction_OnlyRemovesExpiredAgent() IServiceProvider serviceProvider = factory.Build(); var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); - tokenAcquisition.AgentCcaMaxIdleMilliseconds = 150; + // Use a generous idle threshold so the new agent stays well within bounds + // even under CI CPU contention. The old agent will be force-expired by + // reducing the threshold right before the sweep. + tokenAcquisition.AgentCcaMaxIdleMilliseconds = 5000; var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; @@ -962,8 +965,8 @@ await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( authorizationHeaderProviderOptions: optionsOld, claimsPrincipal: null); - // Wait, then create new agent (so old agent is stale, new is fresh) - await Task.Delay(TimeSpan.FromMilliseconds(100)); + // Wait long enough to create a clear gap between old and new agent timestamps. + await Task.Delay(TimeSpan.FromMilliseconds(200)); AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "new-agent-token"); var optionsNew = CreateAgentIdentityOptions(agentAppIdNew); @@ -974,8 +977,9 @@ await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( Assert.True(tokenAcquisition._agentUserFicCcas.Count == 2); - // Wait for old agent to expire (but not the new one) - await Task.Delay(TimeSpan.FromMilliseconds(100)); + // Now set a threshold that the old agent (200ms+ idle) exceeds + // but the new agent (just created) does not. + tokenAcquisition.AgentCcaMaxIdleMilliseconds = 100; // Act int evicted = tokenAcquisition.SweepExpiredAgentCcas(); @@ -1033,5 +1037,53 @@ await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( } #endregion + + #region ExtractTenantFromTokenEndpointIfSameInstance Tests + + [Fact] + public void ExtractTenant_SameInstance_ReturnsTenant() + { + // Arrange + string tokenEndpoint = "https://login.microsoftonline.com/my-tenant-id/oauth2/v2.0/token"; + string instance = "https://login.microsoftonline.com/"; + + // Act + string? tenant = TokenAcquisition.ExtractTenantFromTokenEndpointIfSameInstance(tokenEndpoint, instance); + + // Assert + Assert.Equal("my-tenant-id", tenant); + } + + [Fact] + public void ExtractTenant_DifferentInstance_ReturnsNull() + { + // Arrange — China cloud endpoint with public cloud instance + string tokenEndpoint = "https://login.chinacloudapi.cn/my-tenant/oauth2/v2.0/token"; + string instance = "https://login.microsoftonline.com/"; + + // Act + string? tenant = TokenAcquisition.ExtractTenantFromTokenEndpointIfSameInstance(tokenEndpoint, instance); + + // Assert + Assert.Null(tenant); + } + + [Theory] + [InlineData(null, "https://login.microsoftonline.com/")] + [InlineData("https://login.microsoftonline.com/tenant/oauth2/v2.0/token", null)] + [InlineData(null, null)] + [InlineData("", "https://login.microsoftonline.com/")] + public void ExtractTenant_NullOrEmptyInputs_ReturnsNull(string? tokenEndpoint, string? instance) + { + Assert.Null(TokenAcquisition.ExtractTenantFromTokenEndpointIfSameInstance(tokenEndpoint, instance)); + } + + [Fact] + public void ExtractTenant_InvalidUri_ReturnsNull() + { + Assert.Null(TokenAcquisition.ExtractTenantFromTokenEndpointIfSameInstance("not-a-uri", "also-not-a-uri")); + } + + #endregion } } From dd7a1f21df1ccc8e0c4f58259becc8921aa67da5 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 16 Jun 2026 15:29:03 -0700 Subject: [PATCH 09/20] Fix test issue --- .../TokenAcquisition.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index a5e8b6f57..bb6a91eb9 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IdentityModel.Tokens.Jwt; @@ -66,22 +67,25 @@ class OAuthConstants /// internal sealed class AgentCcaEntry { + private static readonly double s_ticksPerMs = Stopwatch.Frequency / 1000.0; + public IConfidentialClientApplication Cca { get; } private long _lastAccessedTicks; public AgentCcaEntry(IConfidentialClientApplication cca) { Cca = cca; - _lastAccessedTicks = Environment.TickCount64; + _lastAccessedTicks = Stopwatch.GetTimestamp(); } public long LastAccessedTicks => Volatile.Read(ref _lastAccessedTicks); - public void Touch() => Volatile.Write(ref _lastAccessedTicks, Environment.TickCount64); + public void Touch() => Volatile.Write(ref _lastAccessedTicks, Stopwatch.GetTimestamp()); public bool IsExpired(long maxIdleMs) { - return (Environment.TickCount64 - LastAccessedTicks) > maxIdleMs; + long elapsedTicks = Stopwatch.GetTimestamp() - LastAccessedTicks; + return (elapsedTicks / s_ticksPerMs) > maxIdleMs; } } From ffef9164e9b9be17df08d5b901dc5cd5d69a6c7f Mon Sep 17 00:00:00 2001 From: avdunn Date: Wed, 17 Jun 2026 11:07:34 -0700 Subject: [PATCH 10/20] More logging and less overrides --- .../Constants.cs | 6 -- .../LoggingEventId.cs | 8 +++ .../TokenAcquisition.Logger.cs | 56 +++++++++++++++++ .../TokenAcquisition.cs | 61 ++++++------------- 4 files changed, 81 insertions(+), 50 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/Constants.cs b/src/Microsoft.Identity.Web.TokenAcquisition/Constants.cs index 9f096cf32..4110dd520 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/Constants.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/Constants.cs @@ -237,12 +237,6 @@ public static class Constants * Treat as a public member. */ internal const string MicrosoftIdentityOptionsParameter = "IDWEB_FMI_MICROSOFT_IDENTITY_OPTIONS"; - /* - * Used by Microsoft.Identity.Web.AgentIdentities - * Any changes to this member (including removal) can cause runtime failures. - * Treat as a public member. - */ - internal const string TokenExchangeUrlKey = "IDWEB_TOKEN_EXCHANGE_URL"; /// /// Error codes indicating certificate or signed assertion issues that warrant retry with a new certificate. diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/LoggingEventId.cs b/src/Microsoft.Identity.Web.TokenAcquisition/LoggingEventId.cs index c24a942e1..9e8d6980b 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/LoggingEventId.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/LoggingEventId.cs @@ -38,6 +38,14 @@ internal static class LoggingEventId public static readonly EventId AuthorityIgnored = new EventId(500, "AuthorityIgnored"); public static readonly EventId AuthorityUsedConsiderInstanceTenantId = new EventId(501, "AuthorityUsedConsiderInstanceTenantId"); + // Agent User FIC EventIds 600+ + public static readonly EventId AgentUserFicFlowDetected = new EventId(600, "AgentUserFicFlowDetected"); + public static readonly EventId AgentUserFicSilentSuccess = new EventId(601, "AgentUserFicSilentSuccess"); + public static readonly EventId AgentUserFicSilentFailure = new EventId(602, "AgentUserFicSilentFailure"); + public static readonly EventId AgentUserFicAcquisitionComplete = new EventId(603, "AgentUserFicAcquisitionComplete"); + public static readonly EventId AgentCcaCreated = new EventId(604, "AgentCcaCreated"); + public static readonly EventId AgentCcaEviction = new EventId(605, "AgentCcaEviction"); + #pragma warning restore IDE1006 // Naming Styles } } diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.Logger.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.Logger.cs index e70cc4969..c06eaff21 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.Logger.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.Logger.cs @@ -81,6 +81,62 @@ public static void TokenAcquisitionMsalAuthenticationResultTime( cacheRefreshReason, ex); } + + // --- Agent User FIC logging --- + + private static readonly Action s_agentUserFicFlowDetected = + LoggerMessage.Define( + LogLevel.Information, + LoggingEventId.AgentUserFicFlowDetected, + "[MsIdWeb] Agent User FIC flow detected for agent '{AgentAppId}' with user identifier type '{IdentifierType}'."); + + private static readonly Action s_agentUserFicSilentSuccess = + LoggerMessage.Define( + LogLevel.Debug, + LoggingEventId.AgentUserFicSilentSuccess, + "[MsIdWeb] Agent User FIC silent token acquisition succeeded for key '{AccountLookupKey}'."); + + private static readonly Action s_agentUserFicSilentFailure = + LoggerMessage.Define( + LogLevel.Information, + LoggingEventId.AgentUserFicSilentFailure, + "[MsIdWeb] Agent User FIC silent token acquisition failed for key '{AccountLookupKey}': {Reason}. Falling back to 3-leg acquisition."); + + private static readonly Action s_agentUserFicAcquisitionComplete = + LoggerMessage.Define( + LogLevel.Information, + LoggingEventId.AgentUserFicAcquisitionComplete, + "[MsIdWeb] Agent User FIC 3-leg acquisition complete for agent '{AgentAppId}', token source: {TokenSource}."); + + private static readonly Action s_agentCcaCreated = + LoggerMessage.Define( + LogLevel.Information, + LoggingEventId.AgentCcaCreated, + "[MsIdWeb] Created new agent CCA for cache key '{CcaCacheKey}'."); + + private static readonly Action s_agentCcaEviction = + LoggerMessage.Define( + LogLevel.Information, + LoggingEventId.AgentCcaEviction, + "[MsIdWeb] Agent CCA sweep evicted {EvictedCount} entries. Remaining: {RemainingCount}."); + + public static void AgentUserFicFlowDetected(ILogger logger, string agentAppId, string identifierType) + => s_agentUserFicFlowDetected(logger, agentAppId, identifierType, null); + + public static void AgentUserFicSilentSuccess(ILogger logger, string accountLookupKey) + => s_agentUserFicSilentSuccess(logger, accountLookupKey, null); + + public static void AgentUserFicSilentFailure(ILogger logger, string accountLookupKey, string reason, Exception? ex) + => s_agentUserFicSilentFailure(logger, accountLookupKey, reason, ex); + + public static void AgentUserFicAcquisitionComplete(ILogger logger, string agentAppId, string tokenSource) + => s_agentUserFicAcquisitionComplete(logger, agentAppId, tokenSource, null); + + public static void AgentCcaCreated(ILogger logger, string ccaCacheKey) + => s_agentCcaCreated(logger, ccaCacheKey, null); + + public static void AgentCcaEviction(ILogger logger, int evictedCount, int remainingCount) + => s_agentCcaEviction(logger, evictedCount, remainingCount, null); } } } diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 7811fddc4..49db31da8 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -132,17 +132,7 @@ public bool IsExpired(long maxIdleMs) /// internal readonly ConcurrentDictionary _agentUserFicAccountIds = new(); - /// - /// Default FIC token exchange URL for the public cloud. For other clouds, callers can - /// override via ExtraParameters[Constants.TokenExchangeUrlKey]: - /// - /// api://AzureADTokenExchangepublic cloud (default) - /// api://AzureADTokenExchangeChinaChina cloud - /// api://AzureADTokenExchangeFranceBleu - /// api://AzureADTokenExchangeGermanyGerman cloud - /// - /// - private const string DefaultTokenExchangeUrl = "api://AzureADTokenExchange"; + private static readonly string[] s_ficScopes = new[] { "api://AzureADTokenExchange/.default" }; private const string TokenBindingParameterName = "IsTokenBinding"; private const int MaxCertificateRetries = 1; @@ -670,12 +660,11 @@ private async Task GetAuthenticationResultForUserInternalA } string? authScheme = tokenAcquisitionOptions?.AuthenticationOptionsName; - - // Resolve the FIC token exchange scope, allowing national cloud overrides. - string[] ficScopes = ResolveFicScopes(extraParameters); + string identifierType = username is not null ? "UPN" : "OID"; + Logger.AgentUserFicFlowDetected(_logger, agentAppId!, identifierType); var agentCca = await GetOrBuildAgentUserFicCcaAsync( - agentAppId!, application.Authority, authScheme, ficScopes).ConfigureAwait(false); + agentAppId!, application.Authority, authScheme).ConfigureAwait(false); bool forceRefresh = tokenAcquisitionOptions?.ForceRefresh ?? false; @@ -704,14 +693,16 @@ private async Task GetAuthenticationResultForUserInternalA silentBuilder.WithTenantId(tenantId); } - return await silentBuilder.ExecuteAsync().ConfigureAwait(false); + var silentResult = await silentBuilder.ExecuteAsync().ConfigureAwait(false); + Logger.AgentUserFicSilentSuccess(_logger, accountLookupKey); + return silentResult; } catch (MsalException ex) { // Catch all MSAL exceptions (not just MsalUiRequiredException) so that // any silent failure (cache errors, client errors, etc.) falls back to // the full 3-leg acquisition rather than propagating up as a hard failure. - Logger.TokenAcquisitionError(_logger, ex.Message, ex); + Logger.AgentUserFicSilentFailure(_logger, accountLookupKey, ex.ErrorCode ?? ex.GetType().Name, ex); } } else @@ -723,7 +714,7 @@ private async Task GetAuthenticationResultForUserInternalA // Leg 2: Get the agent's instance token (T2). // The assertion callback handles Leg 1 (blueprint → T1) transparently. - var leg2Builder = agentCca.AcquireTokenForClient(ficScopes); + var leg2Builder = agentCca.AcquireTokenForClient(s_ficScopes); if (!string.IsNullOrEmpty(tenantId)) { leg2Builder.WithTenantId(tenantId); @@ -758,6 +749,7 @@ private async Task GetAuthenticationResultForUserInternalA var result = await leg3Builder.ExecuteAsync().ConfigureAwait(false); + Logger.AgentUserFicAcquisitionComplete(_logger, agentAppId!, result.AuthenticationResultMetadata.TokenSource.ToString()); // Store the account identifier for subsequent silent lookups. // This parallels how other ID Web flows write oid/tid claims back into the // ClaimsPrincipal after acquisition (see line ~541 in the ROPC path). Here, @@ -779,8 +771,7 @@ private async Task GetAuthenticationResultForUserInternalA private async Task GetOrBuildAgentUserFicCcaAsync( string agentAppId, string authority, - string? authenticationScheme, - string[] ficScopes) + string? authenticationScheme) { // Include authenticationScheme in the CCA cache key so different schemes // (pointing to different blueprint credentials) get separate CCAs. @@ -805,9 +796,8 @@ private async Task GetOrBuildAgentUserFicCcaAsyn return entry.Cca; } - // Capture authenticationScheme and ficScopes for the assertion callback closure. + // Capture authenticationScheme for the assertion callback closure. string? capturedAuthScheme = authenticationScheme; - string[] capturedFicScopes = ficScopes; var newApp = ConfidentialClientApplicationBuilder .Create(agentAppId) @@ -821,7 +811,7 @@ private async Task GetOrBuildAgentUserFicCcaAsyn blueprintOptions, isTokenBinding: false).ConfigureAwait(false); var leg1Builder = blueprintCca - .AcquireTokenForClient(capturedFicScopes) + .AcquireTokenForClient(s_ficScopes) .WithFmiPath(agentAppId) .WithSendX5C(blueprintOptions.SendX5C); @@ -849,6 +839,7 @@ private async Task GetOrBuildAgentUserFicCcaAsyn .Build(); _agentUserFicCcas[ccaCacheKey] = new AgentCcaEntry(newApp); + Logger.AgentCcaCreated(_logger, ccaCacheKey); // Start the sweep timer on first agent CCA creation (lazy initialization). EnsureAgentCcaSweepTimerStarted(); @@ -929,30 +920,12 @@ internal int SweepExpiredAgentCcas() } } - return evictedCount; - } - - /// - /// Resolves the FIC token exchange scope from ExtraParameters, falling back to the - /// public cloud default. Matches the override pattern used by - /// and OidcIdpSignedAssertionProvider. - /// - private static string[] ResolveFicScopes(IDictionary? extraParameters) - { - string tokenExchangeUrl = DefaultTokenExchangeUrl; - if (extraParameters is not null - && extraParameters.TryGetValue(Constants.TokenExchangeUrlKey, out object? urlObj) - && urlObj is string customUrl - && !string.IsNullOrEmpty(customUrl)) + if (evictedCount > 0) { - tokenExchangeUrl = customUrl; + Logger.AgentCcaEviction(_logger, evictedCount, _agentUserFicCcas.Count); } - string scope = tokenExchangeUrl.EndsWith("/.default", StringComparison.OrdinalIgnoreCase) - ? tokenExchangeUrl - : tokenExchangeUrl + "/.default"; - - return new[] { scope }; + return evictedCount; } /// From dc7585af557a2f703b3dd06848dd1a8a49a9a75f Mon Sep 17 00:00:00 2001 From: avdunn Date: Fri, 26 Jun 2026 11:25:26 -0700 Subject: [PATCH 11/20] Remove upn/oid from agentic flow logs --- .../TokenAcquisition.Logger.cs | 20 +++++++++---------- .../TokenAcquisition.cs | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.Logger.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.Logger.cs index c06eaff21..0c80b2ad5 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.Logger.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.Logger.cs @@ -90,17 +90,17 @@ public static void TokenAcquisitionMsalAuthenticationResultTime( LoggingEventId.AgentUserFicFlowDetected, "[MsIdWeb] Agent User FIC flow detected for agent '{AgentAppId}' with user identifier type '{IdentifierType}'."); - private static readonly Action s_agentUserFicSilentSuccess = - LoggerMessage.Define( + private static readonly Action s_agentUserFicSilentSuccess = + LoggerMessage.Define( LogLevel.Debug, LoggingEventId.AgentUserFicSilentSuccess, - "[MsIdWeb] Agent User FIC silent token acquisition succeeded for key '{AccountLookupKey}'."); + "[MsIdWeb] Agent User FIC silent token acquisition succeeded for agent '{AgentAppId}' in tenant '{TenantId}'."); - private static readonly Action s_agentUserFicSilentFailure = - LoggerMessage.Define( + private static readonly Action s_agentUserFicSilentFailure = + LoggerMessage.Define( LogLevel.Information, LoggingEventId.AgentUserFicSilentFailure, - "[MsIdWeb] Agent User FIC silent token acquisition failed for key '{AccountLookupKey}': {Reason}. Falling back to 3-leg acquisition."); + "[MsIdWeb] Agent User FIC silent token acquisition failed for agent '{AgentAppId}' in tenant '{TenantId}': {Reason}. Falling back to 3-leg acquisition."); private static readonly Action s_agentUserFicAcquisitionComplete = LoggerMessage.Define( @@ -123,11 +123,11 @@ public static void TokenAcquisitionMsalAuthenticationResultTime( public static void AgentUserFicFlowDetected(ILogger logger, string agentAppId, string identifierType) => s_agentUserFicFlowDetected(logger, agentAppId, identifierType, null); - public static void AgentUserFicSilentSuccess(ILogger logger, string accountLookupKey) - => s_agentUserFicSilentSuccess(logger, accountLookupKey, null); + public static void AgentUserFicSilentSuccess(ILogger logger, string agentAppId, string tenantId) + => s_agentUserFicSilentSuccess(logger, agentAppId, tenantId, null); - public static void AgentUserFicSilentFailure(ILogger logger, string accountLookupKey, string reason, Exception? ex) - => s_agentUserFicSilentFailure(logger, accountLookupKey, reason, ex); + public static void AgentUserFicSilentFailure(ILogger logger, string agentAppId, string tenantId, string reason, Exception? ex) + => s_agentUserFicSilentFailure(logger, agentAppId, tenantId, reason, ex); public static void AgentUserFicAcquisitionComplete(ILogger logger, string agentAppId, string tokenSource) => s_agentUserFicAcquisitionComplete(logger, agentAppId, tokenSource, null); diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 7ab8e374b..dcb954f27 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -694,7 +694,7 @@ private async Task GetAuthenticationResultForUserInternalA } var silentResult = await silentBuilder.ExecuteAsync().ConfigureAwait(false); - Logger.AgentUserFicSilentSuccess(_logger, accountLookupKey); + Logger.AgentUserFicSilentSuccess(_logger, agentAppId!, normalizedTenant); return silentResult; } catch (MsalException ex) @@ -702,7 +702,7 @@ private async Task GetAuthenticationResultForUserInternalA // Catch all MSAL exceptions (not just MsalUiRequiredException) so that // any silent failure (cache errors, client errors, etc.) falls back to // the full 3-leg acquisition rather than propagating up as a hard failure. - Logger.AgentUserFicSilentFailure(_logger, accountLookupKey, ex.ErrorCode ?? ex.GetType().Name, ex); + Logger.AgentUserFicSilentFailure(_logger, agentAppId!, normalizedTenant, ex.ErrorCode ?? ex.GetType().Name, ex); } } else From da1b63dd7f618d5c67168d7837fc2b4e81e1d4d7 Mon Sep 17 00:00:00 2001 From: avdunn Date: Fri, 26 Jun 2026 12:35:49 -0700 Subject: [PATCH 12/20] Enable shared static cache for agent CCAs and add cache isolation tests - Default agent User FIC CCAs to use EnableSharedCacheOptions so tokens survive CCA eviction (backed by process-level static dictionaries) - Add UseSharedCacheForAgentCcas internal property (default true) for test controllability - Add 4 shared cache isolation tests: 1. Multi-agent/multi-user correctness (initial + silent) 2. Cache miss with wrong agent/user (strict isolation) 3. Per-instance cache: tokens lost on eviction (UseSharedCache=false) 4. Shared cache: tokens survive CCA re-creation after eviction Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TokenAcquisition.cs | 19 +- .../TokenAcquisitionTests.cs | 325 ++++++++++++++++++ 2 files changed, 341 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index dcb954f27..0f8cd20d3 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -133,6 +133,13 @@ public bool IsExpired(long maxIdleMs) /// internal readonly ConcurrentDictionary _agentUserFicAccountIds = new(); + /// + /// When true, agent User FIC CCAs use MSAL's shared (process-level static) cache. + /// This allows tokens to survive CCA re-creation after eviction. + /// Defaults to true; set to false in tests that need per-instance cache isolation. + /// + internal bool UseSharedCacheForAgentCcas { get; set; } = true; + private static readonly string[] s_ficScopes = new[] { "api://AzureADTokenExchange/.default" }; private const string TokenBindingParameterName = "IsTokenBinding"; @@ -799,7 +806,7 @@ private async Task GetOrBuildAgentUserFicCcaAsyn // Capture authenticationScheme for the assertion callback closure. string? capturedAuthScheme = authenticationScheme; - var newApp = ConfidentialClientApplicationBuilder + var builder = ConfidentialClientApplicationBuilder .Create(agentAppId) .WithClientAssertion(async (AssertionRequestOptions options) => { @@ -835,8 +842,14 @@ private async Task GetOrBuildAgentUserFicCcaAsyn }) .WithAuthority(authority) .WithHttpClientFactory(_httpClientFactory) - .WithExperimentalFeatures() - .Build(); + .WithExperimentalFeatures(); + + if (UseSharedCacheForAgentCcas) + { + builder.WithCacheOptions(CacheOptions.EnableSharedCacheOptions); + } + + var newApp = builder.Build(); _agentUserFicCcas[ccaCacheKey] = new AgentCcaEntry(newApp); Logger.AgentCcaCreated(_logger, ccaCacheKey); diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs index 01233f8d6..900a8b073 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs @@ -790,6 +790,331 @@ public async Task AgentUserIdentity_NativeUserFic_UpnAndOidCachesAreIsolated() #endregion + #region Agent Shared Cache Isolation Tests + + /// + /// Verifies that with EnableSharedCacheOptions, tokens for 2 agents and 2 users + /// are cached and retrieved correctly — no cross-agent or cross-user collisions. + /// + [Fact] + public async Task AgentSharedCache_MultiAgentMultiUser_ReturnsCorrectTokens() + { + // Arrange — 1 blueprint, 2 agents, 2 users + string agent1 = Guid.NewGuid().ToString("N"); + string agent2 = Guid.NewGuid().ToString("N"); + string user1Uid = Guid.NewGuid().ToString("N"); + string user2Uid = Guid.NewGuid().ToString("N"); + string user1Upn = "user1@contoso.com"; + string user2Upn = "user2@contoso.com"; + + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + var mockHttp = serviceProvider.GetRequiredService() as MockHttpClientFactory; + IAuthorizationHeaderProvider authProvider = + serviceProvider.GetRequiredService(); + + // Enqueue handlers for all 4 combinations (agent1+user1, agent1+user2, agent2+user1, agent2+user2) + // Agent1+User1: Leg 1 + Leg 2 + Leg 3 + AddAgentUserFicMockHandlersForUser(mockHttp!, "token-a1-u1", user1Uid, user1Upn); + // Agent1+User2: Leg 3 only (Legs 1,2 cached from agent1 CCA) + mockHttp!.AddMockHandler(CreateUserFicTokenHandlerForUser("token-a1-u2", user2Uid, user2Upn)); + // Agent2+User1: New agent CCA → assertion callback fires (Leg 1) + Leg 2 + Leg 3 + AddAgentUserFicMockHandlersForUser(mockHttp!, "token-a2-u1", user1Uid, user1Upn); + // Agent2+User2: Leg 3 only (Leg 2 cached from agent2 CCA) + mockHttp.AddMockHandler(CreateUserFicTokenHandlerForUser("token-a2-u2", user2Uid, user2Upn)); + + // Act — acquire tokens for all 4 combinations + string r_a1u1 = await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user1Upn), + claimsPrincipal: null); + + string r_a1u2 = await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user2Upn), + claimsPrincipal: null); + + string r_a2u1 = await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent2, user1Upn), + claimsPrincipal: null); + + string r_a2u2 = await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent2, user2Upn), + claimsPrincipal: null); + + // Assert — each combination got its own unique token + Assert.Equal("Bearer token-a1-u1", r_a1u1); + Assert.Equal("Bearer token-a1-u2", r_a1u2); + Assert.Equal("Bearer token-a2-u1", r_a2u1); + Assert.Equal("Bearer token-a2-u2", r_a2u2); + + // Act — silent calls (no more handlers) return the correct cached token + string s_a1u1 = await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user1Upn), + claimsPrincipal: null); + string s_a2u2 = await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent2, user2Upn), + claimsPrincipal: null); + string s_a1u2 = await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user2Upn), + claimsPrincipal: null); + string s_a2u1 = await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent2, user1Upn), + claimsPrincipal: null); + + Assert.Equal("Bearer token-a1-u1", s_a1u1); + Assert.Equal("Bearer token-a2-u2", s_a2u2); + Assert.Equal("Bearer token-a1-u2", s_a1u2); + Assert.Equal("Bearer token-a2-u1", s_a2u1); + } + + /// + /// Verifies that a cached token for (agent1, user1) is NOT returned when + /// queried with a different agent or different user — strict cache isolation. + /// + [Fact] + public async Task AgentSharedCache_DifferentAgentOrUser_DoesNotReturnCachedToken() + { + // Arrange — cache a token for agent1+user1 + string agent1 = Guid.NewGuid().ToString("N"); + string agent2 = Guid.NewGuid().ToString("N"); + string user1Uid = Guid.NewGuid().ToString("N"); + string user2Uid = Guid.NewGuid().ToString("N"); + string user1Upn = "user1@contoso.com"; + string user2Upn = "user2@contoso.com"; + + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + var mockHttp = serviceProvider.GetRequiredService() as MockHttpClientFactory; + IAuthorizationHeaderProvider authProvider = + serviceProvider.GetRequiredService(); + + // Agent1+User1: full 3-leg flow + AddAgentUserFicMockHandlersForUser(mockHttp!, "cached-token-a1u1", user1Uid, user1Upn); + + await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user1Upn), + claimsPrincipal: null); + + // Now try different agent (same user) — should NOT get the cached token + // Needs all 3 legs (new agent CCA with new assertion callback) + AddAgentUserFicMockHandlersForUser(mockHttp!, "fresh-token-a2u1", user1Uid, user1Upn); + + string diffAgent = await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent2, user1Upn), + claimsPrincipal: null); + + // Now try same agent, different user — should NOT get the cached token + mockHttp!.AddMockHandler(CreateUserFicTokenHandlerForUser("fresh-token-a1u2", user2Uid, user2Upn)); + + string diffUser = await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user2Upn), + claimsPrincipal: null); + + // Assert — neither returned the original cached token + Assert.Equal("Bearer fresh-token-a2u1", diffAgent); + Assert.NotEqual("Bearer cached-token-a1u1", diffAgent); + + Assert.Equal("Bearer fresh-token-a1u2", diffUser); + Assert.NotEqual("Bearer cached-token-a1u1", diffUser); + } + + /// + /// Verifies that after CCA instances are evicted from the dictionary and new ones + /// are created with the same agent app IDs, the new CCAs can still acquire fresh + /// tokens (validating that per-instance caches die with the CCA). + /// + [Fact] + public async Task AgentSharedCache_NewCcaAfterEviction_AcquiresFreshTokens() + { + // Arrange + string agent1 = Guid.NewGuid().ToString("N"); + string user1Uid = Guid.NewGuid().ToString("N"); + string user1Upn = "user1@contoso.com"; + + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + var mockHttp = serviceProvider.GetRequiredService() as MockHttpClientFactory; + IAuthorizationHeaderProvider authProvider = + serviceProvider.GetRequiredService(); + + // Acquire the internal TokenAcquisition to manipulate dictionaries + var tokenAcquisition = serviceProvider.GetRequiredService() as TokenAcquisition; + + // Disable shared cache to test per-instance behavior (tokens lost on eviction) + tokenAcquisition!.UseSharedCacheForAgentCcas = false; + + // First acquisition: full 3-leg flow + AddAgentUserFicMockHandlersForUser(mockHttp!, "original-token", user1Uid, user1Upn); + + string result1 = await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user1Upn), + claimsPrincipal: null); + + Assert.Equal("Bearer original-token", result1); + + // Verify silent works (no handlers needed) + string silent1 = await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user1Upn), + claimsPrincipal: null); + Assert.Equal("Bearer original-token", silent1); + + // Evict the agent CCA from the dictionary (simulating sweep) + tokenAcquisition!._agentUserFicCcas.Clear(); + tokenAcquisition._agentUserFicAccountIds.Clear(); + + // After eviction, a new CCA must be built. Since we're using per-instance caches + // (no EnableSharedCacheOptions), the old tokens are gone. + // Need full 3-leg flow again with Leg 1 cached in blueprint. + mockHttp!.AddMockHandler(CreateClientCredentialsTokenHandler(accessToken: "t2-fresh")); + mockHttp.AddMockHandler(CreateUserFicTokenHandlerForUser("fresh-token-after-evict", user1Uid, user1Upn)); + + string result2 = await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user1Upn), + claimsPrincipal: null); + + // Assert — got a fresh token (old cache was lost with the CCA) + Assert.Equal("Bearer fresh-token-after-evict", result2); + Assert.NotEqual("Bearer original-token", result2); + } + + /// + /// Verifies that when EnableSharedCacheOptions is enabled on agent CCAs, + /// tokens survive CCA eviction and new CCAs can retrieve them via silent calls. + /// This validates that shared static cache makes agent tokens durable across + /// CCA lifecycle events. + /// + [Fact] + public async Task AgentSharedCache_WithSharedCacheEnabled_TokensSurviveCcaEviction() + { + // Arrange — 2 agents, 2 users, shared cache enabled + string agent1 = Guid.NewGuid().ToString("N"); + string agent2 = Guid.NewGuid().ToString("N"); + string user1Uid = Guid.NewGuid().ToString("N"); + string user2Uid = Guid.NewGuid().ToString("N"); + string user1Upn = "user1@contoso.com"; + string user2Upn = "user2@contoso.com"; + + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + var mockHttp = serviceProvider.GetRequiredService() as MockHttpClientFactory; + IAuthorizationHeaderProvider authProvider = + serviceProvider.GetRequiredService(); + var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); + + // UseSharedCacheForAgentCcas defaults to true — shared cache is the production behavior + + // Acquire tokens for both agents and both users + AddAgentUserFicMockHandlersForUser(mockHttp!, "shared-token-a1u1", user1Uid, user1Upn); + await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user1Upn), + claimsPrincipal: null); + + mockHttp!.AddMockHandler(CreateUserFicTokenHandlerForUser("shared-token-a1u2", user2Uid, user2Upn)); + await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user2Upn), + claimsPrincipal: null); + + AddAgentUserFicMockHandlersForUser(mockHttp, "shared-token-a2u1", user1Uid, user1Upn); + await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent2, user1Upn), + claimsPrincipal: null); + + mockHttp.AddMockHandler(CreateUserFicTokenHandlerForUser("shared-token-a2u2", user2Uid, user2Upn)); + await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent2, user2Upn), + claimsPrincipal: null); + + // Evict ALL agent CCAs (simulating sweep clearing everything) + tokenAcquisition._agentUserFicCcas.Clear(); + // Keep _agentUserFicAccountIds intact — they store account identifiers for silent lookup + + // Act — acquire again (will build new CCAs, but tokens should come from shared static cache) + // New CCAs need assertion callbacks to work → Leg 1 handlers for blueprint + // But AcquireTokenSilent doesn't need HTTP handlers! + + // Agent1+User1: New CCA built (needs Leg 1 for assertion), silent should find token + mockHttp.AddMockHandler(CreateClientCredentialsTokenHandler(accessToken: "t1-rebuild-a1")); + string result_a1u1 = await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user1Upn), + claimsPrincipal: null); + + // Agent2+User2: New CCA built (needs Leg 1 for assertion), silent should find token + mockHttp.AddMockHandler(CreateClientCredentialsTokenHandler(accessToken: "t1-rebuild-a2")); + string result_a2u2 = await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent2, user2Upn), + claimsPrincipal: null); + + // Assert — tokens from original acquisition should come back (cached in shared static storage) + Assert.Equal("Bearer shared-token-a1u1", result_a1u1); + Assert.Equal("Bearer shared-token-a2u2", result_a2u2); + } + + // --- Helpers for user-specific mock handlers --- + + /// + /// Adds mock handlers for the 3-leg flow with a specific user identity (uid/upn). + /// This allows testing cache isolation between different users. + /// + private static void AddAgentUserFicMockHandlersForUser( + MockHttpClientFactory mockHttpClient, + string userAccessToken, + string userUid, + string userUpn) + { + // Leg 1: Blueprint FMI token (T1) + mockHttpClient.AddMockHandler(CreateClientCredentialsTokenHandler(accessToken: "t1-fmi-token")); + // Leg 2: Agent instance token (T2) + mockHttpClient.AddMockHandler(CreateClientCredentialsTokenHandler(accessToken: "t2-instance-token")); + // Leg 3: User token with specific user identity + mockHttpClient.AddMockHandler(CreateUserFicTokenHandlerForUser(userAccessToken, userUid, userUpn)); + } + + /// + /// Creates a user_fic response handler with a specific user identity (uid/upn) + /// so MSAL creates a distinct account per user in the cache. + /// + private static MockHttpMessageHandler CreateUserFicTokenHandlerForUser( + string accessToken, string userUid, string userUpn) + { + string clientInfo = EncodeBase64Url( + "{\"uid\":\"" + userUid + "\",\"utid\":\"" + TestConstants.Utid + "\"}"); + string idToken = MockHttpCreator.CreateIdToken(userUid, userUpn); + + return new MockHttpMessageHandler() + { + ExpectedMethod = HttpMethod.Post, + ResponseMessage = MockHttpCreator.CreateSuccessResponseMessage( + "{\"token_type\":\"Bearer\"," + + "\"expires_in\":3599," + + "\"scope\":\"https://graph.microsoft.com/.default openid profile offline_access\"," + + "\"access_token\":\"" + accessToken + "\"," + + "\"refresh_token\":\"rt-" + accessToken + "\"," + + "\"client_info\":\"" + clientInfo + "\"," + + "\"id_token\":\"" + idToken + "\"}"), + }; + } + + #endregion + #region Agent CCA Sweep Eviction Tests /// From 14a4849cfd9ac6982a0736d97fda9bb83141f551 Mon Sep 17 00:00:00 2001 From: avdunn Date: Fri, 26 Jun 2026 12:52:47 -0700 Subject: [PATCH 13/20] Clean up tests and comments: consolidate redundant tests, fix stale comments - Remove 4 redundant tests: - WorksWithNonNullClaimsPrincipal (subsumed by CacheWorksWithNewClaimsPrincipalPerCall) - OidCacheWorksWithNewClaimsPrincipalPerCall (OID basic caching already covered) - DifferentAgentOrUser_DoesNotReturnCachedToken (covered by MultiAgentMultiUser test) - ReturnsZero_WhenNothingExpired (trivial, covered by DoesNotRemoveRecentlyAccessed) - Update GetOrBuildAgentUserFicCcaAsync docstring to reflect shared cache design - Remove fragile line-number reference in comment - Clean up verbose metacommentary in test comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TokenAcquisition.cs | 10 +- .../TokenAcquisitionTests.cs | 195 +----------------- 2 files changed, 16 insertions(+), 189 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 0f8cd20d3..2115e3ab0 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -758,9 +758,8 @@ private async Task GetAuthenticationResultForUserInternalA Logger.AgentUserFicAcquisitionComplete(_logger, agentAppId!, result.AuthenticationResultMetadata.TokenSource.ToString()); // Store the account identifier for subsequent silent lookups. - // This parallels how other ID Web flows write oid/tid claims back into the - // ClaimsPrincipal after acquisition (see line ~541 in the ROPC path). Here, - // ClaimsPrincipal is unavailable, so we use _agentUserFicAccountIds instead. + // In other ID Web flows, this is persisted in the ClaimsPrincipal (oid+tid claims). + // Here, ClaimsPrincipal is unavailable, so we use _agentUserFicAccountIds instead. if (result.Account?.HomeAccountId is not null) { _agentUserFicAccountIds[accountLookupKey] = result.Account.HomeAccountId.Identifier; @@ -772,8 +771,9 @@ private async Task GetAuthenticationResultForUserInternalA /// /// Gets or builds an agent CCA for the native User FIC flow. Each agent CCA uses an /// assertion callback that chains back to the blueprint CCA for Leg 1 (FMI token). - /// The agent CCA's in-memory cache provides natural token isolation per agent. - /// Entries are tracked with last-access timestamps for idle-based eviction. + /// Each agent CCA has a unique ClientId (the agent app ID), providing natural cache + /// key isolation in the shared static cache. Entries are tracked with last-access + /// timestamps for idle-based eviction. /// private async Task GetOrBuildAgentUserFicCcaAsync( string agentAppId, diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs index 900a8b073..8cc7aae66 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs @@ -464,48 +464,9 @@ public async Task AgentUserIdentity_NativeUserFic_UsesCacheOnSecondCall() } /// - /// Verifies that the native User FIC path works even when ClaimsPrincipal is non-null. - /// The UPN agentic flow always uses the native path regardless of ClaimsPrincipal state. - /// - [Fact] - public async Task AgentUserIdentity_NativeUserFic_WorksWithNonNullClaimsPrincipal() - { - // Arrange - string agentAppId = Guid.NewGuid().ToString("N"); - var factory = InitTokenAcquirerFactoryForAgent(); - IServiceProvider serviceProvider = factory.Build(); - - var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; - AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "user-token-1"); - - IAuthorizationHeaderProvider authorizationHeaderProvider = - serviceProvider.GetRequiredService(); - - var claimsPrincipal = new System.Security.Claims.ClaimsPrincipal( - new Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity()); - var options = CreateAgentIdentityOptions(agentAppId); - - // Act — first call with non-null ClaimsPrincipal - string result1 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( - new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: options, - claimsPrincipal: claimsPrincipal); - - // Act — second call: should still use cache - string result2 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( - new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: options, - claimsPrincipal: claimsPrincipal); - - // Assert - Assert.Equal("Bearer user-token-1", result1); - Assert.Equal("Bearer user-token-1", result2); - } - - /// - /// Verifies cache works with new ClaimsPrincipal instances per call. Unlike the - /// old ROPC path, the native User FIC path does not depend on ClaimsPrincipal - /// for cache lookups — the account identifier is stored internally. + /// Verifies cache works with new ClaimsPrincipal instances per call. The native + /// User FIC path does not depend on ClaimsPrincipal for cache lookups — the + /// account identifier is stored internally via _agentUserFicAccountIds. /// [Fact] public async Task AgentUserIdentity_NativeUserFic_CacheWorksWithNewClaimsPrincipalPerCall() @@ -706,43 +667,6 @@ public async Task AgentUserIdentity_NativeUserFic_OidUsesCacheOnSecondCall() Assert.Equal("Bearer user-token-oid-1", result2); } - /// - /// Verifies that OID-based flow works with fresh ClaimsPrincipal instances per call. - /// - [Fact] - public async Task AgentUserIdentity_NativeUserFic_OidCacheWorksWithNewClaimsPrincipalPerCall() - { - // Arrange - string agentAppId = Guid.NewGuid().ToString("N"); - var factory = InitTokenAcquirerFactoryForAgent(); - IServiceProvider serviceProvider = factory.Build(); - - var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; - AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "user-token-oid-2"); - - IAuthorizationHeaderProvider authorizationHeaderProvider = - serviceProvider.GetRequiredService(); - - var options = CreateAgentIdentityOptionsWithOid(agentAppId, AgentTestUserOid); - - // Act — each call gets a fresh ClaimsPrincipal (simulates request-scoped DI) - string result1 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( - new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: options, - claimsPrincipal: new System.Security.Claims.ClaimsPrincipal( - new Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity())); - - string result2 = await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( - new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: options, - claimsPrincipal: new System.Security.Claims.ClaimsPrincipal( - new Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity())); - - // Assert - Assert.Equal("Bearer user-token-oid-2", result1); - Assert.Equal("Bearer user-token-oid-2", result2); - } - /// /// Verifies that UPN and OID flows for the same agent produce separate cached tokens, /// ensuring cache isolation between the two identifier types. @@ -759,10 +683,8 @@ public async Task AgentUserIdentity_NativeUserFic_UpnAndOidCachesAreIsolated() // UPN flow handlers (3 legs) AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "upn-user-token"); - // OID flow handlers (3 legs — Leg 1 may be cached from UPN flow, but Leg 2 + Leg 3 are needed) - // Note: Leg 1 (blueprint FMI) is cached in the blueprint CCA, but Leg 2 uses the agent CCA - // which also caches T2. Since OID and UPN go to the same agent CCA, Leg 2 may be cached. - // We still need a Leg 3 handler for the OID grant type. + // OID flow: Legs 1 and 2 are cached (same blueprint and agent CCA), + // only a Leg 3 handler is needed for the OID grant type. mockHttpClient!.AddMockHandler(CreateUserFicTokenHandler(accessToken: "oid-user-token")); IAuthorizationHeaderProvider authorizationHeaderProvider = @@ -874,64 +796,10 @@ public async Task AgentSharedCache_MultiAgentMultiUser_ReturnsCorrectTokens() Assert.Equal("Bearer token-a2-u1", s_a2u1); } - /// - /// Verifies that a cached token for (agent1, user1) is NOT returned when - /// queried with a different agent or different user — strict cache isolation. - /// - [Fact] - public async Task AgentSharedCache_DifferentAgentOrUser_DoesNotReturnCachedToken() - { - // Arrange — cache a token for agent1+user1 - string agent1 = Guid.NewGuid().ToString("N"); - string agent2 = Guid.NewGuid().ToString("N"); - string user1Uid = Guid.NewGuid().ToString("N"); - string user2Uid = Guid.NewGuid().ToString("N"); - string user1Upn = "user1@contoso.com"; - string user2Upn = "user2@contoso.com"; - - var factory = InitTokenAcquirerFactoryForAgent(); - IServiceProvider serviceProvider = factory.Build(); - var mockHttp = serviceProvider.GetRequiredService() as MockHttpClientFactory; - IAuthorizationHeaderProvider authProvider = - serviceProvider.GetRequiredService(); - - // Agent1+User1: full 3-leg flow - AddAgentUserFicMockHandlersForUser(mockHttp!, "cached-token-a1u1", user1Uid, user1Upn); - - await authProvider.CreateAuthorizationHeaderForUserAsync( - new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user1Upn), - claimsPrincipal: null); - - // Now try different agent (same user) — should NOT get the cached token - // Needs all 3 legs (new agent CCA with new assertion callback) - AddAgentUserFicMockHandlersForUser(mockHttp!, "fresh-token-a2u1", user1Uid, user1Upn); - - string diffAgent = await authProvider.CreateAuthorizationHeaderForUserAsync( - new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent2, user1Upn), - claimsPrincipal: null); - - // Now try same agent, different user — should NOT get the cached token - mockHttp!.AddMockHandler(CreateUserFicTokenHandlerForUser("fresh-token-a1u2", user2Uid, user2Upn)); - - string diffUser = await authProvider.CreateAuthorizationHeaderForUserAsync( - new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user2Upn), - claimsPrincipal: null); - - // Assert — neither returned the original cached token - Assert.Equal("Bearer fresh-token-a2u1", diffAgent); - Assert.NotEqual("Bearer cached-token-a1u1", diffAgent); - - Assert.Equal("Bearer fresh-token-a1u2", diffUser); - Assert.NotEqual("Bearer cached-token-a1u1", diffUser); - } - /// /// Verifies that after CCA instances are evicted from the dictionary and new ones - /// are created with the same agent app IDs, the new CCAs can still acquire fresh - /// tokens (validating that per-instance caches die with the CCA). + /// are created with the same agent app IDs, the new CCAs must acquire fresh + /// tokens when shared cache is disabled (per-instance caches die with the CCA). /// [Fact] public async Task AgentSharedCache_NewCcaAfterEviction_AcquiresFreshTokens() @@ -1014,8 +882,6 @@ public async Task AgentSharedCache_WithSharedCacheEnabled_TokensSurviveCcaEvicti serviceProvider.GetRequiredService(); var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); - // UseSharedCacheForAgentCcas defaults to true — shared cache is the production behavior - // Acquire tokens for both agents and both users AddAgentUserFicMockHandlersForUser(mockHttp!, "shared-token-a1u1", user1Uid, user1Upn); await authProvider.CreateAuthorizationHeaderForUserAsync( @@ -1041,22 +907,18 @@ await authProvider.CreateAuthorizationHeaderForUserAsync( authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent2, user2Upn), claimsPrincipal: null); - // Evict ALL agent CCAs (simulating sweep clearing everything) + // Evict ALL agent CCAs (simulating sweep clearing everything). + // Keep _agentUserFicAccountIds intact — silent lookup needs them to find the account. tokenAcquisition._agentUserFicCcas.Clear(); - // Keep _agentUserFicAccountIds intact — they store account identifiers for silent lookup - - // Act — acquire again (will build new CCAs, but tokens should come from shared static cache) - // New CCAs need assertion callbacks to work → Leg 1 handlers for blueprint - // But AcquireTokenSilent doesn't need HTTP handlers! - // Agent1+User1: New CCA built (needs Leg 1 for assertion), silent should find token + // Act — new CCAs must be built, but tokens should come from shared static cache. + // Each new CCA needs a Leg 1 handler (assertion callback fires on first use). mockHttp.AddMockHandler(CreateClientCredentialsTokenHandler(accessToken: "t1-rebuild-a1")); string result_a1u1 = await authProvider.CreateAuthorizationHeaderForUserAsync( new[] { "https://graph.microsoft.com/.default" }, authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user1Upn), claimsPrincipal: null); - // Agent2+User2: New CCA built (needs Leg 1 for assertion), silent should find token mockHttp.AddMockHandler(CreateClientCredentialsTokenHandler(accessToken: "t1-rebuild-a2")); string result_a2u2 = await authProvider.CreateAuthorizationHeaderForUserAsync( new[] { "https://graph.microsoft.com/.default" }, @@ -1326,41 +1188,6 @@ await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( Assert.True(hasNewAgentAccountId, "New agent's account IDs should survive the sweep."); } - /// - /// Verifies that SweepExpiredAgentCcas returns zero when no entries are expired. - /// - [Fact] - public async Task AgentCcaSweep_ReturnsZero_WhenNothingExpired() - { - // Arrange - string agentAppId = Guid.NewGuid().ToString("N"); - var factory = InitTokenAcquirerFactoryForAgent(); - IServiceProvider serviceProvider = factory.Build(); - - var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); - // Long idle threshold — nothing will expire - tokenAcquisition.AgentCcaMaxIdleMilliseconds = (long)TimeSpan.FromHours(1).TotalMilliseconds; - - var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; - AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "fresh-token"); - - var options = CreateAgentIdentityOptions(agentAppId); - IAuthorizationHeaderProvider authorizationHeaderProvider = - serviceProvider.GetRequiredService(); - - await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( - new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: options, - claimsPrincipal: null); - - // Act - int evicted = tokenAcquisition.SweepExpiredAgentCcas(); - - // Assert - Assert.Equal(0, evicted); - Assert.True(tokenAcquisition._agentUserFicCcas.Count == 1); - } - #endregion #region ExtractTenantFromTokenEndpointIfSameInstance Tests From 19dcddc2c272535616dd472ecbb3f5d0db71cf18 Mon Sep 17 00:00:00 2001 From: avdunn Date: Fri, 26 Jun 2026 13:06:00 -0700 Subject: [PATCH 14/20] Replace periodic sweep with simple size-threshold eviction Replace the timer-based idle sweep (AgentCcaEntry, Stopwatch timestamps, SweepExpiredAgentCcas, EnsureAgentCcaSweepTimerStarted) with a simple size-threshold clear: when _agentUserFicCcas exceeds AgentCcaMaxCount (default 10,000), clear the entire dictionary. This is safe because tokens live in MSAL's shared static cache (enabled via EnableSharedCacheOptions), not per-CCA instance caches. Clearing the dictionary only discards lightweight CCA wrapper objects. New CCAs built with the same clientId will find cached tokens via AcquireTokenSilent. Removes: - AgentCcaEntry class (timestamps, Touch, IsExpired) - AgentCcaMaxIdleMilliseconds, AgentCcaSweepInterval properties - _agentCcaSweepTimer field - EnsureAgentCcaSweepTimerStarted(), SweepExpiredAgentCcas() methods - System.Diagnostics using (Stopwatch no longer needed) - 4 sweep eviction tests (timer, touch, companion cleanup, selective) Adds: - AgentCcaMaxCount property (default 10,000) - Inline size check after CCA creation - 1 size-threshold eviction test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TokenAcquisition.cs | 148 ++---------- .../TokenAcquisitionTests.cs | 219 +++--------------- 2 files changed, 52 insertions(+), 315 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 2115e3ab0..57467a134 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IdentityModel.Tokens.Jwt; @@ -63,48 +62,12 @@ class OAuthConstants private readonly ConcurrentDictionary _appSemaphores = new(); /// - /// Wraps an agent CCA with a last-access timestamp for idle-based eviction. - /// The sweep timer removes entries that haven't been accessed within - /// . + /// Maximum number of agent CCA instances to keep in the dictionary before + /// clearing it as a DOS protection measure. Since tokens are stored in MSAL's + /// shared static cache (not per-CCA), clearing the dictionary only discards + /// lightweight CCA objects — tokens remain accessible to newly-built CCAs. /// - internal sealed class AgentCcaEntry - { - private static readonly double s_ticksPerMs = Stopwatch.Frequency / 1000.0; - - public IConfidentialClientApplication Cca { get; } - private long _lastAccessedTicks; - - public AgentCcaEntry(IConfidentialClientApplication cca) - { - Cca = cca; - _lastAccessedTicks = Stopwatch.GetTimestamp(); - } - - public long LastAccessedTicks => Volatile.Read(ref _lastAccessedTicks); - - public void Touch() => Volatile.Write(ref _lastAccessedTicks, Stopwatch.GetTimestamp()); - - public bool IsExpired(long maxIdleMs) - { - long elapsedTicks = Stopwatch.GetTimestamp() - LastAccessedTicks; - return (elapsedTicks / s_ticksPerMs) > maxIdleMs; - } - } - - /// - /// Maximum time (in milliseconds) an agent CCA can remain idle before being - /// eligible for eviction by the sweep timer. Default: 8 hours. - /// Aligns with typical token lifetimes — a CCA idle for this long likely has - /// mostly-expired tokens, making eviction nearly free. - /// Internal for testing — production code uses the default. - /// - internal long AgentCcaMaxIdleMilliseconds { get; set; } = (long)TimeSpan.FromHours(8).TotalMilliseconds; - - /// - /// Interval between background sweep runs. Default: 30 minutes. - /// Internal for testing — production code uses the default. - /// - internal TimeSpan AgentCcaSweepInterval { get; set; } = TimeSpan.FromMinutes(30); + internal int AgentCcaMaxCount { get; set; } = 10000; /// /// Caches agent CCAs for the native User FIC flow. Each agent CCA uses an assertion @@ -113,11 +76,11 @@ public bool IsExpired(long maxIdleMs) /// The authenticationScheme is included because different schemes may resolve to /// different blueprint credentials (e.g. certificates), and the agent CCA's assertion /// callback captures the scheme to chain to the correct blueprint CCA. - /// Entries are wrapped in for idle-based eviction. + /// When the dictionary exceeds , it is cleared entirely; + /// tokens survive in MSAL's shared static cache and are found by new CCAs via silent calls. /// - internal readonly ConcurrentDictionary _agentUserFicCcas = new(); + internal readonly ConcurrentDictionary _agentUserFicCcas = new(); private readonly ConcurrentDictionary _agentCcaSemaphores = new(); - private Timer? _agentCcaSweepTimer; // Not explicitly disposed — TokenAcquisition is a DI singleton with process lifetime. /// /// Maps (agentAppId, user identifier, tenantId) tuples to MSAL account identifiers for the @@ -789,8 +752,7 @@ private async Task GetOrBuildAgentUserFicCcaAsyn if (_agentUserFicCcas.TryGetValue(ccaCacheKey, out var existing)) { - existing.Touch(); - return existing.Cca; + return existing; } var semaphore = _agentCcaSemaphores.GetOrAdd(ccaCacheKey, _ => new SemaphoreSlim(1, 1)); @@ -799,8 +761,7 @@ private async Task GetOrBuildAgentUserFicCcaAsyn { if (_agentUserFicCcas.TryGetValue(ccaCacheKey, out var entry)) { - entry.Touch(); - return entry.Cca; + return entry; } // Capture authenticationScheme for the assertion callback closure. @@ -851,11 +812,18 @@ private async Task GetOrBuildAgentUserFicCcaAsyn var newApp = builder.Build(); - _agentUserFicCcas[ccaCacheKey] = new AgentCcaEntry(newApp); + _agentUserFicCcas[ccaCacheKey] = newApp; Logger.AgentCcaCreated(_logger, ccaCacheKey); - // Start the sweep timer on first agent CCA creation (lazy initialization). - EnsureAgentCcaSweepTimerStarted(); + // DOS protection: if the dictionary grows beyond the threshold, clear it. + // Tokens survive in MSAL's shared static cache and will be found by new CCAs. + if (_agentUserFicCcas.Count > AgentCcaMaxCount) + { + int cleared = _agentUserFicCcas.Count; + _agentUserFicCcas.Clear(); + _agentUserFicAccountIds.Clear(); + Logger.AgentCcaEviction(_logger, cleared, 0); + } return newApp; } @@ -865,82 +833,6 @@ private async Task GetOrBuildAgentUserFicCcaAsyn } } - /// - /// Lazily starts the background sweep timer on first agent CCA creation. - /// Thread-safe via Interlocked.CompareExchange. - /// - private void EnsureAgentCcaSweepTimerStarted() - { - if (_agentCcaSweepTimer is not null) - { - return; - } - - var newTimer = new Timer( - _ => SweepExpiredAgentCcas(), - null, - Timeout.InfiniteTimeSpan, // Don't start immediately - Timeout.InfiniteTimeSpan); - - if (Interlocked.CompareExchange(ref _agentCcaSweepTimer, newTimer, null) is not null) - { - // Another thread already created the timer — dispose ours. - newTimer.Dispose(); - } - else - { - // We won the race — start the timer. - _agentCcaSweepTimer!.Change(AgentCcaSweepInterval, AgentCcaSweepInterval); - } - } - - /// - /// Removes agent CCA entries that have been idle for longer than - /// . Also cleans up companion - /// entries for evicted agents. - /// Called by the background sweep timer and can be invoked manually in tests. - /// - internal int SweepExpiredAgentCcas() - { - int evictedCount = 0; - - foreach (var kvp in _agentUserFicCcas) - { - if (kvp.Value.IsExpired(AgentCcaMaxIdleMilliseconds)) - { - if (_agentUserFicCcas.TryRemove(kvp.Key, out _)) - { - if (_agentCcaSemaphores.TryRemove(kvp.Key, out var semaphore)) - { - semaphore.Dispose(); - } - - // Clean up companion account ID entries for this agent CCA. - // Account IDs are keyed as "{agentAppId}:{USER_IDENTIFIER}:{TENANTID}", - // and the CCA key is "{agentAppId}:{authenticationScheme}". - // Extract the agentAppId prefix to match related account entries. - string agentAppIdPrefix = kvp.Key.Split(':')[0] + ":"; - foreach (var accountKvp in _agentUserFicAccountIds) - { - if (accountKvp.Key.StartsWith(agentAppIdPrefix, StringComparison.Ordinal)) - { - _agentUserFicAccountIds.TryRemove(accountKvp.Key, out _); - } - } - - evictedCount++; - } - } - } - - if (evictedCount > 0) - { - Logger.AgentCcaEviction(_logger, evictedCount, _agentUserFicCcas.Count); - } - - return evictedCount; - } - /// /// Extracts the tenant ID from an OAuth2 token endpoint URL when the endpoint belongs /// to the same cloud instance as the configured authority. Returns null if the hosts diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs index 8cc7aae66..261e44d93 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs @@ -977,215 +977,60 @@ private static MockHttpMessageHandler CreateUserFicTokenHandlerForUser( #endregion - #region Agent CCA Sweep Eviction Tests + #region Agent CCA Size-Threshold Eviction Tests /// - /// Verifies that the sweep timer removes agent CCA entries that have been - /// idle for longer than the configured maximum idle time. + /// Verifies that when the agent CCA dictionary exceeds the configured threshold, + /// it is cleared entirely as DOS protection. Tokens survive in MSAL's shared + /// static cache (tested separately by WithSharedCacheEnabled_TokensSurviveCcaEviction). /// [Fact] - public async Task AgentCcaSweep_RemovesExpiredEntries() + public async Task AgentCcaEviction_ClearsDictionaryAtThreshold() { - // Arrange - string agentAppId = Guid.NewGuid().ToString("N"); - var factory = InitTokenAcquirerFactoryForAgent(); - IServiceProvider serviceProvider = factory.Build(); - - var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); - - // Override the idle threshold to a very short value for testing. - tokenAcquisition.AgentCcaMaxIdleMilliseconds = 50; - - var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; - AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "sweep-token-1"); - - var options = CreateAgentIdentityOptions(agentAppId); - - IAuthorizationHeaderProvider authorizationHeaderProvider = - serviceProvider.GetRequiredService(); - - // Act — first call populates the agent CCA and account IDs - await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( - new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: options, - claimsPrincipal: null); - - Assert.True(tokenAcquisition._agentUserFicCcas.Count == 1); - Assert.NotEmpty(tokenAcquisition._agentUserFicAccountIds); - - // Wait for the entry to expire - await Task.Delay(TimeSpan.FromMilliseconds(100)); - - // Act — manual sweep - int evicted = tokenAcquisition.SweepExpiredAgentCcas(); - - // Assert - Assert.Equal(1, evicted); - Assert.True(tokenAcquisition._agentUserFicCcas.Count == 0); - Assert.True(tokenAcquisition._agentUserFicAccountIds.IsEmpty); - } - - /// - /// Verifies that the sweep does not remove agent CCA entries that have been - /// recently accessed (i.e., the touch mechanism resets the idle timer). - /// - [Fact] - public async Task AgentCcaSweep_DoesNotRemoveRecentlyAccessedEntries() - { - // Arrange - string agentAppId = Guid.NewGuid().ToString("N"); + // Arrange — set a very low threshold to trigger clearing var factory = InitTokenAcquirerFactoryForAgent(); IServiceProvider serviceProvider = factory.Build(); - - var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); - tokenAcquisition.AgentCcaMaxIdleMilliseconds = 200; - - var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; - AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "sweep-token-2"); - - var options = CreateAgentIdentityOptions(agentAppId); - - IAuthorizationHeaderProvider authorizationHeaderProvider = + var mockHttp = serviceProvider.GetRequiredService() as MockHttpClientFactory; + IAuthorizationHeaderProvider authProvider = serviceProvider.GetRequiredService(); - - // Act — first call populates - await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( - new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: options, - claimsPrincipal: null); - - // Touch via second call (cache hit, no new handlers needed) - await Task.Delay(TimeSpan.FromMilliseconds(100)); - await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( - new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: options, - claimsPrincipal: null); - - // Sweep should find nothing expired (entry was just touched) - int evicted = tokenAcquisition.SweepExpiredAgentCcas(); - - // Assert - Assert.Equal(0, evicted); - Assert.True(tokenAcquisition._agentUserFicCcas.Count == 1); - } - - /// - /// Verifies that the sweep cleans up companion _agentUserFicAccountIds entries - /// when an agent CCA is evicted. - /// - [Fact] - public async Task AgentCcaSweep_CleansUpCompanionAccountIds() - { - // Arrange — two agents, each with their own user tokens - string agentAppId1 = Guid.NewGuid().ToString("N"); - string agentAppId2 = Guid.NewGuid().ToString("N"); - var factory = InitTokenAcquirerFactoryForAgent(); - IServiceProvider serviceProvider = factory.Build(); - var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); - tokenAcquisition.AgentCcaMaxIdleMilliseconds = 50; - var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + // Set threshold to 2 so the 3rd agent triggers a clear + tokenAcquisition.AgentCcaMaxCount = 2; - // Agent 1 flow - AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "agent1-user-token"); - var options1 = CreateAgentIdentityOptions(agentAppId1); - IAuthorizationHeaderProvider authorizationHeaderProvider = - serviceProvider.GetRequiredService(); - await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( - new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: options1, - claimsPrincipal: null); + string agent1 = Guid.NewGuid().ToString("N"); + string agent2 = Guid.NewGuid().ToString("N"); + string agent3 = Guid.NewGuid().ToString("N"); + string user1Uid = Guid.NewGuid().ToString("N"); + string user1Upn = "user1@contoso.com"; - // Agent 2 flow - AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "agent2-user-token"); - var options2 = CreateAgentIdentityOptions(agentAppId2); - await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + // Populate 2 agents (at threshold, not over) + AddAgentUserFicMockHandlersForUser(mockHttp!, "token-a1", user1Uid, user1Upn); + await authProvider.CreateAuthorizationHeaderForUserAsync( new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: options2, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user1Upn), claimsPrincipal: null); - Assert.True(tokenAcquisition._agentUserFicCcas.Count == 2); - int accountIdsBefore = tokenAcquisition._agentUserFicAccountIds.Count; - Assert.True(accountIdsBefore >= 2, "Should have account IDs for both agents."); - - // Wait for both to expire - await Task.Delay(TimeSpan.FromMilliseconds(100)); - - // Act - int evicted = tokenAcquisition.SweepExpiredAgentCcas(); - - // Assert — both CCAs evicted, all companion account IDs cleaned up - Assert.Equal(2, evicted); - Assert.True(tokenAcquisition._agentUserFicCcas.Count == 0); - Assert.True(tokenAcquisition._agentUserFicAccountIds.IsEmpty); - } - - /// - /// Verifies that when only one of two agents expires, the sweep only evicts - /// the expired agent and leaves the other intact (including its account IDs). - /// - [Fact] - public async Task AgentCcaSweep_SelectiveEviction_OnlyRemovesExpiredAgent() - { - // Arrange - string agentAppIdOld = Guid.NewGuid().ToString("N"); - string agentAppIdNew = Guid.NewGuid().ToString("N"); - var factory = InitTokenAcquirerFactoryForAgent(); - IServiceProvider serviceProvider = factory.Build(); - - var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); - // Use a generous idle threshold so the new agent stays well within bounds - // even under CI CPU contention. The old agent will be force-expired by - // reducing the threshold right before the sweep. - tokenAcquisition.AgentCcaMaxIdleMilliseconds = 5000; - - var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; - - // Create old agent first - AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "old-agent-token"); - var optionsOld = CreateAgentIdentityOptions(agentAppIdOld); - IAuthorizationHeaderProvider authorizationHeaderProvider = - serviceProvider.GetRequiredService(); - await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + AddAgentUserFicMockHandlersForUser(mockHttp!, "token-a2", user1Uid, user1Upn); + await authProvider.CreateAuthorizationHeaderForUserAsync( new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: optionsOld, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent2, user1Upn), claimsPrincipal: null); - // Wait long enough to create a clear gap between old and new agent timestamps. - await Task.Delay(TimeSpan.FromMilliseconds(200)); + Assert.Equal(2, tokenAcquisition._agentUserFicCcas.Count); + Assert.Equal(2, tokenAcquisition._agentUserFicAccountIds.Count); - AddAgentUserFicMockHandlers(mockHttpClient!, userAccessToken: "new-agent-token"); - var optionsNew = CreateAgentIdentityOptions(agentAppIdNew); - await authorizationHeaderProvider.CreateAuthorizationHeaderForUserAsync( + // Add 3rd agent — exceeds threshold, triggers clear + AddAgentUserFicMockHandlersForUser(mockHttp!, "token-a3", user1Uid, user1Upn); + await authProvider.CreateAuthorizationHeaderForUserAsync( new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: optionsNew, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent3, user1Upn), claimsPrincipal: null); - Assert.True(tokenAcquisition._agentUserFicCcas.Count == 2); - - // Now set a threshold that the old agent (200ms+ idle) exceeds - // but the new agent (just created) does not. - tokenAcquisition.AgentCcaMaxIdleMilliseconds = 100; - - // Act - int evicted = tokenAcquisition.SweepExpiredAgentCcas(); - - // Assert — only old agent evicted - Assert.Equal(1, evicted); - Assert.True(tokenAcquisition._agentUserFicCcas.Count == 1); - - // New agent's account IDs should still be present - bool hasNewAgentAccountId = false; - foreach (var kvp in tokenAcquisition._agentUserFicAccountIds) - { - if (kvp.Key.StartsWith(agentAppIdNew + ":", StringComparison.Ordinal)) - { - hasNewAgentAccountId = true; - break; - } - } - Assert.True(hasNewAgentAccountId, "New agent's account IDs should survive the sweep."); + // Assert — CCA dictionary was cleared; only the 3rd agent's account ID remains + // (written after the clear, during Leg 3 of the triggering call) + Assert.Empty(tokenAcquisition._agentUserFicCcas); + Assert.Single(tokenAcquisition._agentUserFicAccountIds); } #endregion From e813abd9b86c3d03a15d5ce5ad1a5531074ffe35 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 30 Jun 2026 08:36:55 -0700 Subject: [PATCH 15/20] Address review feedback: normalize agentAppId, handle Guid OID values, clear semaphores on eviction, fix stale XML docs and log message - Normalize agentAppId to uppercase to prevent duplicate CCAs from GUID casing - Accept Guid objects (not just strings) for OID via ToString() fallback - Clear _agentCcaSemaphores alongside CCA/account dictionaries on threshold eviction - Fix stale XML docs referencing timestamp-based eviction (now size-threshold) - Add shared-cache caveat to AgentCcaMaxCount doc - Fix _agentUserFicAccountIds doc (opportunistic cleanup, not MSAL-driven eviction) - Update log message from 'sweep evicted' to 'cache cleared (exceeded size threshold)' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TokenAcquisition.Logger.cs | 2 +- .../TokenAcquisition.cs | 23 ++++++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.Logger.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.Logger.cs index 0c80b2ad5..37fa4c3c1 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.Logger.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.Logger.cs @@ -118,7 +118,7 @@ public static void TokenAcquisitionMsalAuthenticationResultTime( LoggerMessage.Define( LogLevel.Information, LoggingEventId.AgentCcaEviction, - "[MsIdWeb] Agent CCA sweep evicted {EvictedCount} entries. Remaining: {RemainingCount}."); + "[MsIdWeb] Agent CCA cache cleared {EvictedCount} entries (exceeded size threshold). Remaining: {RemainingCount}."); public static void AgentUserFicFlowDetected(ILogger logger, string agentAppId, string identifierType) => s_agentUserFicFlowDetected(logger, agentAppId, identifierType, null); diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 57467a134..6edc1e6b9 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -63,9 +63,11 @@ class OAuthConstants /// /// Maximum number of agent CCA instances to keep in the dictionary before - /// clearing it as a DOS protection measure. Since tokens are stored in MSAL's - /// shared static cache (not per-CCA), clearing the dictionary only discards - /// lightweight CCA objects — tokens remain accessible to newly-built CCAs. + /// clearing it as a DOS protection measure. When + /// is enabled (the default), tokens are stored in MSAL's shared static cache, so + /// clearing the dictionary only discards lightweight CCA objects — tokens remain + /// accessible to newly-built CCAs. When shared cache is disabled, clearing the + /// dictionary also discards the per-instance in-memory token caches. /// internal int AgentCcaMaxCount { get; set; } = 10000; @@ -92,7 +94,8 @@ class OAuthConstants /// request (bot/service pattern), so there is no persistent object to write back to. /// This dictionary fills that role, keyed by "{agentAppId}:{USER_IDENTIFIER}:{TENANTID}" /// where USER_IDENTIFIER is either the normalized UPN or OID. - /// Entries are cleaned up when MSAL evicts the corresponding account from its cache. + /// Entries are cleaned up opportunistically (when GetAccountAsync returns null during + /// a silent attempt) or when the CCA dictionary is cleared due to size-threshold eviction. /// internal readonly ConcurrentDictionary _agentUserFicAccountIds = new(); @@ -600,12 +603,14 @@ private async Task GetAuthenticationResultForUserInternalA return null; } - string? agentAppId = agentObj as string; + string? agentAppId = agentObj as string ?? agentObj?.ToString(); if (string.IsNullOrEmpty(agentAppId)) { return null; } + agentAppId = agentAppId.ToUpperInvariant(); + // Determine user identifier: UPN takes precedence over OID (matching WithAgentUserIdentity behavior). string? username = null; Guid? userObjectId = null; @@ -618,7 +623,8 @@ private async Task GetAuthenticationResultForUserInternalA userIdentifierForCacheKey = upn.ToUpperInvariant(); } else if (extraParameters.TryGetValue(Constants.UserIdKey, out object? userIdObj) - && userIdObj is string oidStr && Guid.TryParse(oidStr, out Guid parsedOid)) + && (userIdObj is string oidStr || (oidStr = userIdObj?.ToString()!) is not null) + && Guid.TryParse(oidStr, out Guid parsedOid)) { userObjectId = parsedOid; userIdentifierForCacheKey = parsedOid.ToString("D").ToUpperInvariant(); @@ -735,8 +741,8 @@ private async Task GetAuthenticationResultForUserInternalA /// Gets or builds an agent CCA for the native User FIC flow. Each agent CCA uses an /// assertion callback that chains back to the blueprint CCA for Leg 1 (FMI token). /// Each agent CCA has a unique ClientId (the agent app ID), providing natural cache - /// key isolation in the shared static cache. Entries are tracked with last-access - /// timestamps for idle-based eviction. + /// key isolation in the shared static cache. When the dictionary exceeds + /// , it is cleared entirely as DOS protection. /// private async Task GetOrBuildAgentUserFicCcaAsync( string agentAppId, @@ -822,6 +828,7 @@ private async Task GetOrBuildAgentUserFicCcaAsyn int cleared = _agentUserFicCcas.Count; _agentUserFicCcas.Clear(); _agentUserFicAccountIds.Clear(); + _agentCcaSemaphores.Clear(); Logger.AgentCcaEviction(_logger, cleared, 0); } From ae8a04a22f8068a222db2a4526f1f2f06755f0d9 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 30 Jun 2026 09:26:30 -0700 Subject: [PATCH 16/20] Fix CS8602 on older TFMs and drop unnecessary agentAppId normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert agentAppId.ToUpperInvariant() — existing ID Web patterns (GetApplicationKey, _applicationsByAuthorityClientId) do not normalize client IDs, so adding case-normalization only in the agentic flow would be inconsistent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 6edc1e6b9..2e2c1f258 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -609,8 +609,6 @@ private async Task GetAuthenticationResultForUserInternalA return null; } - agentAppId = agentAppId.ToUpperInvariant(); - // Determine user identifier: UPN takes precedence over OID (matching WithAgentUserIdentity behavior). string? username = null; Guid? userObjectId = null; From 956f849e4d0ebe1eaf73bcc6d36151b61dccbcfe Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 7 Jul 2026 08:27:18 -0700 Subject: [PATCH 17/20] Address review feedback: simplify OID detection, use MSAL TenantId, remove ExtractTenant, drop WithExperimentalFeatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simplify OID detection to Guid.TryParse(userIdObj?.ToString(), ...) instead of complex || assignment with null-forgiving operator - Use AssertionRequestOptions.TenantId directly with WithTenantId() for Leg 1 tenant propagation, replacing custom ExtractTenantFromTokenEndpointIfSameInstance - Remove ExtractTenantFromTokenEndpointIfSameInstance method and its 4 tests (OidcIdpSignedAssertionProvider's copy in OidcFIC project is unaffected) - Remove WithExperimentalFeatures() from agent CCA builder — none of the APIs used (WithClientAssertion, WithFmiPath, AcquireTokenByUserFederatedIdentityCredential) require it Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TokenAcquisition.cs | 68 ++----------------- .../TokenAcquisitionTests.cs | 48 ------------- 2 files changed, 6 insertions(+), 110 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 2e2c1f258..8c3579a4a 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -621,8 +621,7 @@ private async Task GetAuthenticationResultForUserInternalA userIdentifierForCacheKey = upn.ToUpperInvariant(); } else if (extraParameters.TryGetValue(Constants.UserIdKey, out object? userIdObj) - && (userIdObj is string oidStr || (oidStr = userIdObj?.ToString()!) is not null) - && Guid.TryParse(oidStr, out Guid parsedOid)) + && Guid.TryParse(userIdObj?.ToString(), out Guid parsedOid)) { userObjectId = parsedOid; userIdentifierForCacheKey = parsedOid.ToString("D").ToUpperInvariant(); @@ -788,15 +787,11 @@ private async Task GetOrBuildAgentUserFicCcaAsyn .WithSendX5C(blueprintOptions.SendX5C); // Propagate tenant override to Leg 1 when the caller specifies a tenant - // (e.g., via WithTenantId on Leg 2/3). Extract tenant from the token - // endpoint provided by MSAL, matching the pattern used by - // OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance. - string? leg1Tenant = ExtractTenantFromTokenEndpointIfSameInstance( - options.TokenEndpoint, - blueprintOptions.Instance); - if (!string.IsNullOrEmpty(leg1Tenant)) + // (e.g., via WithTenantId on Leg 2/3). MSAL's AssertionRequestOptions + // provides the resolved TenantId directly from the runtime authority. + if (!string.IsNullOrEmpty(options.TenantId)) { - leg1Builder.WithTenantId(leg1Tenant); + leg1Builder.WithTenantId(options.TenantId); } var leg1 = await leg1Builder @@ -806,8 +801,7 @@ private async Task GetOrBuildAgentUserFicCcaAsyn return leg1.AccessToken; }) .WithAuthority(authority) - .WithHttpClientFactory(_httpClientFactory) - .WithExperimentalFeatures(); + .WithHttpClientFactory(_httpClientFactory); if (UseSharedCacheForAgentCcas) { @@ -838,56 +832,6 @@ private async Task GetOrBuildAgentUserFicCcaAsyn } } - /// - /// Extracts the tenant ID from an OAuth2 token endpoint URL when the endpoint belongs - /// to the same cloud instance as the configured authority. Returns null if the hosts - /// don't match (cross-cloud) or the URL format is unrecognized. - /// Token endpoint format: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token - /// - /// - /// This is the same logic as OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance, - /// duplicated here because that method is internal to the OidcFIC project. - /// - internal static string? ExtractTenantFromTokenEndpointIfSameInstance( - string? tokenEndpoint, string? configuredInstance) - { - if (string.IsNullOrEmpty(tokenEndpoint) || string.IsNullOrEmpty(configuredInstance)) - { - return null; - } - - try - { - var endpointUri = new Uri(tokenEndpoint!); - var instanceUri = new Uri(configuredInstance!.TrimEnd('/')); - - if (!string.Equals(endpointUri.Host, instanceUri.Host, StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - var pathSegments = endpointUri.AbsolutePath.Split( - new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); - - if (pathSegments.Length >= 2) - { - for (int i = 1; i < pathSegments.Length; i++) - { - if (string.Equals(pathSegments[i], "oauth2", StringComparison.OrdinalIgnoreCase)) - { - return pathSegments[0]; - } - } - } - } - catch (UriFormatException) - { - // Invalid URI — fall through to return null. - } - - return null; - } - private void LogAuthResult(AuthenticationResult? authenticationResult) { if (authenticationResult != null) diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs index 261e44d93..b77065baa 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs @@ -1034,53 +1034,5 @@ await authProvider.CreateAuthorizationHeaderForUserAsync( } #endregion - - #region ExtractTenantFromTokenEndpointIfSameInstance Tests - - [Fact] - public void ExtractTenant_SameInstance_ReturnsTenant() - { - // Arrange - string tokenEndpoint = "https://login.microsoftonline.com/my-tenant-id/oauth2/v2.0/token"; - string instance = "https://login.microsoftonline.com/"; - - // Act - string? tenant = TokenAcquisition.ExtractTenantFromTokenEndpointIfSameInstance(tokenEndpoint, instance); - - // Assert - Assert.Equal("my-tenant-id", tenant); - } - - [Fact] - public void ExtractTenant_DifferentInstance_ReturnsNull() - { - // Arrange — China cloud endpoint with public cloud instance - string tokenEndpoint = "https://login.chinacloudapi.cn/my-tenant/oauth2/v2.0/token"; - string instance = "https://login.microsoftonline.com/"; - - // Act - string? tenant = TokenAcquisition.ExtractTenantFromTokenEndpointIfSameInstance(tokenEndpoint, instance); - - // Assert - Assert.Null(tenant); - } - - [Theory] - [InlineData(null, "https://login.microsoftonline.com/")] - [InlineData("https://login.microsoftonline.com/tenant/oauth2/v2.0/token", null)] - [InlineData(null, null)] - [InlineData("", "https://login.microsoftonline.com/")] - public void ExtractTenant_NullOrEmptyInputs_ReturnsNull(string? tokenEndpoint, string? instance) - { - Assert.Null(TokenAcquisition.ExtractTenantFromTokenEndpointIfSameInstance(tokenEndpoint, instance)); - } - - [Fact] - public void ExtractTenant_InvalidUri_ReturnsNull() - { - Assert.Null(TokenAcquisition.ExtractTenantFromTokenEndpointIfSameInstance("not-a-uri", "also-not-a-uri")); - } - - #endregion } } From 4473dc40c6e8e80d22908c6550059894b869117c Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 7 Jul 2026 08:35:49 -0700 Subject: [PATCH 18/20] Tighten catch --- .../TokenAcquisition.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 8c3579a4a..a449f280d 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -670,11 +670,9 @@ private async Task GetAuthenticationResultForUserInternalA Logger.AgentUserFicSilentSuccess(_logger, agentAppId!, normalizedTenant); return silentResult; } - catch (MsalException ex) + catch (MsalUiRequiredException ex) { - // Catch all MSAL exceptions (not just MsalUiRequiredException) so that - // any silent failure (cache errors, client errors, etc.) falls back to - // the full 3-leg acquisition rather than propagating up as a hard failure. + // No cached token available — fall back to full 3-leg acquisition below. Logger.AgentUserFicSilentFailure(_logger, agentAppId!, normalizedTenant, ex.ErrorCode ?? ex.GetType().Name, ex); } } From 1c3956d4e61268859fdc7acae279489b8dd856ae Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 7 Jul 2026 08:50:16 -0700 Subject: [PATCH 19/20] Remove unneeded shared cache toggle --- .../TokenAcquisition.cs | 23 ++----- .../TokenAcquisitionTests.cs | 62 ------------------- 2 files changed, 5 insertions(+), 80 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index a449f280d..5950fb395 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -63,11 +63,9 @@ class OAuthConstants /// /// Maximum number of agent CCA instances to keep in the dictionary before - /// clearing it as a DOS protection measure. When - /// is enabled (the default), tokens are stored in MSAL's shared static cache, so - /// clearing the dictionary only discards lightweight CCA objects — tokens remain - /// accessible to newly-built CCAs. When shared cache is disabled, clearing the - /// dictionary also discards the per-instance in-memory token caches. + /// clearing it as a DOS protection measure. Tokens are stored in MSAL's + /// shared static cache, so clearing the dictionary only discards lightweight + /// CCA objects — tokens remain accessible to newly-built CCAs. /// internal int AgentCcaMaxCount { get; set; } = 10000; @@ -99,13 +97,6 @@ class OAuthConstants /// internal readonly ConcurrentDictionary _agentUserFicAccountIds = new(); - /// - /// When true, agent User FIC CCAs use MSAL's shared (process-level static) cache. - /// This allows tokens to survive CCA re-creation after eviction. - /// Defaults to true; set to false in tests that need per-instance cache isolation. - /// - internal bool UseSharedCacheForAgentCcas { get; set; } = true; - private static readonly string[] s_ficScopes = new[] { "api://AzureADTokenExchange/.default" }; private const string TokenBindingParameterName = "IsTokenBinding"; @@ -799,12 +790,8 @@ private async Task GetOrBuildAgentUserFicCcaAsyn return leg1.AccessToken; }) .WithAuthority(authority) - .WithHttpClientFactory(_httpClientFactory); - - if (UseSharedCacheForAgentCcas) - { - builder.WithCacheOptions(CacheOptions.EnableSharedCacheOptions); - } + .WithHttpClientFactory(_httpClientFactory) + .WithCacheOptions(CacheOptions.EnableSharedCacheOptions); var newApp = builder.Build(); diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs index b77065baa..71eb4b665 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs @@ -796,68 +796,6 @@ public async Task AgentSharedCache_MultiAgentMultiUser_ReturnsCorrectTokens() Assert.Equal("Bearer token-a2-u1", s_a2u1); } - /// - /// Verifies that after CCA instances are evicted from the dictionary and new ones - /// are created with the same agent app IDs, the new CCAs must acquire fresh - /// tokens when shared cache is disabled (per-instance caches die with the CCA). - /// - [Fact] - public async Task AgentSharedCache_NewCcaAfterEviction_AcquiresFreshTokens() - { - // Arrange - string agent1 = Guid.NewGuid().ToString("N"); - string user1Uid = Guid.NewGuid().ToString("N"); - string user1Upn = "user1@contoso.com"; - - var factory = InitTokenAcquirerFactoryForAgent(); - IServiceProvider serviceProvider = factory.Build(); - var mockHttp = serviceProvider.GetRequiredService() as MockHttpClientFactory; - IAuthorizationHeaderProvider authProvider = - serviceProvider.GetRequiredService(); - - // Acquire the internal TokenAcquisition to manipulate dictionaries - var tokenAcquisition = serviceProvider.GetRequiredService() as TokenAcquisition; - - // Disable shared cache to test per-instance behavior (tokens lost on eviction) - tokenAcquisition!.UseSharedCacheForAgentCcas = false; - - // First acquisition: full 3-leg flow - AddAgentUserFicMockHandlersForUser(mockHttp!, "original-token", user1Uid, user1Upn); - - string result1 = await authProvider.CreateAuthorizationHeaderForUserAsync( - new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user1Upn), - claimsPrincipal: null); - - Assert.Equal("Bearer original-token", result1); - - // Verify silent works (no handlers needed) - string silent1 = await authProvider.CreateAuthorizationHeaderForUserAsync( - new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user1Upn), - claimsPrincipal: null); - Assert.Equal("Bearer original-token", silent1); - - // Evict the agent CCA from the dictionary (simulating sweep) - tokenAcquisition!._agentUserFicCcas.Clear(); - tokenAcquisition._agentUserFicAccountIds.Clear(); - - // After eviction, a new CCA must be built. Since we're using per-instance caches - // (no EnableSharedCacheOptions), the old tokens are gone. - // Need full 3-leg flow again with Leg 1 cached in blueprint. - mockHttp!.AddMockHandler(CreateClientCredentialsTokenHandler(accessToken: "t2-fresh")); - mockHttp.AddMockHandler(CreateUserFicTokenHandlerForUser("fresh-token-after-evict", user1Uid, user1Upn)); - - string result2 = await authProvider.CreateAuthorizationHeaderForUserAsync( - new[] { "https://graph.microsoft.com/.default" }, - authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user1Upn), - claimsPrincipal: null); - - // Assert — got a fresh token (old cache was lost with the CCA) - Assert.Equal("Bearer fresh-token-after-evict", result2); - Assert.NotEqual("Bearer original-token", result2); - } - /// /// Verifies that when EnableSharedCacheOptions is enabled on agent CCAs, /// tokens survive CCA eviction and new CCAs can retrieve them via silent calls. From bcbfea83f45a034ffe2722a494e1796211a76e1a Mon Sep 17 00:00:00 2001 From: Avery-Dunn <62066438+Avery-Dunn@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:01:06 -0700 Subject: [PATCH 20/20] Refactor CCA instance management in agentic flows (#3930) * Refactor agent CCA instance management * PR feedback * PR feedback --- .../TokenAcquisition.Logger.cs | 10 +- .../TokenAcquisition.cs | 338 ++++++++++-------- .../TokenAcquisitionTests.cs | 121 ++++++- 3 files changed, 314 insertions(+), 155 deletions(-) diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.Logger.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.Logger.cs index 37fa4c3c1..d45c4ce57 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.Logger.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.Logger.cs @@ -114,11 +114,11 @@ public static void TokenAcquisitionMsalAuthenticationResultTime( LoggingEventId.AgentCcaCreated, "[MsIdWeb] Created new agent CCA for cache key '{CcaCacheKey}'."); - private static readonly Action s_agentCcaEviction = - LoggerMessage.Define( + private static readonly Action s_agentCcaEviction = + LoggerMessage.Define( LogLevel.Information, LoggingEventId.AgentCcaEviction, - "[MsIdWeb] Agent CCA cache cleared {EvictedCount} entries (exceeded size threshold). Remaining: {RemainingCount}."); + "[MsIdWeb] CCA dictionary cleared ({EvictedCount} entries exceeded size threshold)."); public static void AgentUserFicFlowDetected(ILogger logger, string agentAppId, string identifierType) => s_agentUserFicFlowDetected(logger, agentAppId, identifierType, null); @@ -135,8 +135,8 @@ public static void AgentUserFicAcquisitionComplete(ILogger logger, string agentA public static void AgentCcaCreated(ILogger logger, string ccaCacheKey) => s_agentCcaCreated(logger, ccaCacheKey, null); - public static void AgentCcaEviction(ILogger logger, int evictedCount, int remainingCount) - => s_agentCcaEviction(logger, evictedCount, remainingCount, null); + public static void AgentCcaEviction(ILogger logger, int evictedCount) + => s_agentCcaEviction(logger, evictedCount, null); } } } diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 5950fb395..e095c99bc 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System; @@ -11,6 +11,7 @@ using System.Net.Http; using System.Security.Claims; using System.Security.Cryptography.X509Certificates; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -58,30 +59,20 @@ class OAuthConstants /// Important: call GetOrBuildConfidentialClientApplication instead of accessing _applicationsByAuthorityClientId directly. /// Write access to this dictionary is synchronized. /// - private readonly ConcurrentDictionary _applicationsByAuthorityClientId = new(); + internal readonly ConcurrentDictionary _applicationsByAuthorityClientId = new(); private readonly ConcurrentDictionary _appSemaphores = new(); /// - /// Maximum number of agent CCA instances to keep in the dictionary before - /// clearing it as a DOS protection measure. Tokens are stored in MSAL's - /// shared static cache, so clearing the dictionary only discards lightweight - /// CCA objects — tokens remain accessible to newly-built CCAs. + /// Maximum number of CCA instances to keep in the shared application dictionary + /// before clearing it as a DOS protection measure. Token data lives in external + /// caches (MSAL's shared static cache for in-memory providers, or the distributed + /// cache provider for Redis/SQL/etc.), so clearing the dictionary only discards + /// lightweight CCA objects — tokens remain accessible to newly-built CCAs. + /// Eviction is only triggered by agent CCA creation, since normal CCAs are bounded + /// by the number of configured authentication schemes. /// internal int AgentCcaMaxCount { get; set; } = 10000; - /// - /// Caches agent CCAs for the native User FIC flow. Each agent CCA uses an assertion - /// callback that chains to the blueprint CCA for Leg 1 (FMI token acquisition). - /// Key format: "{agentAppId}:{authenticationScheme}". - /// The authenticationScheme is included because different schemes may resolve to - /// different blueprint credentials (e.g. certificates), and the agent CCA's assertion - /// callback captures the scheme to chain to the correct blueprint CCA. - /// When the dictionary exceeds , it is cleared entirely; - /// tokens survive in MSAL's shared static cache and are found by new CCAs via silent calls. - /// - internal readonly ConcurrentDictionary _agentUserFicCcas = new(); - private readonly ConcurrentDictionary _agentCcaSemaphores = new(); - /// /// Maps (agentAppId, user identifier, tenantId) tuples to MSAL account identifiers for the /// native User FIC flow. This is needed because AcquireTokenSilent requires an IAccount, @@ -276,13 +267,27 @@ private async Task AddAccountToCacheFromAuthorizationCodeInt /// Merged configuration options. /// Whether mTLS token binding (PoP) is requested. /// Callers must pass this explicitly to avoid accidental cache collisions. - /// Concatenated string of authority, client id, azure region, credential id, and token-binding flag. - private static string GetApplicationKey(MergedOptions mergedOptions, bool isTokenBinding) + /// When non-null, appends an agent-specific segment so each + /// agent CCA gets its own entry in the shared dictionary. + /// Concatenated string of authority, client id, azure region, credential id, + /// token-binding flag, and optional agent app id. + private static string GetApplicationKey(MergedOptions mergedOptions, bool isTokenBinding, string? agentAppId = null) { string credentialId = string.Join("-", mergedOptions.ClientCredentials?.Select(c => c.Id) ?? Enumerable.Empty()); - string baseKey = DefaultTokenAcquirerFactoryImplementation.GetKey(mergedOptions.Authority, mergedOptions.ClientId, mergedOptions.AzureRegion) + credentialId; - return isTokenBinding ? baseKey + "-tokenBinding" : baseKey; + var keyBuilder = new StringBuilder( + DefaultTokenAcquirerFactoryImplementation.GetKey(mergedOptions.Authority, mergedOptions.ClientId, mergedOptions.AzureRegion)); + keyBuilder.Append(credentialId); + if (isTokenBinding) + { + keyBuilder.Append("-tokenBinding"); + } + if (agentAppId is not null) + { + keyBuilder.Append(":agent:"); + keyBuilder.Append(agentAppId); + } + return keyBuilder.ToString(); } /// @@ -341,14 +346,26 @@ private async Task GetAuthenticationResultForUserInternalA MergedOptions mergedOptions = GetMergedOptions(authenticationScheme, tokenAcquisitionOptions); user ??= await _tokenAcquisitionHost.GetAuthenticatedUserAsync(user).ConfigureAwait(false); - var application = await GetOrBuildConfidentialClientApplicationAsync(mergedOptions, isTokenBinding: false); - if (tokenAcquisitionOptions is not null) { tokenAcquisitionOptions.ExtraParameters ??= new Dictionary(); tokenAcquisitionOptions.ExtraParameters[Constants.ExtensionOptionsServiceProviderKey] = _serviceProvider; } + // Detect agentic User FIC flow early — before building the blueprint CCA. + // Agent CCAs are built via the unified builder path with their own ClientId, + // so the blueprint CCA is only built lazily (inside the assertion callback) + // when actually needed for Leg 1 token acquisition. + var agentResult = await TryGetAuthenticationResultForAgentUserFicAsync( + tenantId, scopes, mergedOptions, tokenAcquisitionOptions).ConfigureAwait(false); + if (agentResult is not null) + { + LogAuthResult(agentResult); + return agentResult; + } + + var application = await GetOrBuildConfidentialClientApplicationAsync(mergedOptions, isTokenBinding: false); + CredentialSourceLoaderParameters loaderParameters = new CredentialSourceLoaderParameters(application.AppConfig.ClientId, application.Authority) { Protocol = ProtocolNames.Bearer, @@ -442,15 +459,6 @@ private async Task GetAuthenticationResultForUserInternalA MergedOptions mergedOptions, TokenAcquisitionOptions? tokenAcquisitionOptions) { - // Handle agentic User FIC flow (both UPN and OID) using MSAL's native UserFIC API. - // This bypasses the ROPC piggybacking approach and provides proper cache behavior. - var agentUserFicResult = await TryGetAuthenticationResultForAgentUserFicAsync( - application, tenantId, scopes, mergedOptions, tokenAcquisitionOptions).ConfigureAwait(false); - if (agentUserFicResult is not null) - { - return agentUserFicResult; - } - string? username = null; string? password = null; @@ -579,7 +587,6 @@ private async Task GetAuthenticationResultForUserInternalA /// An if this is an agentic User FIC flow /// (UPN or OID); null if not an agentic flow (regular ROPC). private async Task TryGetAuthenticationResultForAgentUserFicAsync( - IConfidentialClientApplication application, string? tenantId, IEnumerable scopes, MergedOptions mergedOptions, @@ -628,7 +635,7 @@ private async Task GetAuthenticationResultForUserInternalA Logger.AgentUserFicFlowDetected(_logger, agentAppId!, identifierType); var agentCca = await GetOrBuildAgentUserFicCcaAsync( - agentAppId!, application.Authority, authScheme).ConfigureAwait(false); + agentAppId!, authScheme, mergedOptions).ConfigureAwait(false); bool forceRefresh = tokenAcquisitionOptions?.ForceRefresh ?? false; @@ -724,97 +731,69 @@ private async Task GetAuthenticationResultForUserInternalA } /// - /// Gets or builds an agent CCA for the native User FIC flow. Each agent CCA uses an - /// assertion callback that chains back to the blueprint CCA for Leg 1 (FMI token). - /// Each agent CCA has a unique ClientId (the agent app ID), providing natural cache - /// key isolation in the shared static cache. When the dictionary exceeds - /// , it is cleared entirely as DOS protection. + /// Gets or builds an agent CCA for the native User FIC flow. Delegates to the unified + /// / + /// builder path so that agent CCAs + /// receive the same configuration as normal CCAs (logging, authority, cache initialization). + /// Each agent CCA has a unique ClientId (the agent app ID), providing natural cache key + /// isolation in both the CCA dictionary and MSAL's shared static token cache. /// private async Task GetOrBuildAgentUserFicCcaAsync( string agentAppId, - string authority, - string? authenticationScheme) + string? authenticationScheme, + MergedOptions mergedOptions) { - // Include authenticationScheme in the CCA cache key so different schemes - // (pointing to different blueprint credentials) get separate CCAs. - // Authority is intentionally excluded: within a single TokenAcquisition instance, - // the authority is derived from the one configured MergedOptions and does not vary - // per call. Multi-authority scenarios would require separate TokenAcquisition instances. - string ccaCacheKey = $"{agentAppId}:{authenticationScheme ?? string.Empty}"; - - if (_agentUserFicCcas.TryGetValue(ccaCacheKey, out var existing)) - { - return existing; - } - - var semaphore = _agentCcaSemaphores.GetOrAdd(ccaCacheKey, _ => new SemaphoreSlim(1, 1)); - await semaphore.WaitAsync().ConfigureAwait(false); - try - { - if (_agentUserFicCcas.TryGetValue(ccaCacheKey, out var entry)) + // Fast path: if the agent CCA is already cached, return it without + // allocating a closure for the assertion callback. The callback captures + // authenticationScheme and agentAppId, producing a heap-allocated closure + // object + delegate on every call — wasteful when the CCA already exists. + string key = GetApplicationKey(mergedOptions, isTokenBinding: false, agentAppId); + if (_applicationsByAuthorityClientId.TryGetValue(key, out var cached) && cached != null) + { + return cached; + } + + // Cache miss — build the assertion callback that chains to the blueprint CCA for Leg 1. + // Capture authenticationScheme so the callback resolves the correct blueprint. + string? capturedAuthScheme = authenticationScheme; + + Func> assertionCallback = async (AssertionRequestOptions options) => + { + // Leg 1: Blueprint acquires FMI token (T1) for this agent. + // AcquireTokenForClient checks cache first — only the first call + // or an expired T1 hits the network. + MergedOptions blueprintOptions = _tokenAcquisitionHost.GetOptions(capturedAuthScheme, out _); + var blueprintCca = await GetOrBuildConfidentialClientApplicationAsync( + blueprintOptions, isTokenBinding: false).ConfigureAwait(false); + + var leg1Builder = blueprintCca + .AcquireTokenForClient(s_ficScopes) + .WithFmiPath(agentAppId) + .WithSendX5C(blueprintOptions.SendX5C); + + // Propagate tenant override to Leg 1 when the caller specifies a tenant + // (e.g., via WithTenantId on Leg 2/3). MSAL's AssertionRequestOptions + // provides the resolved TenantId directly from the runtime authority. + if (!string.IsNullOrEmpty(options.TenantId)) { - return entry; + leg1Builder.WithTenantId(options.TenantId); } - // Capture authenticationScheme for the assertion callback closure. - string? capturedAuthScheme = authenticationScheme; + var leg1 = await leg1Builder + .ExecuteAsync(options.CancellationToken) + .ConfigureAwait(false); - var builder = ConfidentialClientApplicationBuilder - .Create(agentAppId) - .WithClientAssertion(async (AssertionRequestOptions options) => - { - // Leg 1: Blueprint acquires FMI token (T1) for this agent. - // AcquireTokenForClient checks cache first — only the first call - // or an expired T1 hits the network. - MergedOptions blueprintOptions = _tokenAcquisitionHost.GetOptions(capturedAuthScheme, out _); - var blueprintCca = await GetOrBuildConfidentialClientApplicationAsync( - blueprintOptions, isTokenBinding: false).ConfigureAwait(false); - - var leg1Builder = blueprintCca - .AcquireTokenForClient(s_ficScopes) - .WithFmiPath(agentAppId) - .WithSendX5C(blueprintOptions.SendX5C); - - // Propagate tenant override to Leg 1 when the caller specifies a tenant - // (e.g., via WithTenantId on Leg 2/3). MSAL's AssertionRequestOptions - // provides the resolved TenantId directly from the runtime authority. - if (!string.IsNullOrEmpty(options.TenantId)) - { - leg1Builder.WithTenantId(options.TenantId); - } - - var leg1 = await leg1Builder - .ExecuteAsync(options.CancellationToken) - .ConfigureAwait(false); - - return leg1.AccessToken; - }) - .WithAuthority(authority) - .WithHttpClientFactory(_httpClientFactory) - .WithCacheOptions(CacheOptions.EnableSharedCacheOptions); - - var newApp = builder.Build(); - - _agentUserFicCcas[ccaCacheKey] = newApp; - Logger.AgentCcaCreated(_logger, ccaCacheKey); + return leg1.AccessToken; + }; - // DOS protection: if the dictionary grows beyond the threshold, clear it. - // Tokens survive in MSAL's shared static cache and will be found by new CCAs. - if (_agentUserFicCcas.Count > AgentCcaMaxCount) - { - int cleared = _agentUserFicCcas.Count; - _agentUserFicCcas.Clear(); - _agentUserFicAccountIds.Clear(); - _agentCcaSemaphores.Clear(); - Logger.AgentCcaEviction(_logger, cleared, 0); - } + // Delegate to the unified builder path. The agent app ID is incorporated into + // the cache key automatically, and the CCA gets all the same configuration as + // normal CCAs (logging, authority, cache initialization, etc.). + var agentCca = await GetOrBuildConfidentialClientApplicationAsync( + mergedOptions, isTokenBinding: false, agentAppId, assertionCallback).ConfigureAwait(false); - return newApp; - } - finally - { - semaphore.Release(); - } + Logger.AgentCcaCreated(_logger, agentAppId); + return agentCca; } private void LogAuthResult(AuthenticationResult? authenticationResult) @@ -1350,9 +1329,11 @@ public async Task GetConfidentialClientApplicati internal /* for testing */ async Task GetOrBuildConfidentialClientApplicationAsync( MergedOptions mergedOptions, - bool isTokenBinding) + bool isTokenBinding, + string? agentAppId = null, + Func>? agenticAssertionProvider = null) { - string key = GetApplicationKey(mergedOptions, isTokenBinding); + string key = GetApplicationKey(mergedOptions, isTokenBinding, agentAppId); // GetOrAddAsync based on https://github.com/dotnet/runtime/issues/83636#issuecomment-1474998680 // Fast path: check if already created @@ -1370,11 +1351,27 @@ public async Task GetConfidentialClientApplicati return app; // Build and store the application - var newApp = await BuildConfidentialClientApplicationAsync(mergedOptions, isTokenBinding); + var newApp = await BuildConfidentialClientApplicationAsync( + mergedOptions, isTokenBinding, agentAppId, agenticAssertionProvider); // Recompute the key as BuildConfidentialClientApplicationAsync can cause it to change. - key = GetApplicationKey(mergedOptions, isTokenBinding); + key = GetApplicationKey(mergedOptions, isTokenBinding, agentAppId); _applicationsByAuthorityClientId[key] = newApp; + + // DOS protection: if the dictionary grows beyond the threshold, clear it. + // All token data lives in external caches (MSAL's shared static cache for + // in-memory providers, or the distributed cache provider for Redis/SQL/etc.), + // so clearing the dictionary only discards lightweight CCA objects — tokens + // remain accessible to newly-built CCAs. + if (agentAppId is not null && _applicationsByAuthorityClientId.Count > AgentCcaMaxCount) + { + int cleared = _applicationsByAuthorityClientId.Count; + _applicationsByAuthorityClientId.Clear(); + _appSemaphores.Clear(); + _agentUserFicAccountIds.Clear(); + Logger.AgentCcaEviction(_logger, cleared); + } + return newApp; } finally @@ -1386,10 +1383,32 @@ public async Task GetConfidentialClientApplicati /// /// Creates an MSAL confidential client application. /// + /// Merged configuration options. + /// Whether mTLS token binding (PoP) is requested. + /// When non-null, builds an agent CCA with this app ID as + /// the ClientId and uses for credentials + /// instead of the normal client credentials. The rest of the builder configuration + /// (logging, authority, redirect URI, cache initialization) is shared with the normal + /// CCA builder path. + /// Assertion callback for agent CCAs. Required + /// when is non-null. private async Task BuildConfidentialClientApplicationAsync( MergedOptions mergedOptions, - bool isTokenBinding) + bool isTokenBinding, + string? agentAppId = null, + Func>? agenticAssertionProvider = null) { + // agentAppId and agenticAssertionProvider must both be null or both be non-null. + // Agent CCAs require an assertion callback for Leg 1 (FMI token), and the callback + // is only meaningful in the context of an agent CCA. + if ((agentAppId is null) != (agenticAssertionProvider is null)) + { + throw new ArgumentException( + "agentAppId and agenticAssertionProvider must both be provided or both be null."); + } + + bool isAgentCca = agentAppId is not null; + mergedOptions.PrepareAuthorityInstanceForMsal(); // Validate that we have enough configuration to build an authority @@ -1404,12 +1423,27 @@ private async Task BuildConfidentialClientApplic try { + // For agent CCAs, create a fresh ConfidentialClientApplicationOptions with + // the agent's ClientId. This avoids mutating the cached MergedOptions instance + // that the blueprint CCA depends on. + ConfidentialClientApplicationOptions ccaOptions; + if (isAgentCca) + { + ccaOptions = new ConfidentialClientApplicationOptions(); + MergedOptions.UpdateConfidentialClientApplicationOptionsFromMergedOptions(mergedOptions, ccaOptions); + ccaOptions.ClientId = agentAppId; + } + else + { + ccaOptions = mergedOptions.ConfidentialClientApplicationOptions; + } + ConfidentialClientApplicationBuilder builder = ConfidentialClientApplicationBuilder - .CreateWithApplicationOptions(mergedOptions.ConfidentialClientApplicationOptions) + .CreateWithApplicationOptions(ccaOptions) .WithHttpClientFactory(_httpClientFactory) .WithLogging( new IdentityLoggerAdapter(_logger), - enablePiiLogging: mergedOptions.ConfidentialClientApplicationOptions.EnablePiiLogging) + enablePiiLogging: ccaOptions.EnablePiiLogging) .WithExperimentalFeatures(); if (_tokenCacheProvider is MsalMemoryTokenCacheProvider) @@ -1417,10 +1451,17 @@ private async Task BuildConfidentialClientApplic builder.WithCacheOptions(CacheOptions.EnableSharedCacheOptions); } + // Agent CCAs always use the shared cache: tokens survive CCA eviction and + // are found by newly-built CCAs via AcquireTokenSilent. + if (isAgentCca) + { + builder.WithCacheOptions(CacheOptions.EnableSharedCacheOptions); + } + string? currentUri = _tokenAcquisitionHost.GetCurrentRedirectUri(mergedOptions); - // The redirect URI is not needed for OBO - if (!string.IsNullOrEmpty(currentUri)) + // The redirect URI is not needed for OBO or agent flows + if (!string.IsNullOrEmpty(currentUri) && !isAgentCca) { builder.WithRedirectUri(currentUri); } @@ -1482,29 +1523,42 @@ private async Task BuildConfidentialClientApplic builder.WithAuthority(authority); } - try + // Configure credentials: agent CCAs use an assertion callback that chains + // to the blueprint CCA for Leg 1 (FMI token), while normal CCAs use the + // standard client credentials (certificate, secret, etc.). + if (isAgentCca && agenticAssertionProvider is not null) { - await builder.WithClientCredentialsAsync( - mergedOptions, - _credentialsProvider, - new CredentialSourceLoaderParameters(mergedOptions.ClientId!, authority) - { - Protocol = isTokenBinding ? ProtocolNames.MtlsPop : ProtocolNames.Bearer, - }, - isTokenBinding); + builder.WithClientAssertion(agenticAssertionProvider); } - catch (ArgumentException ex) when (ex.Message == IDWebErrorMessage.ClientCertificatesHaveExpiredOrCannotBeLoaded) + else { - Logger.TokenAcquisitionError( - _logger, - IDWebErrorMessage.ClientCertificatesHaveExpiredOrCannotBeLoaded, - ex); - throw; + try + { + await builder.WithClientCredentialsAsync( + mergedOptions, + _credentialsProvider, + new CredentialSourceLoaderParameters(mergedOptions.ClientId!, authority) + { + Protocol = isTokenBinding ? ProtocolNames.MtlsPop : ProtocolNames.Bearer, + }, + isTokenBinding); + } + catch (ArgumentException ex) when (ex.Message == IDWebErrorMessage.ClientCertificatesHaveExpiredOrCannotBeLoaded) + { + Logger.TokenAcquisitionError( + _logger, + IDWebErrorMessage.ClientCertificatesHaveExpiredOrCannotBeLoaded, + ex); + throw; + } } IConfidentialClientApplication app = builder.Build(); - // Initialize token cache providers + // Initialize token cache providers. + // For in-memory caches, the shared cache options above handle caching. + // For distributed caches (Redis, SQL, etc.), the provider must be + // initialized on both app and user token caches. if (!(_tokenCacheProvider is MsalMemoryTokenCacheProvider)) { _tokenCacheProvider.Initialize(app.AppTokenCache); diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs index 71eb4b665..d16344366 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -845,9 +846,13 @@ await authProvider.CreateAuthorizationHeaderForUserAsync( authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent2, user2Upn), claimsPrincipal: null); - // Evict ALL agent CCAs (simulating sweep clearing everything). + // Evict ALL agent CCAs from the shared dictionary. // Keep _agentUserFicAccountIds intact — silent lookup needs them to find the account. - tokenAcquisition._agentUserFicCcas.Clear(); + foreach (var key in tokenAcquisition._applicationsByAuthorityClientId.Keys + .Where(k => k.IndexOf(":agent:", StringComparison.Ordinal) >= 0).ToList()) + { + tokenAcquisition._applicationsByAuthorityClientId.TryRemove(key, out _); + } // Act — new CCAs must be built, but tokens should come from shared static cache. // Each new CCA needs a Leg 1 handler (assertion callback fires on first use). @@ -933,8 +938,12 @@ public async Task AgentCcaEviction_ClearsDictionaryAtThreshold() serviceProvider.GetRequiredService(); var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); - // Set threshold to 2 so the 3rd agent triggers a clear - tokenAcquisition.AgentCcaMaxCount = 2; + // Set threshold to 4 so the 3rd agent triggers a clear: + // 1 blueprint + 2 agents = 3 entries (≤ 4), 1 blueprint + 3 agents = 4 entries (≤ 4) + // but after adding the 3rd agent the count becomes 4 which equals the threshold. + // We use 3 so that: blueprint + 2 agents = 3 ≤ 3 (no eviction), + // blueprint + 3 agents = 4 > 3 (triggers eviction). + tokenAcquisition.AgentCcaMaxCount = 3; string agent1 = Guid.NewGuid().ToString("N"); string agent2 = Guid.NewGuid().ToString("N"); @@ -955,7 +964,7 @@ await authProvider.CreateAuthorizationHeaderForUserAsync( authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent2, user1Upn), claimsPrincipal: null); - Assert.Equal(2, tokenAcquisition._agentUserFicCcas.Count); + Assert.Equal(2, tokenAcquisition._applicationsByAuthorityClientId.Keys.Count(k => k.IndexOf(":agent:", StringComparison.Ordinal) >= 0)); Assert.Equal(2, tokenAcquisition._agentUserFicAccountIds.Count); // Add 3rd agent — exceeds threshold, triggers clear @@ -965,12 +974,108 @@ await authProvider.CreateAuthorizationHeaderForUserAsync( authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent3, user1Upn), claimsPrincipal: null); - // Assert — CCA dictionary was cleared; only the 3rd agent's account ID remains - // (written after the clear, during Leg 3 of the triggering call) - Assert.Empty(tokenAcquisition._agentUserFicCcas); + // Assert — dictionary was cleared by DOS eviction, then repopulated during + // the rest of the 3rd agent's flow (blueprint rebuilds for Leg 1). + // Previous agent CCAs are gone; only the rebuilt blueprint remains. + Assert.Equal(0, tokenAcquisition._applicationsByAuthorityClientId.Keys + .Count(k => k.IndexOf(":agent:", StringComparison.Ordinal) >= 0)); Assert.Single(tokenAcquisition._agentUserFicAccountIds); } + /// + /// Verifies that agent CCAs are stored in the shared _applicationsByAuthorityClientId + /// dictionary alongside normal CCAs, using the ":agent:" key segment for identification. + /// This ensures agent CCAs go through the same builder path and get identical configuration + /// (logging, authority handling, cache initialization) as normal CCAs. + /// + [Fact] + public async Task AgentCca_StoredInSharedDictionary_WithAgentKeySegment() + { + // Arrange + string agent1 = Guid.NewGuid().ToString("N"); + string user1Upn = "user1@contoso.com"; + + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + var mockHttp = serviceProvider.GetRequiredService() as MockHttpClientFactory; + IAuthorizationHeaderProvider authProvider = + serviceProvider.GetRequiredService(); + var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); + + AddAgentUserFicMockHandlersForUser(mockHttp!, "token-a1u1", Guid.NewGuid().ToString("N"), user1Upn); + + // Act + await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user1Upn), + claimsPrincipal: null); + + // Assert — the shared dictionary has agent entries (identified by ":agent:" segment) + var agentKeys = tokenAcquisition._applicationsByAuthorityClientId.Keys + .Where(k => k.IndexOf(":agent:", StringComparison.Ordinal) >= 0) + .ToList(); + Assert.Single(agentKeys); + Assert.True(agentKeys[0].IndexOf(agent1, StringComparison.Ordinal) >= 0); + + // The blueprint CCA is also in the same dictionary (created lazily by assertion callback) + var blueprintKeys = tokenAcquisition._applicationsByAuthorityClientId.Keys + .Where(k => k.IndexOf(":agent:", StringComparison.Ordinal) < 0) + .ToList(); + Assert.Single(blueprintKeys); + } + + /// + /// Verifies that DOS eviction clears the entire CCA dictionary (including blueprint), + /// but tokens survive in MSAL's shared static cache. After eviction, new CCAs are + /// rebuilt lazily and can still retrieve cached tokens via AcquireTokenSilent. + /// + [Fact] + public async Task AgentCcaEviction_ClearsDictionary_TokensSurvive() + { + // Arrange + string agent1 = Guid.NewGuid().ToString("N"); + string agent2 = Guid.NewGuid().ToString("N"); + string agent3 = Guid.NewGuid().ToString("N"); + string user1Upn = "user1@contoso.com"; + string user1Uid = Guid.NewGuid().ToString("N"); + + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + var mockHttp = serviceProvider.GetRequiredService() as MockHttpClientFactory; + IAuthorizationHeaderProvider authProvider = + serviceProvider.GetRequiredService(); + var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); + tokenAcquisition.AgentCcaMaxCount = 3; + + // Populate 2 agents (at threshold) + AddAgentUserFicMockHandlersForUser(mockHttp!, "token-a1", user1Uid, user1Upn); + await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent1, user1Upn), + claimsPrincipal: null); + + AddAgentUserFicMockHandlersForUser(mockHttp!, "token-a2", user1Uid, user1Upn); + await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent2, user1Upn), + claimsPrincipal: null); + + // Verify dictionary has entries (2 agents + 1 blueprint = 3) + Assert.True(tokenAcquisition._applicationsByAuthorityClientId.Count >= 3); + + // Act — 3rd agent triggers eviction (clears entire dictionary) + AddAgentUserFicMockHandlersForUser(mockHttp!, "token-a3", user1Uid, user1Upn); + await authProvider.CreateAuthorizationHeaderForUserAsync( + new[] { "https://graph.microsoft.com/.default" }, + authorizationHeaderProviderOptions: CreateAgentIdentityOptionsWithUpn(agent3, user1Upn), + claimsPrincipal: null); + + // Assert — dictionary was cleared by eviction, then blueprint was rebuilt + // during the 3rd agent's Leg 1. Previous agent CCAs are gone. + Assert.Equal(0, tokenAcquisition._applicationsByAuthorityClientId.Keys + .Count(k => k.IndexOf(":agent:", StringComparison.Ordinal) >= 0)); + } + #endregion } }