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/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..d45c4ce57 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 agent '{AgentAppId}' in tenant '{TenantId}'."); + + private static readonly Action s_agentUserFicSilentFailure = + LoggerMessage.Define( + LogLevel.Information, + LoggingEventId.AgentUserFicSilentFailure, + "[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( + 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] CCA dictionary cleared ({EvictedCount} entries exceeded size threshold)."); + + public static void AgentUserFicFlowDetected(ILogger logger, string agentAppId, string identifierType) + => s_agentUserFicFlowDetected(logger, agentAppId, identifierType, null); + + public static void AgentUserFicSilentSuccess(ILogger logger, string agentAppId, string tenantId) + => s_agentUserFicSilentSuccess(logger, agentAppId, tenantId, null); + + 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); + + public static void AgentCcaCreated(ILogger logger, string ccaCacheKey) + => s_agentCcaCreated(logger, ccaCacheKey, 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 f474cff82..3be96c6ec 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; @@ -60,9 +61,37 @@ 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 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; + + /// + /// 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}:{USER_IDENTIFIER}:{TENANTID}" + /// where USER_IDENTIFIER is either the normalized UPN or OID. + /// 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(); + + private static readonly string[] s_ficScopes = new[] { "api://AzureADTokenExchange/.default" }; + private const string TokenBindingParameterName = "IsTokenBinding"; private const int MaxCertificateRetries = 1; protected readonly IMsalHttpClientFactory _httpClientFactory; @@ -240,13 +269,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(); } /// @@ -305,14 +348,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, @@ -407,7 +462,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)) @@ -416,22 +470,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; @@ -528,6 +566,237 @@ private async Task GetAuthenticationResultForUserInternalA return authenticationResult; } + /// + /// 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 + 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 + /// 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 an agentic User FIC flow + /// (UPN or OID); null if not an agentic flow (regular ROPC). + private async Task TryGetAuthenticationResultForAgentUserFicAsync( + string? tenantId, + IEnumerable scopes, + MergedOptions mergedOptions, + TokenAcquisitionOptions? tokenAcquisitionOptions) + { + var extraParameters = tokenAcquisitionOptions?.ExtraParameters; + + // Detect agentic flow: requires AgentIdentityKey plus either UsernameKey (UPN) or UserIdKey (OID). + if (extraParameters is null + || !extraParameters.TryGetValue(Constants.AgentIdentityKey, out object? agentObj)) + { + return null; + } + + string? agentAppId = agentObj as string ?? agentObj?.ToString(); + 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) + && Guid.TryParse(userIdObj?.ToString(), 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; + } + + string? authScheme = tokenAcquisitionOptions?.AuthenticationOptionsName; + string identifierType = username is not null ? "UPN" : "OID"; + Logger.AgentUserFicFlowDetected(_logger, agentAppId!, identifierType); + + var agentCca = await GetOrBuildAgentUserFicCcaAsync( + agentAppId!, authScheme, mergedOptions).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. + // 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 + && _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); + } + + var silentResult = await silentBuilder.ExecuteAsync().ConfigureAwait(false); + Logger.AgentUserFicSilentSuccess(_logger, agentAppId!, normalizedTenant); + return silentResult; + } + catch (MsalUiRequiredException ex) + { + // No cached token available — fall back to full 3-leg acquisition below. + Logger.AgentUserFicSilentFailure(_logger, agentAppId!, normalizedTenant, ex.ErrorCode ?? ex.GetType().Name, 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 + 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)) + { + leg3Builder.WithTenantId(tenantId); + } + + var result = await leg3Builder.ExecuteAsync().ConfigureAwait(false); + + Logger.AgentUserFicAcquisitionComplete(_logger, agentAppId!, result.AuthenticationResultMetadata.TokenSource.ToString()); + // Store the account identifier for subsequent silent lookups. + // 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; + } + + return result; + } + + /// + /// 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? authenticationScheme, + MergedOptions mergedOptions) + { + // 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)) + { + leg1Builder.WithTenantId(options.TenantId); + } + + var leg1 = await leg1Builder + .ExecuteAsync(options.CancellationToken) + .ConfigureAwait(false); + + return leg1.AccessToken; + }; + + // 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); + + Logger.AgentCcaCreated(_logger, agentAppId); + return agentCca; + } + private void LogAuthResult(AuthenticationResult? authenticationResult) { if (authenticationResult != null) @@ -1063,9 +1332,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 @@ -1083,11 +1354,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 @@ -1099,10 +1386,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 @@ -1117,12 +1426,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) @@ -1130,10 +1454,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); } @@ -1195,29 +1526,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 bcb699e54..3d3b7305f 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs @@ -1,8 +1,9 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Security.Claims; using System.Threading; @@ -458,5 +459,664 @@ 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 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() + { + // 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 CreateAgentIdentityOptionsWithUpn(agentAppId, AgentTestUsername); + } + + private static AuthorizationHeaderProviderOptions CreateAgentIdentityOptionsWithUpn(string agentAppId, string username) + { + return new AuthorizationHeaderProviderOptions + { + AcquireTokenOptions = new AcquireTokenOptions + { + ExtraParameters = new Dictionary + { + [Constants.AgentIdentityKey] = agentAppId, + [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"), + } + } + }; + } + + 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('/', '_'); + } + + // --- 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 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: 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 = + 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 + + #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 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(); + + // 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 from the shared dictionary. + // Keep _agentUserFicAccountIds intact — silent lookup needs them to find the account. + 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). + 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); + + 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 Size-Threshold Eviction Tests + + /// + /// 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 AgentCcaEviction_ClearsDictionaryAtThreshold() + { + // Arrange — set a very low threshold to trigger clearing + var factory = InitTokenAcquirerFactoryForAgent(); + IServiceProvider serviceProvider = factory.Build(); + var mockHttp = serviceProvider.GetRequiredService() as MockHttpClientFactory; + IAuthorizationHeaderProvider authProvider = + serviceProvider.GetRequiredService(); + var tokenAcquisition = (TokenAcquisition)serviceProvider.GetRequiredService(); + + // 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"); + string agent3 = Guid.NewGuid().ToString("N"); + string user1Uid = Guid.NewGuid().ToString("N"); + string user1Upn = "user1@contoso.com"; + + // Populate 2 agents (at threshold, not over) + 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); + + 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 + 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 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 } }