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/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 ddc96fd65..d3765206e 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -59,6 +59,43 @@ 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}". + /// 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(); + + /// + /// 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 when MSAL evicts the corresponding account from its cache. + /// + private 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 const string TokenBindingParameterName = "IsTokenBinding"; private const int MaxCertificateRetries = 1; protected readonly IMsalHttpClientFactory _httpClientFactory; @@ -403,9 +440,17 @@ 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; - 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)) @@ -414,22 +459,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; @@ -526,6 +555,256 @@ 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( + IConfidentialClientApplication application, + 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; + 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; + } + + 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, ficScopes).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}:{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); + } + + 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(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); + + // 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, + string[] ficScopes) + { + // 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 and ficScopes for the assertion callback closure. + string? capturedAuthScheme = authenticationScheme; + string[] capturedFicScopes = ficScopes; + + 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(capturedFicScopes) + .WithFmiPath(agentAppId) + .WithSendX5C(blueprintOptions.SendX5C) + .ExecuteAsync(options.CancellationToken) + .ConfigureAwait(false); + + return leg1.AccessToken; + }) + .WithAuthority(authority) + .WithHttpClientFactory(_httpClientFactory) + .WithExperimentalFeatures() + .Build(); + + _agentUserFicCcas[ccaCacheKey] = newApp; + return newApp; + } + finally + { + semaphore.Release(); + } + } + + /// + /// 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) diff --git a/src/Microsoft.Identity.Web.UI/Microsoft.Identity.Web.UI.xml b/src/Microsoft.Identity.Web.UI/Microsoft.Identity.Web.UI.xml index e41b70490..684cd4bd7 100644 --- a/src/Microsoft.Identity.Web.UI/Microsoft.Identity.Web.UI.xml +++ b/src/Microsoft.Identity.Web.UI/Microsoft.Identity.Web.UI.xml @@ -60,6 +60,24 @@ Authentication scheme. Challenge generating a redirect to Azure AD B2C. + + + Returns true when has the same origin (scheme + host + port) + as . Used by Challenge to accept same-origin absolute redirect URIs + without opening an open-redirect sink. + + + + + Defense-in-depth: reject paths whose first segment starts with a percent-encoded + forward or backward slash (%2f/%5c). Browsers per RFC 3986 treat these + as literal path characters, but misconfigured reverse proxies (NGINX, IIS ARR, F5) + can decode them into // or /\ when rewriting the Location + header, reopening the protocol-relative bypass that this controller otherwise + closes. Comparison is case-insensitive because the RFC 3986 encoding is + hex-case-insensitive. + + Page presenting the Access denied error. diff --git a/tests/DevApps/aspnet-mvc/OwinWebApi/Web.config b/tests/DevApps/aspnet-mvc/OwinWebApi/Web.config index faedd7b83..5efd41f37 100644 --- a/tests/DevApps/aspnet-mvc/OwinWebApi/Web.config +++ b/tests/DevApps/aspnet-mvc/OwinWebApi/Web.config @@ -22,7 +22,7 @@ - + @@ -122,7 +122,7 @@ - + diff --git a/tests/DevApps/aspnet-mvc/OwinWebApp/Web.config b/tests/DevApps/aspnet-mvc/OwinWebApp/Web.config index bcaa746c9..b63cd6fb1 100644 --- a/tests/DevApps/aspnet-mvc/OwinWebApp/Web.config +++ b/tests/DevApps/aspnet-mvc/OwinWebApp/Web.config @@ -23,7 +23,7 @@ - + @@ -123,7 +123,7 @@ - + diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs index ef66fa427..273d0df53 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs @@ -417,5 +417,377 @@ 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 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 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 } }