From 588c935d72059a3b30478d0c9ac10ef021b3938c Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 24 Mar 2026 09:26:05 -0700 Subject: [PATCH 1/6] Add agent ID support --- .../AgentIdentity.cs | 93 ++++++++ ...ratedIdentityCredentialParameterBuilder.cs | 31 +++ .../AcquireTokenForAgentParameterBuilder.cs | 104 ++++++++ .../Executors/ConfidentialClientExecutor.cs | 24 ++ .../IConfidentialClientApplicationExecutor.cs | 5 + ...erFederatedIdentityCredentialParameters.cs | 2 + .../AcquireTokenForAgentParameters.cs | 30 +++ .../ConfidentialClientApplication.cs | 24 ++ .../IByUserFederatedIdentityCredential.cs | 19 +- .../IConfidentialClientApplication.cs | 14 ++ .../Internal/Requests/AgentTokenRequest.cs | 126 ++++++++++ .../UserFederatedIdentityCredentialRequest.cs | 29 ++- .../OAuth2/OAuthConstants.cs | 1 + .../PublicApi/net462/PublicAPI.Unshipped.txt | 13 + .../PublicApi/net472/PublicAPI.Unshipped.txt | 13 + .../net8.0-android/PublicAPI.Unshipped.txt | 13 + .../net8.0-ios/PublicAPI.Unshipped.txt | 13 + .../PublicApi/net8.0/PublicAPI.Unshipped.txt | 13 + .../netstandard2.0/PublicAPI.Unshipped.txt | 13 + .../TelemetryCore/Internal/Events/ApiEvent.cs | 3 + .../HeadlessTests/Agentic.cs | 224 ++++++++++++++++++ 21 files changed, 805 insertions(+), 2 deletions(-) create mode 100644 src/client/Microsoft.Identity.Client/AgentIdentity.cs create mode 100644 src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForAgentParameterBuilder.cs create mode 100644 src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForAgentParameters.cs create mode 100644 src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs diff --git a/src/client/Microsoft.Identity.Client/AgentIdentity.cs b/src/client/Microsoft.Identity.Client/AgentIdentity.cs new file mode 100644 index 0000000000..dd83a1370d --- /dev/null +++ b/src/client/Microsoft.Identity.Client/AgentIdentity.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; + +namespace Microsoft.Identity.Client +{ + /// + /// Represents the identity of an agent application and the user it acts on behalf of. + /// Used with + /// to acquire tokens for agent scenarios using Federated Managed Identity (FMI) and User Federated Identity Credentials (UserFIC). + /// + public sealed class AgentIdentity + { + private AgentIdentity(string agentApplicationId) + { + if (string.IsNullOrEmpty(agentApplicationId)) + { + throw new ArgumentNullException(nameof(agentApplicationId)); + } + + AgentApplicationId = agentApplicationId; + } + + /// + /// Creates an that identifies the user by their object ID (OID). + /// This is the recommended approach for identifying users in agent scenarios. + /// + /// The client ID of the agent application. + /// The object ID (OID) of the user the agent acts on behalf of. + /// An configured with the user's OID. + public AgentIdentity(string agentApplicationId, Guid userObjectId) + : this(agentApplicationId) + { + if (userObjectId == Guid.Empty) + { + throw new ArgumentException("userObjectId must not be empty.", nameof(userObjectId)); + } + + UserObjectId = userObjectId; + } + + /// + /// Creates an that identifies the user by their UPN (User Principal Name). + /// + /// The client ID of the agent application. + /// The UPN of the user the agent acts on behalf of. + /// An configured with the user's UPN. + public static AgentIdentity WithUsername(string agentApplicationId, string username) + { + if (string.IsNullOrEmpty(username)) + { + throw new ArgumentNullException(nameof(username)); + } + + return new AgentIdentity(agentApplicationId) + { + Username = username + }; + } + + /// + /// Creates an for app-only (no user) scenarios, where only Legs 1-2 of the + /// agent token acquisition are performed. + /// + /// The client ID of the agent application. + /// An configured for app-only access. + public static AgentIdentity AppOnly(string agentApplicationId) + { + return new AgentIdentity(agentApplicationId); + } + + /// + /// Gets the client ID of the agent application. + /// + public string AgentApplicationId { get; } + + /// + /// Gets the object ID (OID) of the user, if specified. + /// + public Guid? UserObjectId { get; private set; } + + /// + /// Gets the UPN of the user, if specified. + /// + public string Username { get; private set; } + + /// + /// Gets a value indicating whether this identity includes a user identifier (OID or UPN). + /// + internal bool HasUserIdentifier => UserObjectId.HasValue || !string.IsNullOrEmpty(Username); + } +} diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.cs index 7906b9ac60..e6d5230329 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.cs @@ -33,6 +33,16 @@ internal AcquireTokenByUserFederatedIdentityCredentialParameterBuilder( Parameters.Assertion = assertion; } + internal AcquireTokenByUserFederatedIdentityCredentialParameterBuilder( + IConfidentialClientApplicationExecutor confidentialClientApplicationExecutor, + Guid userObjectId, + string assertion) + : base(confidentialClientApplicationExecutor) + { + Parameters.UserObjectId = userObjectId; + Parameters.Assertion = assertion; + } + internal static AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Create( IConfidentialClientApplicationExecutor confidentialClientApplicationExecutor, IEnumerable scopes, @@ -54,6 +64,27 @@ internal static AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Cr .WithScopes(scopes); } + internal static AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Create( + IConfidentialClientApplicationExecutor confidentialClientApplicationExecutor, + IEnumerable scopes, + Guid userObjectId, + string assertion) + { + if (userObjectId == Guid.Empty) + { + throw new ArgumentException("userObjectId must not be empty.", nameof(userObjectId)); + } + + if (string.IsNullOrEmpty(assertion)) + { + throw new ArgumentNullException(nameof(assertion)); + } + + return new AcquireTokenByUserFederatedIdentityCredentialParameterBuilder( + confidentialClientApplicationExecutor, userObjectId, assertion) + .WithScopes(scopes); + } + /// /// Forces MSAL to refresh the token from the identity provider even if a cached token is available. /// diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForAgentParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForAgentParameterBuilder.cs new file mode 100644 index 0000000000..9083fc7434 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForAgentParameterBuilder.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client.ApiConfig.Executors; +using Microsoft.Identity.Client.ApiConfig.Parameters; +using Microsoft.Identity.Client.TelemetryCore.Internal.Events; + +namespace Microsoft.Identity.Client +{ + /// + /// Builder for AcquireTokenForAgent, used to acquire tokens for agent scenarios involving + /// Federated Managed Identity (FMI) and User Federated Identity Credentials (UserFIC). + /// This orchestrates the multi-leg token acquisition automatically. + /// +#if !SUPPORTS_CONFIDENTIAL_CLIENT + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] // hide confidential client on mobile +#endif + public sealed class AcquireTokenForAgentParameterBuilder : + AbstractConfidentialClientAcquireTokenParameterBuilder + { + internal AcquireTokenForAgentParameters Parameters { get; } = new AcquireTokenForAgentParameters(); + + /// + internal AcquireTokenForAgentParameterBuilder( + IConfidentialClientApplicationExecutor confidentialClientApplicationExecutor, + AgentIdentity agentIdentity) + : base(confidentialClientApplicationExecutor) + { + Parameters.AgentIdentity = agentIdentity; + } + + internal static AcquireTokenForAgentParameterBuilder Create( + IConfidentialClientApplicationExecutor confidentialClientApplicationExecutor, + IEnumerable scopes, + AgentIdentity agentIdentity) + { + if (agentIdentity == null) + { + throw new ArgumentNullException(nameof(agentIdentity)); + } + + return new AcquireTokenForAgentParameterBuilder( + confidentialClientApplicationExecutor, + agentIdentity) + .WithScopes(scopes); + } + + /// + /// Specifies if the client application should ignore access tokens when reading the token cache. + /// New tokens will still be written to the token cache. + /// By default the token is taken from the cache (forceRefresh=false). + /// + /// + /// If true, the request will ignore cached access tokens on read, but will still write them to the cache once obtained from the identity provider. The default is false. + /// + /// The builder to chain the .With methods. + public AcquireTokenForAgentParameterBuilder WithForceRefresh(bool forceRefresh) + { + Parameters.ForceRefresh = forceRefresh; + return this; + } + + /// + /// Specifies if the x5c claim (public key of the certificate) should be sent to the identity provider, + /// which enables subject name/issuer based authentication for the client credential. + /// This is useful for certificate rollover scenarios. See https://aka.ms/msal-net-sni. + /// + /// true if the x5c should be sent. Otherwise false. + /// The default is false. + /// The builder to chain the .With methods. + public AcquireTokenForAgentParameterBuilder WithSendX5C(bool withSendX5C) + { + Parameters.SendX5C = withSendX5C; + return this; + } + + /// + internal override Task ExecuteInternalAsync(CancellationToken cancellationToken) + { + return ConfidentialClientApplicationExecutor.ExecuteAsync(CommonParameters, Parameters, cancellationToken); + } + + /// + protected override void Validate() + { + base.Validate(); + + if (Parameters.SendX5C == null) + { + Parameters.SendX5C = this.ServiceBundle.Config.SendX5C; + } + } + + /// + internal override ApiEvent.ApiIds CalculateApiEventId() + { + return ApiEvent.ApiIds.AcquireTokenForAgent; + } + } +} diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs index 8b1178cf78..2afb9f76d9 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs @@ -187,5 +187,29 @@ public async Task ExecuteAsync( return await handler.RunAsync(cancellationToken).ConfigureAwait(false); } + + public async Task ExecuteAsync( + AcquireTokenCommonParameters commonParameters, + AcquireTokenForAgentParameters agentParameters, + CancellationToken cancellationToken) + { + RequestContext requestContext = CreateRequestContextAndLogVersionInfo(commonParameters.CorrelationId, commonParameters.MtlsCertificate, cancellationToken); + + AuthenticationRequestParameters requestParams = await _confidentialClientApplication.CreateRequestParametersAsync( + commonParameters, + requestContext, + _confidentialClientApplication.UserTokenCacheInternal, + cancellationToken).ConfigureAwait(false); + + requestParams.SendX5C = agentParameters.SendX5C ?? false; + + var handler = new AgentTokenRequest( + ServiceBundle, + requestParams, + agentParameters, + _confidentialClientApplication); + + return await handler.RunAsync(cancellationToken).ConfigureAwait(false); + } } } diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/IConfidentialClientApplicationExecutor.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/IConfidentialClientApplicationExecutor.cs index 87a7ee4c75..aa637bf498 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/IConfidentialClientApplicationExecutor.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/IConfidentialClientApplicationExecutor.cs @@ -42,5 +42,10 @@ Task ExecuteAsync( AcquireTokenCommonParameters commonParameters, AcquireTokenByUserFederatedIdentityCredentialParameters userFicParameters, CancellationToken cancellationToken); + + Task ExecuteAsync( + AcquireTokenCommonParameters commonParameters, + AcquireTokenForAgentParameters agentParameters, + CancellationToken cancellationToken); } } diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs index 2256f88e99..f81182eb70 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; using Microsoft.Identity.Client.Core; namespace Microsoft.Identity.Client.ApiConfig.Parameters @@ -8,6 +9,7 @@ namespace Microsoft.Identity.Client.ApiConfig.Parameters internal class AcquireTokenByUserFederatedIdentityCredentialParameters : IAcquireTokenParameters { public string Username { get; set; } + public Guid? UserObjectId { get; set; } public string Assertion { get; set; } public bool? SendX5C { get; set; } public bool ForceRefresh { get; set; } diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForAgentParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForAgentParameters.cs new file mode 100644 index 0000000000..82b6f43ee7 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForAgentParameters.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text; +using Microsoft.Identity.Client.Core; + +namespace Microsoft.Identity.Client.ApiConfig.Parameters +{ + internal class AcquireTokenForAgentParameters : AbstractAcquireTokenConfidentialClientParameters, IAcquireTokenParameters + { + public AgentIdentity AgentIdentity { get; set; } + + public bool ForceRefresh { get; set; } + + /// + public void LogParameters(ILoggerAdapter logger) + { + if (logger.IsLoggingEnabled(LogLevel.Info)) + { + var builder = new StringBuilder(); + builder.AppendLine("=== AcquireTokenForAgentParameters ==="); + builder.AppendLine("SendX5C: " + SendX5C); + builder.AppendLine("ForceRefresh: " + ForceRefresh); + builder.AppendLine("AgentApplicationId: " + AgentIdentity?.AgentApplicationId); + builder.AppendLine("HasUserIdentifier: " + (AgentIdentity?.HasUserIdentifier ?? false)); + logger.Info(builder.ToString()); + } + } + } +} diff --git a/src/client/Microsoft.Identity.Client/ConfidentialClientApplication.cs b/src/client/Microsoft.Identity.Client/ConfidentialClientApplication.cs index 50d6c17568..9e4224ddf1 100644 --- a/src/client/Microsoft.Identity.Client/ConfidentialClientApplication.cs +++ b/src/client/Microsoft.Identity.Client/ConfidentialClientApplication.cs @@ -197,6 +197,19 @@ AcquireTokenByUserFederatedIdentityCredentialParameterBuilder IByUserFederatedId assertion); } + /// + AcquireTokenByUserFederatedIdentityCredentialParameterBuilder IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential( + IEnumerable scopes, + Guid userObjectId, + string assertion) + { + return AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.Create( + ClientExecutorFactory.CreateConfidentialClientExecutor(this), + scopes, + userObjectId, + assertion); + } + AcquireTokenByRefreshTokenParameterBuilder IByRefreshToken.AcquireTokenByRefreshToken( IEnumerable scopes, string refreshToken) @@ -207,6 +220,17 @@ AcquireTokenByRefreshTokenParameterBuilder IByRefreshToken.AcquireTokenByRefresh refreshToken); } + /// + public AcquireTokenForAgentParameterBuilder AcquireTokenForAgent( + IEnumerable scopes, + AgentIdentity agentIdentity) + { + return AcquireTokenForAgentParameterBuilder.Create( + ClientExecutorFactory.CreateConfidentialClientExecutor(this), + scopes, + agentIdentity); + } + /// public ITokenCache AppTokenCache => AppTokenCacheInternal; diff --git a/src/client/Microsoft.Identity.Client/IByUserFederatedIdentityCredential.cs b/src/client/Microsoft.Identity.Client/IByUserFederatedIdentityCredential.cs index 8eff4a19e6..0f80b905f4 100644 --- a/src/client/Microsoft.Identity.Client/IByUserFederatedIdentityCredential.cs +++ b/src/client/Microsoft.Identity.Client/IByUserFederatedIdentityCredential.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; using System.Collections.Generic; namespace Microsoft.Identity.Client @@ -15,7 +16,7 @@ public interface IByUserFederatedIdentityCredential { /// /// Acquires a token on behalf of a user using a federated identity credential assertion. - /// This uses the user_fic grant type. + /// This uses the user_fic grant type. The user is identified by UPN. /// /// Scopes requested to access a protected API. /// The UPN (User Principal Name) of the user, e.g. john.doe@contoso.com. @@ -28,5 +29,21 @@ AcquireTokenByUserFederatedIdentityCredentialParameterBuilder AcquireTokenByUser IEnumerable scopes, string username, string assertion); + + /// + /// Acquires a token on behalf of a user using a federated identity credential assertion. + /// This uses the user_fic grant type. The user is identified by Object ID (OID). + /// + /// Scopes requested to access a protected API. + /// The Object ID (OID) of the user in Entra ID. + /// + /// The federated identity credential assertion (JWT) for the user. + /// Acquire this token from a Managed Identity or Confidential Client application before calling this method. + /// + /// A builder enabling you to add optional parameters before executing the token request. + AcquireTokenByUserFederatedIdentityCredentialParameterBuilder AcquireTokenByUserFederatedIdentityCredential( + IEnumerable scopes, + Guid userObjectId, + string assertion); } } diff --git a/src/client/Microsoft.Identity.Client/IConfidentialClientApplication.cs b/src/client/Microsoft.Identity.Client/IConfidentialClientApplication.cs index 8eced85a84..351f775304 100644 --- a/src/client/Microsoft.Identity.Client/IConfidentialClientApplication.cs +++ b/src/client/Microsoft.Identity.Client/IConfidentialClientApplication.cs @@ -86,6 +86,20 @@ AcquireTokenByAuthorizationCodeParameterBuilder AcquireTokenByAuthorizationCode( /// URL of the authorization endpoint with the specified parameters. GetAuthorizationRequestUrlParameterBuilder GetAuthorizationRequestUrl(IEnumerable scopes); + /// + /// Acquires a token for an agent application acting on behalf of a user or as an app-only identity. + /// This method orchestrates the multi-leg Federated Managed Identity (FMI) and + /// User Federated Identity Credential (UserFIC) flows automatically. + /// The calling application (blueprint) must be configured with a certificate credential (SN+I) + /// for FMI token acquisition. + /// + /// Scopes requested to access a protected API. + /// An describing the agent application and optionally the user. + /// A builder enabling you to add optional parameters before executing the token request. + AcquireTokenForAgentParameterBuilder AcquireTokenForAgent( + IEnumerable scopes, + AgentIdentity agentIdentity); + /// /// In confidential client apps use instead. /// diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs new file mode 100644 index 0000000000..99702f3aa2 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client.ApiConfig.Parameters; +using Microsoft.Identity.Client.Extensibility; + +namespace Microsoft.Identity.Client.Internal.Requests +{ + internal class AgentTokenRequest : RequestBase + { + private readonly AcquireTokenForAgentParameters _agentParameters; + private readonly ConfidentialClientApplication _blueprintApplication; + + public AgentTokenRequest( + IServiceBundle serviceBundle, + AuthenticationRequestParameters authenticationRequestParameters, + AcquireTokenForAgentParameters agentParameters, + ConfidentialClientApplication blueprintApplication) + : base(serviceBundle, authenticationRequestParameters, agentParameters) + { + _agentParameters = agentParameters; + _blueprintApplication = blueprintApplication; + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + await ResolveAuthorityAsync().ConfigureAwait(false); + + AgentIdentity agentIdentity = _agentParameters.AgentIdentity; + string agentAppId = agentIdentity.AgentApplicationId; + string authority = AuthenticationRequestParameters.Authority.AuthorityInfo.CanonicalAuthority.ToString(); + + if (!agentIdentity.HasUserIdentifier) + { + // App-only flow: get a client credential token for the agent + var agentCca = BuildAgentCca(agentAppId, authority); + + return await agentCca + .AcquireTokenForClient(AuthenticationRequestParameters.Scope) + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false); + } + + // User identity flow + // Step 1: Get assertion token via FMI path + var assertionApp = BuildAssertionApp(agentAppId, authority); + + var assertionResult = await assertionApp + .AcquireTokenForClient(new[] { TokenExchangeScope }) + .WithFmiPathForClientAssertion(agentAppId) + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false); + + string assertion = assertionResult.AccessToken; + + // Step 2: Exchange assertion for user token via UserFIC + var mainCca = BuildAgentCca(agentAppId, authority); + + if (agentIdentity.UserObjectId.HasValue) + { + return await ((IByUserFederatedIdentityCredential)mainCca) + .AcquireTokenByUserFederatedIdentityCredential( + AuthenticationRequestParameters.Scope, + agentIdentity.UserObjectId.Value, + assertion) + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false); + } + + return await ((IByUserFederatedIdentityCredential)mainCca) + .AcquireTokenByUserFederatedIdentityCredential( + AuthenticationRequestParameters.Scope, + agentIdentity.Username, + assertion) + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false); + } + + protected override KeyValuePair? GetCcsHeader(IDictionary additionalBodyParameters) + { + // CCS headers are handled by the internal CCAs' own request handlers. + return null; + } + + private const string TokenExchangeScope = "api://AzureADTokenExchange/.default"; + + private IConfidentialClientApplication BuildAgentCca(string agentAppId, string authority) + { + return ConfidentialClientApplicationBuilder + .Create(agentAppId) + .WithAuthority(authority) + .WithExperimentalFeatures(true) + .WithClientAssertion((AssertionRequestOptions _) => GetFmiCredentialAsync(agentAppId)) + .Build(); + } + + private IConfidentialClientApplication BuildAssertionApp(string agentAppId, string authority) + { + return ConfidentialClientApplicationBuilder + .Create(agentAppId) + .WithAuthority(authority) + .WithExperimentalFeatures(true) + .WithClientAssertion(async (AssertionRequestOptions opts) => + { + string fmiPath = opts.ClientAssertionFmiPath ?? agentAppId; + return await GetFmiCredentialAsync(fmiPath).ConfigureAwait(false); + }) + .Build(); + } + + private async Task GetFmiCredentialAsync(string fmiPath) + { + var result = await _blueprintApplication + .AcquireTokenForClient(new[] { TokenExchangeScope }) + .WithFmiPath(fmiPath) + .ExecuteAsync() + .ConfigureAwait(false); + + return result.AccessToken; + } + } +} diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/UserFederatedIdentityCredentialRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/UserFederatedIdentityCredentialRequest.cs index f73bc3490c..f4422454fb 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/UserFederatedIdentityCredentialRequest.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/UserFederatedIdentityCredentialRequest.cs @@ -38,10 +38,22 @@ private Dictionary GetAdditionalBodyParameters(string assertion) var dict = new Dictionary { [OAuth2Parameter.GrantType] = OAuth2GrantType.UserFic, - [OAuth2Parameter.Username] = _userFicParameters.Username, [OAuth2Parameter.UserFederatedIdentityCredential] = assertion }; + // The user_fic grant identifies the user by either OID (user_id) or UPN (username). + // The parameter builder enforces that exactly one is set via separate constructors, + // so both values cannot be populated simultaneously through the public API. + // OID is checked first because it is immutable and preferred over UPN, which can be renamed. + if (_userFicParameters.UserObjectId.HasValue) + { + dict[OAuth2Parameter.UserId] = _userFicParameters.UserObjectId.Value.ToString("D"); + } + else + { + dict[OAuth2Parameter.Username] = _userFicParameters.Username; + } + ISet unionScope = new HashSet { OAuth2Value.ScopeOpenId, @@ -58,6 +70,21 @@ private Dictionary GetAdditionalBodyParameters(string assertion) protected override KeyValuePair? GetCcsHeader(IDictionary additionalBodyParameters) { + // CCS routing hint mirrors the OID/UPN choice above—route by OID when available, UPN otherwise. + if (_userFicParameters.UserObjectId.HasValue) + { + string ccsHint = CoreHelpers.GetCcsClientInfoHint( + _userFicParameters.UserObjectId.Value.ToString("D"), + AuthenticationRequestParameters.Authority.TenantId); + + if (!string.IsNullOrEmpty(ccsHint)) + { + return new KeyValuePair(Constants.CcsRoutingHintHeader, ccsHint); + } + + return null; + } + return GetCcsUpnHeader(_userFicParameters.Username); } } diff --git a/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs b/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs index 9a15c34ad8..e200c359c0 100644 --- a/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs +++ b/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs @@ -47,6 +47,7 @@ internal static class OAuth2Parameter public const string FmiPath = "fmi_path"; // not a standard OAuth2 param public const string Attributes = "attributes"; // not a standard OAuth2 param public const string UserFederatedIdentityCredential = "user_federated_identity_credential"; // user_fic grant type parameter + public const string UserId = "user_id"; // user_fic grant type parameter (OID-based user identification) } internal static class OAuth2GrantType diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt index 101adba535..6a8e69854d 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -3,3 +3,16 @@ Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameter Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.IByUserFederatedIdentityCredential Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, string username, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, System.Guid userObjectId, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AgentIdentity +Microsoft.Identity.Client.AgentIdentity.AgentIdentity(string agentApplicationId, System.Guid userObjectId) -> void +Microsoft.Identity.Client.AgentIdentity.AgentApplicationId.get -> string +Microsoft.Identity.Client.AgentIdentity.UserObjectId.get -> System.Guid? +Microsoft.Identity.Client.AgentIdentity.Username.get -> string +static Microsoft.Identity.Client.AgentIdentity.WithUsername(string agentApplicationId, string username) -> Microsoft.Identity.Client.AgentIdentity +static Microsoft.Identity.Client.AgentIdentity.AppOnly(string agentApplicationId) -> Microsoft.Identity.Client.AgentIdentity +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.IConfidentialClientApplication.AcquireTokenForAgent(System.Collections.Generic.IEnumerable scopes, Microsoft.Identity.Client.AgentIdentity agentIdentity) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.ConfidentialClientApplication.AcquireTokenForAgent(System.Collections.Generic.IEnumerable scopes, Microsoft.Identity.Client.AgentIdentity agentIdentity) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt index 101adba535..6a8e69854d 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -3,3 +3,16 @@ Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameter Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.IByUserFederatedIdentityCredential Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, string username, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, System.Guid userObjectId, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AgentIdentity +Microsoft.Identity.Client.AgentIdentity.AgentIdentity(string agentApplicationId, System.Guid userObjectId) -> void +Microsoft.Identity.Client.AgentIdentity.AgentApplicationId.get -> string +Microsoft.Identity.Client.AgentIdentity.UserObjectId.get -> System.Guid? +Microsoft.Identity.Client.AgentIdentity.Username.get -> string +static Microsoft.Identity.Client.AgentIdentity.WithUsername(string agentApplicationId, string username) -> Microsoft.Identity.Client.AgentIdentity +static Microsoft.Identity.Client.AgentIdentity.AppOnly(string agentApplicationId) -> Microsoft.Identity.Client.AgentIdentity +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.IConfidentialClientApplication.AcquireTokenForAgent(System.Collections.Generic.IEnumerable scopes, Microsoft.Identity.Client.AgentIdentity agentIdentity) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.ConfidentialClientApplication.AcquireTokenForAgent(System.Collections.Generic.IEnumerable scopes, Microsoft.Identity.Client.AgentIdentity agentIdentity) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt index 101adba535..6a8e69854d 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt @@ -3,3 +3,16 @@ Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameter Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.IByUserFederatedIdentityCredential Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, string username, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, System.Guid userObjectId, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AgentIdentity +Microsoft.Identity.Client.AgentIdentity.AgentIdentity(string agentApplicationId, System.Guid userObjectId) -> void +Microsoft.Identity.Client.AgentIdentity.AgentApplicationId.get -> string +Microsoft.Identity.Client.AgentIdentity.UserObjectId.get -> System.Guid? +Microsoft.Identity.Client.AgentIdentity.Username.get -> string +static Microsoft.Identity.Client.AgentIdentity.WithUsername(string agentApplicationId, string username) -> Microsoft.Identity.Client.AgentIdentity +static Microsoft.Identity.Client.AgentIdentity.AppOnly(string agentApplicationId) -> Microsoft.Identity.Client.AgentIdentity +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.IConfidentialClientApplication.AcquireTokenForAgent(System.Collections.Generic.IEnumerable scopes, Microsoft.Identity.Client.AgentIdentity agentIdentity) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.ConfidentialClientApplication.AcquireTokenForAgent(System.Collections.Generic.IEnumerable scopes, Microsoft.Identity.Client.AgentIdentity agentIdentity) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt index 101adba535..6a8e69854d 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt @@ -3,3 +3,16 @@ Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameter Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.IByUserFederatedIdentityCredential Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, string username, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, System.Guid userObjectId, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AgentIdentity +Microsoft.Identity.Client.AgentIdentity.AgentIdentity(string agentApplicationId, System.Guid userObjectId) -> void +Microsoft.Identity.Client.AgentIdentity.AgentApplicationId.get -> string +Microsoft.Identity.Client.AgentIdentity.UserObjectId.get -> System.Guid? +Microsoft.Identity.Client.AgentIdentity.Username.get -> string +static Microsoft.Identity.Client.AgentIdentity.WithUsername(string agentApplicationId, string username) -> Microsoft.Identity.Client.AgentIdentity +static Microsoft.Identity.Client.AgentIdentity.AppOnly(string agentApplicationId) -> Microsoft.Identity.Client.AgentIdentity +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.IConfidentialClientApplication.AcquireTokenForAgent(System.Collections.Generic.IEnumerable scopes, Microsoft.Identity.Client.AgentIdentity agentIdentity) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.ConfidentialClientApplication.AcquireTokenForAgent(System.Collections.Generic.IEnumerable scopes, Microsoft.Identity.Client.AgentIdentity agentIdentity) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt index 101adba535..6a8e69854d 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt @@ -3,3 +3,16 @@ Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameter Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.IByUserFederatedIdentityCredential Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, string username, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, System.Guid userObjectId, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AgentIdentity +Microsoft.Identity.Client.AgentIdentity.AgentIdentity(string agentApplicationId, System.Guid userObjectId) -> void +Microsoft.Identity.Client.AgentIdentity.AgentApplicationId.get -> string +Microsoft.Identity.Client.AgentIdentity.UserObjectId.get -> System.Guid? +Microsoft.Identity.Client.AgentIdentity.Username.get -> string +static Microsoft.Identity.Client.AgentIdentity.WithUsername(string agentApplicationId, string username) -> Microsoft.Identity.Client.AgentIdentity +static Microsoft.Identity.Client.AgentIdentity.AppOnly(string agentApplicationId) -> Microsoft.Identity.Client.AgentIdentity +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.IConfidentialClientApplication.AcquireTokenForAgent(System.Collections.Generic.IEnumerable scopes, Microsoft.Identity.Client.AgentIdentity agentIdentity) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.ConfidentialClientApplication.AcquireTokenForAgent(System.Collections.Generic.IEnumerable scopes, Microsoft.Identity.Client.AgentIdentity agentIdentity) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt index 101adba535..6a8e69854d 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt @@ -3,3 +3,16 @@ Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameter Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.IByUserFederatedIdentityCredential Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, string username, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential(System.Collections.Generic.IEnumerable scopes, System.Guid userObjectId, string assertion) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder +Microsoft.Identity.Client.AgentIdentity +Microsoft.Identity.Client.AgentIdentity.AgentIdentity(string agentApplicationId, System.Guid userObjectId) -> void +Microsoft.Identity.Client.AgentIdentity.AgentApplicationId.get -> string +Microsoft.Identity.Client.AgentIdentity.UserObjectId.get -> System.Guid? +Microsoft.Identity.Client.AgentIdentity.Username.get -> string +static Microsoft.Identity.Client.AgentIdentity.WithUsername(string agentApplicationId, string username) -> Microsoft.Identity.Client.AgentIdentity +static Microsoft.Identity.Client.AgentIdentity.AppOnly(string agentApplicationId) -> Microsoft.Identity.Client.AgentIdentity +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.IConfidentialClientApplication.AcquireTokenForAgent(System.Collections.Generic.IEnumerable scopes, Microsoft.Identity.Client.AgentIdentity agentIdentity) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder +Microsoft.Identity.Client.ConfidentialClientApplication.AcquireTokenForAgent(System.Collections.Generic.IEnumerable scopes, Microsoft.Identity.Client.AgentIdentity agentIdentity) -> Microsoft.Identity.Client.AcquireTokenForAgentParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/TelemetryCore/Internal/Events/ApiEvent.cs b/src/client/Microsoft.Identity.Client/TelemetryCore/Internal/Events/ApiEvent.cs index d6ecf2a84c..f6e4b4ac3f 100644 --- a/src/client/Microsoft.Identity.Client/TelemetryCore/Internal/Events/ApiEvent.cs +++ b/src/client/Microsoft.Identity.Client/TelemetryCore/Internal/Events/ApiEvent.cs @@ -42,6 +42,9 @@ public enum ApiIds // UserFIC AcquireTokenByUserFederatedIdentityCredential = 1019, + // Agent identity (FMI + UserFIC orchestration) + AcquireTokenForAgent = 1020, + // "2002" is reserved for 1p OTEL signal that telemetry is disabled } diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/Agentic.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/Agentic.cs index 7d150e8eb5..c3cdc0499f 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/Agentic.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/Agentic.cs @@ -23,12 +23,25 @@ public class Agentic private const string TokenExchangeUrl = "api://AzureADTokenExchange/.default"; private const string Scope = "https://graph.microsoft.com/.default"; + #region UserFIC Primitive + App-only Tests (Lower-Level API) + + /// + /// Validates the low-level UserFIC primitive: builds separate assertion and main CCAs, + /// acquires a user_fic assertion via FMI, then exchanges it for a user-scoped Graph token + /// using the UPN-based AcquireTokenByUserFederatedIdentityCredential overload. + /// Also verifies that the resulting token is cached and can be retrieved silently. + /// [TestMethod] public async Task AgentUserIdentityGetsTokenForGraphTest() { await AgentUserIdentityGetsTokenForGraphAsync().ConfigureAwait(false); } + /// + /// Validates app-only (no user) token acquisition for an agent app. + /// The agent CCA uses an FMI-based client assertion to get a client credentials token + /// for Graph, without any user identity involved. + /// [TestMethod] public async Task AgentGetsAppTokenForGraphTest() { @@ -99,6 +112,215 @@ private static async Task AgentUserIdentityGetsTokenForGraphAsync() Assert.AreEqual(TokenSource.Cache, result2.AuthenticationResultMetadata.TokenSource, "Token should be from cache"); } + #endregion + + #region AcquireTokenForAgent Tests (High-Level API) + + /// + /// Tests the high-level AcquireTokenForAgent API with a UPN-based AgentIdentity. + /// This exercises the full 3-leg flow (FMI credential → assertion → UserFIC exchange) + /// orchestrated internally by AgentTokenRequest, using a blueprint CCA with SN+I certificate. + /// + [TestMethod] + public async Task AcquireTokenForAgent_WithUpn_Test() + { + // Arrange: Blueprint CCA configured with certificate (SN+I) for FMI flows + X509Certificate2 cert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + + var blueprintCca = ConfidentialClientApplicationBuilder + .Create(ClientId) + .WithAuthority("https://login.microsoftonline.com/", TenantId) + .WithExperimentalFeatures(true) + .WithCertificate(cert, sendX5C: true) + .Build(); + + var agentId = Client.AgentIdentity.WithUsername(AgentIdentity, UserUpn); + + // Act: Use the high-level AcquireTokenForAgent API + var result = await blueprintCca + .AcquireTokenForAgent([Scope], agentId) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsNotNull(result.AccessToken, "Access token should not be null"); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource, "Token should be from identity provider"); + + Trace.WriteLine($"AcquireTokenForAgent (UPN) token source: {result.AuthenticationResultMetadata.TokenSource}"); + } + + /// + /// Tests the high-level AcquireTokenForAgent API for app-only (no user) scenarios. + /// Only Legs 1-2 are performed: the blueprint CCA fetches an FMI credential, then + /// an internal agent CCA uses it to get a client credentials token for Graph. + /// + [TestMethod] + public async Task AcquireTokenForAgent_AppOnly_Test() + { + // Arrange: Blueprint CCA configured with certificate (SN+I) for FMI flows + X509Certificate2 cert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + + var blueprintCca = ConfidentialClientApplicationBuilder + .Create(ClientId) + .WithAuthority("https://login.microsoftonline.com/", TenantId) + .WithExperimentalFeatures(true) + .WithCertificate(cert, sendX5C: true) + .Build(); + + var agentId = Client.AgentIdentity.AppOnly(AgentIdentity); + + // Act: Use the high-level AcquireTokenForAgent API for app-only scenario + var result = await blueprintCca + .AcquireTokenForAgent([Scope], agentId) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsNotNull(result.AccessToken, "Access token should not be null"); + + Trace.WriteLine($"AcquireTokenForAgent (AppOnly) token source: {result.AuthenticationResultMetadata.TokenSource}"); + } + + #endregion + + #region UserFIC Guid Overload Tests + + /// + /// Tests the Guid (OID) overload of the low-level AcquireTokenByUserFederatedIdentityCredential primitive. + /// First discovers the user's OID by calling the UPN-based flow, then acquires a fresh assertion + /// and calls the Guid overload to verify it sends user_id (OID) instead of username (UPN). + /// + [TestMethod] + public async Task UserFic_WithGuidObjectId_Test() + { + // Arrange: First obtain a token via UPN to get the user's OID + var assertionApp = ConfidentialClientApplicationBuilder + .Create(AgentIdentity) + .WithAuthority("https://login.microsoftonline.com/", TenantId) + .WithExperimentalFeatures(true) + .WithCacheOptions(CacheOptions.EnableSharedCacheOptions) + .WithClientAssertion(async (AssertionRequestOptions a) => + { + return await GetAppCredentialAsync(a.ClientAssertionFmiPath ?? AgentIdentity).ConfigureAwait(false); + }) + .Build(); + + var cca = ConfidentialClientApplicationBuilder + .Create(AgentIdentity) + .WithAuthority("https://login.microsoftonline.com/", TenantId) + .WithCacheOptions(CacheOptions.EnableSharedCacheOptions) + .WithExperimentalFeatures(true) + .WithClientAssertion((AssertionRequestOptions _) => GetAppCredentialAsync(AgentIdentity)) + .Build(); + + // Step 1: Get assertion via FMI path + var assertionResult = await assertionApp + .AcquireTokenForClient([TokenExchangeUrl]) + .WithFmiPathForClientAssertion(AgentIdentity) + .ExecuteAsync() + .ConfigureAwait(false); + + string assertion1 = assertionResult.AccessToken; + + // Step 2: Get user token via UPN to discover the user's OID + var upnResult = await (cca as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential([Scope], UserUpn, assertion1) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(upnResult.Account, "Account should not be null"); + + // Extract the user OID from the account's HomeAccountId (format: oid.tid) + string oidString = upnResult.Account.HomeAccountId.ObjectId; + Assert.IsNotNull(oidString, "OID should not be null"); + Guid userOid = Guid.Parse(oidString); + + Trace.WriteLine($"Discovered user OID: {userOid}"); + + // Step 3: Now acquire a NEW assertion (since the first one was consumed) + var assertionResult2 = await assertionApp + .AcquireTokenForClient([TokenExchangeUrl]) + .WithForceRefresh(true) + .WithFmiPathForClientAssertion(AgentIdentity) + .ExecuteAsync() + .ConfigureAwait(false); + + string assertion2 = assertionResult2.AccessToken; + + // Act: Use the Guid overload of AcquireTokenByUserFederatedIdentityCredential + var result = await (cca as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential([Scope], userOid, assertion2) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsNotNull(result.AccessToken, "Access token should not be null"); + Assert.AreEqual(oidString, result.Account.HomeAccountId.ObjectId, "OID should match"); + + Trace.WriteLine($"UserFIC Guid overload token source: {result.AuthenticationResultMetadata.TokenSource}"); + } + + /// + /// Tests the high-level AcquireTokenForAgent API with a Guid (OID)-based AgentIdentity. + /// Discovers the user's OID via the UPN path first, then creates an AgentIdentity(agentAppId, userOid) + /// and verifies the full 3-leg flow succeeds using the OID-based UserFIC exchange. + /// + [TestMethod] + public async Task AcquireTokenForAgent_WithOid_Test() + { + // Arrange: First discover the user's OID by running a UPN-based flow + X509Certificate2 cert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + + var blueprintCca = ConfidentialClientApplicationBuilder + .Create(ClientId) + .WithAuthority("https://login.microsoftonline.com/", TenantId) + .WithExperimentalFeatures(true) + .WithCertificate(cert, sendX5C: true) + .Build(); + + // Get the OID via the UPN path + var upnResult = await blueprintCca + .AcquireTokenForAgent([Scope], Client.AgentIdentity.WithUsername(AgentIdentity, UserUpn)) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(upnResult.Account, "Account should not be null after UPN-based flow"); + Guid userOid = Guid.Parse(upnResult.Account.HomeAccountId.ObjectId); + Trace.WriteLine($"Discovered user OID: {userOid}"); + + // Act: Build a new blueprint CCA and use the OID-based AgentIdentity + var blueprintCca2 = ConfidentialClientApplicationBuilder + .Create(ClientId) + .WithAuthority("https://login.microsoftonline.com/", TenantId) + .WithExperimentalFeatures(true) + .WithCertificate(cert, sendX5C: true) + .Build(); + + var agentId = new Client.AgentIdentity(AgentIdentity, userOid); + + var result = await blueprintCca2 + .AcquireTokenForAgent([Scope], agentId) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsNotNull(result.AccessToken, "Access token should not be null"); + Assert.AreEqual( + upnResult.Account.HomeAccountId.ObjectId, + result.Account.HomeAccountId.ObjectId, + "OID should match between UPN and OID flows"); + + Trace.WriteLine($"AcquireTokenForAgent (OID) token source: {result.AuthenticationResultMetadata.TokenSource}"); + } + + #endregion + + #region Shared Helpers + private static async Task GetAppCredentialAsync(string fmiPath) { Assert.IsNotNull(fmiPath, "fmiPath cannot be null"); @@ -121,5 +343,7 @@ private static async Task GetAppCredentialAsync(string fmiPath) return result.AccessToken; } + + #endregion } } From 9f5ea5c9348404e57cefefbd29c29151699ac472 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 24 Mar 2026 14:53:34 -0700 Subject: [PATCH 2/6] Improve caching behavior for agent scenarios --- .../ConfidentialClientApplication.cs | 8 + .../Internal/Requests/AgentTokenRequest.cs | 129 +++++++++- .../HeadlessTests/Agentic.cs | 224 ++++++++++++++++++ .../UserFederatedIdentityCredentialTests.cs | 158 ++++++++++++ 4 files changed, 508 insertions(+), 11 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/ConfidentialClientApplication.cs b/src/client/Microsoft.Identity.Client/ConfidentialClientApplication.cs index 9e4224ddf1..381cb39991 100644 --- a/src/client/Microsoft.Identity.Client/ConfidentialClientApplication.cs +++ b/src/client/Microsoft.Identity.Client/ConfidentialClientApplication.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Security.Cryptography.X509Certificates; using System.Threading; @@ -242,6 +243,13 @@ public AcquireTokenForAgentParameterBuilder AcquireTokenForAgent( // Stores all app tokens internal ITokenCacheInternal AppTokenCacheInternal { get; } + /// + /// Caches internal CCA instances created by so that + /// subsequent AcquireTokenForAgent calls for the same agent reuse the same CCA + /// (and its in-memory token cache) instead of rebuilding from scratch each time. + /// + internal ConcurrentDictionary AgentCcaCache { get; } = new(); + internal override async Task CreateRequestParametersAsync( AcquireTokenCommonParameters commonParameters, RequestContext requestContext, diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs index 99702f3aa2..0871278bf1 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Identity.Client.ApiConfig.Parameters; @@ -36,8 +37,10 @@ protected override async Task ExecuteAsync(CancellationTok if (!agentIdentity.HasUserIdentifier) { - // App-only flow: get a client credential token for the agent - var agentCca = BuildAgentCca(agentAppId, authority); + // App-only flow: get a client credential token for the agent. + // AcquireTokenForClient has built-in cache-first logic, so CCA persistence + // is sufficient — no explicit silent call needed. + var agentCca = GetOrCreateAgentCca(agentAppId, authority); return await agentCca .AcquireTokenForClient(AuthenticationRequestParameters.Scope) @@ -46,8 +49,26 @@ protected override async Task ExecuteAsync(CancellationTok } // User identity flow + var mainCca = GetOrCreateAgentCca(agentAppId, authority); + + // Try cache first via AcquireTokenSilent (unless ForceRefresh is set) + if (!_agentParameters.ForceRefresh) + { + var cachedResult = await TryAcquireTokenSilentAsync( + mainCca, + agentIdentity, + AuthenticationRequestParameters.Scope, + cancellationToken).ConfigureAwait(false); + if (cachedResult != null) + { + return cachedResult; + } + } + + // Cache miss or ForceRefresh — execute the full Leg 2 + Leg 3 flow + // Step 1: Get assertion token via FMI path - var assertionApp = BuildAssertionApp(agentAppId, authority); + var assertionApp = GetOrCreateAssertionCca(agentAppId, authority); var assertionResult = await assertionApp .AcquireTokenForClient(new[] { TokenExchangeScope }) @@ -58,8 +79,6 @@ protected override async Task ExecuteAsync(CancellationTok string assertion = assertionResult.AccessToken; // Step 2: Exchange assertion for user token via UserFIC - var mainCca = BuildAgentCca(agentAppId, authority); - if (agentIdentity.UserObjectId.HasValue) { return await ((IByUserFederatedIdentityCredential)mainCca) @@ -80,6 +99,56 @@ protected override async Task ExecuteAsync(CancellationTok .ConfigureAwait(false); } + /// + /// Attempts to find a cached token for the specified user on the agent CCA. + /// Returns null if no matching account is found or the silent call fails. + /// + private static async Task TryAcquireTokenSilentAsync( + IConfidentialClientApplication agentCca, + AgentIdentity agentIdentity, + IEnumerable scopes, + CancellationToken cancellationToken) + { +#pragma warning disable CS0618 // GetAccountsAsync is obsolete for external callers but needed here to enumerate cached accounts + var accounts = await agentCca.GetAccountsAsync().ConfigureAwait(false); +#pragma warning restore CS0618 + + IAccount matchedAccount = FindMatchingAccount(accounts, agentIdentity); + if (matchedAccount == null) + { + return null; + } + + try + { + return await agentCca + .AcquireTokenSilent(scopes, matchedAccount) + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false); + } + catch (MsalUiRequiredException) + { + // Token expired or requires interaction — fall through to full flow + return null; + } + } + + /// + /// Finds an account in the cache that matches the agent identity by OID or UPN. + /// + private static IAccount FindMatchingAccount(IEnumerable accounts, AgentIdentity agentIdentity) + { + if (agentIdentity.UserObjectId.HasValue) + { + string targetOid = agentIdentity.UserObjectId.Value.ToString("D"); + return accounts.FirstOrDefault(a => + string.Equals(a.HomeAccountId?.ObjectId, targetOid, StringComparison.OrdinalIgnoreCase)); + } + + return accounts.FirstOrDefault(a => + string.Equals(a.Username, agentIdentity.Username, StringComparison.OrdinalIgnoreCase)); + } + protected override KeyValuePair? GetCcsHeader(IDictionary additionalBodyParameters) { // CCS headers are handled by the internal CCAs' own request handlers. @@ -87,20 +156,36 @@ protected override async Task ExecuteAsync(CancellationTok } private const string TokenExchangeScope = "api://AzureADTokenExchange/.default"; + private const string AgentCcaKeyPrefix = "agent_"; + private const string AssertionCcaKeyPrefix = "assertion_"; + + private IConfidentialClientApplication GetOrCreateAgentCca(string agentAppId, string authority) + { + string key = AgentCcaKeyPrefix + agentAppId; + return _blueprintApplication.AgentCcaCache.GetOrAdd(key, _ => BuildAgentCca(agentAppId, authority)); + } + + private IConfidentialClientApplication GetOrCreateAssertionCca(string agentAppId, string authority) + { + string key = AssertionCcaKeyPrefix + agentAppId; + return _blueprintApplication.AgentCcaCache.GetOrAdd(key, _ => BuildAssertionApp(agentAppId, authority)); + } private IConfidentialClientApplication BuildAgentCca(string agentAppId, string authority) { - return ConfidentialClientApplicationBuilder + var builder = ConfidentialClientApplicationBuilder .Create(agentAppId) .WithAuthority(authority) .WithExperimentalFeatures(true) - .WithClientAssertion((AssertionRequestOptions _) => GetFmiCredentialAsync(agentAppId)) - .Build(); + .WithClientAssertion((AssertionRequestOptions _) => GetFmiCredentialAsync(agentAppId)); + + PropagateHttpConfig(builder); + return builder.Build(); } private IConfidentialClientApplication BuildAssertionApp(string agentAppId, string authority) { - return ConfidentialClientApplicationBuilder + var builder = ConfidentialClientApplicationBuilder .Create(agentAppId) .WithAuthority(authority) .WithExperimentalFeatures(true) @@ -108,8 +193,30 @@ private IConfidentialClientApplication BuildAssertionApp(string agentAppId, stri { string fmiPath = opts.ClientAssertionFmiPath ?? agentAppId; return await GetFmiCredentialAsync(fmiPath).ConfigureAwait(false); - }) - .Build(); + }); + + PropagateHttpConfig(builder); + return builder.Build(); + } + + /// + /// Propagates HTTP configuration from the blueprint CCA to an internal CCA builder, + /// ensuring that custom HTTP client factories (e.g., proxy settings) and internal + /// HTTP managers (used in tests) are shared with the internal CCAs. + /// + private void PropagateHttpConfig(ConfidentialClientApplicationBuilder builder) + { + var blueprintConfig = _blueprintApplication.ServiceBundle.Config; + + if (blueprintConfig.HttpClientFactory != null) + { + builder.WithHttpClientFactory(blueprintConfig.HttpClientFactory); + } + + if (blueprintConfig.HttpManager != null) + { + builder.WithHttpManager(blueprintConfig.HttpManager); + } } private async Task GetFmiCredentialAsync(string fmiPath) diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/Agentic.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/Agentic.cs index c3cdc0499f..7137656585 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/Agentic.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/Agentic.cs @@ -319,6 +319,230 @@ public async Task AcquireTokenForAgent_WithOid_Test() #endregion + #region Cache Isolation Tests + + /// + /// Verifies that two separate blueprint CCA instances maintain independent caches, + /// and that the internal agent CCAs created by AcquireTokenForAgent are cached and + /// reused within each blueprint (so subsequent agent calls hit the cache). + /// + /// Scenario: + /// CCA1 (blueprint1): makes a non-agent client credential call, then a silent call → cache hit. + /// CCA2 (blueprint2): makes a non-agent client credential call, then a silent call → cache hit; + /// then makes an agent call (UPN) → identity provider; then a second identical agent call → cache hit. + /// Finally, verifies CCA1's agent CCA cache is empty (no bleed from CCA2). + /// + [TestMethod] + public async Task AcquireTokenForAgent_CacheIsolation_Test() + { + X509Certificate2 cert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + + // === CCA1: Non-agent only === + var cca1 = ConfidentialClientApplicationBuilder + .Create(ClientId) + .WithAuthority("https://login.microsoftonline.com/", TenantId) + .WithExperimentalFeatures(true) + .WithCertificate(cert, sendX5C: true) + .Build(); + + // CCA1: First call hits the identity provider + var cca1Result1 = await cca1 + .AcquireTokenForClient([Scope]) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(cca1Result1.AccessToken, "CCA1 first call should return a token"); + Assert.AreEqual(TokenSource.IdentityProvider, cca1Result1.AuthenticationResultMetadata.TokenSource, + "CCA1 first call should come from identity provider"); + + // CCA1: Second call should come from cache + var cca1Result2 = await cca1 + .AcquireTokenForClient([Scope]) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, cca1Result2.AuthenticationResultMetadata.TokenSource, + "CCA1 second call should come from cache"); + + // === CCA2: Non-agent + agent === + var cca2 = ConfidentialClientApplicationBuilder + .Create(ClientId) + .WithAuthority("https://login.microsoftonline.com/", TenantId) + .WithExperimentalFeatures(true) + .WithCertificate(cert, sendX5C: true) + .Build(); + + // CCA2: Non-agent call - should NOT get CCA1's cached token (separate instance, separate cache) + var cca2Result1 = await cca2 + .AcquireTokenForClient([Scope]) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(cca2Result1.AccessToken, "CCA2 first call should return a token"); + Assert.AreEqual(TokenSource.IdentityProvider, cca2Result1.AuthenticationResultMetadata.TokenSource, + "CCA2 first call should come from identity provider (no cache bleed from CCA1)"); + + // CCA2: Non-agent silent call - should come from CCA2's own cache + var cca2Result2 = await cca2 + .AcquireTokenForClient([Scope]) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, cca2Result2.AuthenticationResultMetadata.TokenSource, + "CCA2 second call should come from its own cache"); + + // CCA2: Agent call (first time) - should hit identity provider + var agentId = Client.AgentIdentity.WithUsername(AgentIdentity, UserUpn); + + var agentResult1 = await cca2 + .AcquireTokenForAgent([Scope], agentId) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(agentResult1.AccessToken, "Agent first call should return a token"); + Assert.AreEqual(TokenSource.IdentityProvider, agentResult1.AuthenticationResultMetadata.TokenSource, + "Agent first call should come from identity provider"); + + // CCA2: Agent call (second time, same identity) - should come from the cached internal CCA + var agentResult2 = await cca2 + .AcquireTokenForAgent([Scope], agentId) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, agentResult2.AuthenticationResultMetadata.TokenSource, + "Agent second call should come from cache (internal CCA reuse)"); + + // Verify CCA1 has no agent CCA cache entries (no bleed from CCA2's agent operations) + var cca1Cache = (ConfidentialClientApplication)cca1; + Assert.IsTrue(cca1Cache.AgentCcaCache.IsEmpty, + "CCA1 should have no agent CCA cache entries"); + + // Verify CCA2 has agent CCA cache entries (the internal CCAs were cached) + var cca2Cache = (ConfidentialClientApplication)cca2; + Assert.IsFalse(cca2Cache.AgentCcaCache.IsEmpty, + "CCA2 should have cached agent CCA instances"); + + Trace.WriteLine($"CCA2 agent CCA cache size: {cca2Cache.AgentCcaCache.Count}"); + } + + /// + /// Verifies that WithForceRefresh(true) on AcquireTokenForAgent bypasses the + /// internal AcquireTokenSilent cache check and always hits the identity provider. + /// + [TestMethod] + public async Task AcquireTokenForAgent_ForceRefresh_Test() + { + X509Certificate2 cert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + + var cca = ConfidentialClientApplicationBuilder + .Create(ClientId) + .WithAuthority("https://login.microsoftonline.com/", TenantId) + .WithExperimentalFeatures(true) + .WithCertificate(cert, sendX5C: true) + .Build(); + + var agentId = Client.AgentIdentity.WithUsername(AgentIdentity, UserUpn); + + // First call: hits identity provider and populates cache + var result1 = await cca + .AcquireTokenForAgent([Scope], agentId) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource, + "First call should come from identity provider"); + + // Second call without ForceRefresh: should come from cache + var result2 = await cca + .AcquireTokenForAgent([Scope], agentId) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, result2.AuthenticationResultMetadata.TokenSource, + "Second call should come from cache"); + + // Third call with ForceRefresh: should bypass cache and hit identity provider + var result3 = await cca + .AcquireTokenForAgent([Scope], agentId) + .WithForceRefresh(true) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, result3.AuthenticationResultMetadata.TokenSource, + "ForceRefresh call should bypass cache and come from identity provider"); + } + + /// + /// Verifies that the internal AcquireTokenSilent account-matching logic correctly + /// resolves cached tokens when switching between UPN-based and OID-based AgentIdentity + /// for the same user on the same blueprint CCA. + /// + /// Scenario: + /// 1. AcquireTokenForAgent with UPN → hits identity provider, populates cache. + /// 2. AcquireTokenForAgent with UPN again → cache hit (UPN match). + /// 3. AcquireTokenForAgent with OID (same user) → cache hit (OID match on the same account). + /// + /// This proves that the FindMatchingAccount logic works for both identifier types + /// and that an OID lookup can find a token originally cached via a UPN-based call. + /// + [TestMethod] + public async Task AcquireTokenForAgent_UpnThenOid_SharesCache_Test() + { + X509Certificate2 cert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName); + + var cca = ConfidentialClientApplicationBuilder + .Create(ClientId) + .WithAuthority("https://login.microsoftonline.com/", TenantId) + .WithExperimentalFeatures(true) + .WithCertificate(cert, sendX5C: true) + .Build(); + + // Step 1: UPN-based call → identity provider (populates cache) + var upnIdentity = Client.AgentIdentity.WithUsername(AgentIdentity, UserUpn); + + var upnResult = await cca + .AcquireTokenForAgent([Scope], upnIdentity) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, upnResult.AuthenticationResultMetadata.TokenSource, + "First UPN call should come from identity provider"); + Assert.IsNotNull(upnResult.Account, "Account should not be null"); + + // Extract the OID from the returned account + string oidString = upnResult.Account.HomeAccountId.ObjectId; + Assert.IsNotNull(oidString, "OID should not be null in the account"); + Guid userOid = Guid.Parse(oidString); + + // Step 2: UPN-based call again → cache hit (sanity check) + var upnResult2 = await cca + .AcquireTokenForAgent([Scope], upnIdentity) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, upnResult2.AuthenticationResultMetadata.TokenSource, + "Second UPN call should come from cache"); + + // Step 3: OID-based call for the SAME user → should also be a cache hit + // because FindMatchingAccount matches by HomeAccountId.ObjectId + var oidIdentity = new Client.AgentIdentity(AgentIdentity, userOid); + + var oidResult = await cca + .AcquireTokenForAgent([Scope], oidIdentity) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, oidResult.AuthenticationResultMetadata.TokenSource, + "OID call for the same user should come from cache (OID-based account match)"); + Assert.AreEqual(oidString, oidResult.Account.HomeAccountId.ObjectId, + "OID should match between UPN-cached and OID-retrieved tokens"); + + Trace.WriteLine($"UPN token: {upnResult.AccessToken.Substring(0, 20)}..."); + Trace.WriteLine($"OID token: {oidResult.AccessToken.Substring(0, 20)}..."); + } + + #endregion + #region Shared Helpers private static async Task GetAppCredentialAsync(string fmiPath) diff --git a/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs b/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs index 036afe33f0..9c2d627dde 100644 --- a/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Threading.Tasks; using Microsoft.Identity.Client; @@ -175,5 +176,162 @@ public void AcquireTokenByUserFic_EmptyAssertion_ThrowsArgumentNullException() username: FakeUsername, assertion: string.Empty)); } + + #region Multi-User Cache Tests (Low-Level API) + + /// + /// Adds a mock handler for a UserFIC call with a specific username and a token response + /// that returns a distinct user identity (OID and preferred_username). + /// + private static void AddMockHandlerForUserFicWithIdentity( + MockHttpManager httpManager, + string username, + string userOid, + string accessToken, + string authority = TestConstants.AuthorityCommonTenant) + { + httpManager.AddMockHandler(new MockHttpMessageHandler + { + ExpectedUrl = authority + "oauth2/v2.0/token", + ExpectedMethod = HttpMethod.Post, + ExpectedPostData = new Dictionary + { + { OAuth2Parameter.GrantType, OAuth2GrantType.UserFic }, + { OAuth2Parameter.Username, username }, + { OAuth2Parameter.UserFederatedIdentityCredential, FakeAssertion } + }, + ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage( + userOid, + username, + TestConstants.s_scope.ToArray(), + accessToken: accessToken) + }); + } + + /// + /// Verifies that when two different users (by UPN) acquire tokens via UserFIC on the same CCA, + /// AcquireTokenSilent returns the correct cached token for each user and does not cross-contaminate. + /// + [TestMethod] + public async Task AcquireTokenByUserFic_TwoUpns_SilentReturnsCorrectToken_Async() + { + const string User1Upn = "alice@contoso.com"; + const string User1Oid = "oid-alice-1111"; + const string User1Token = "access-token-alice"; + + const string User2Upn = "bob@contoso.com"; + const string User2Oid = "oid-bob-2222"; + const string User2Token = "access-token-bob"; + + using var httpManager = new MockHttpManager(); + httpManager.AddInstanceDiscoveryMockHandler(); + + var app = BuildCCA(httpManager); + + // Acquire token for User 1 (Alice) via UserFIC + AddMockHandlerForUserFicWithIdentity(httpManager, User1Upn, User1Oid, User1Token); + + var result1 = await (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential(TestConstants.s_scope, User1Upn, FakeAssertion) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(User1Token, result1.AccessToken); + Assert.AreEqual(User1Upn, result1.Account.Username); + + // Acquire token for User 2 (Bob) via UserFIC + AddMockHandlerForUserFicWithIdentity(httpManager, User2Upn, User2Oid, User2Token); + + var result2 = await (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential(TestConstants.s_scope, User2Upn, FakeAssertion) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(User2Token, result2.AccessToken); + Assert.AreEqual(User2Upn, result2.Account.Username); + + // Both accounts should be in the cache + var accounts = await app.GetAccountsAsync().ConfigureAwait(false); + Assert.AreEqual(2, accounts.Count(), "Two accounts should be cached"); + + // AcquireTokenSilent for User 1 → should return Alice's token, NOT Bob's + var account1 = accounts.First(a => string.Equals(a.Username, User1Upn, StringComparison.OrdinalIgnoreCase)); + var silent1 = await app.AcquireTokenSilent(TestConstants.s_scope, account1).ExecuteAsync().ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, silent1.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(User1Token, silent1.AccessToken, "Silent call for Alice should return Alice's token"); + + // AcquireTokenSilent for User 2 → should return Bob's token, NOT Alice's + var account2 = accounts.First(a => string.Equals(a.Username, User2Upn, StringComparison.OrdinalIgnoreCase)); + var silent2 = await app.AcquireTokenSilent(TestConstants.s_scope, account2).ExecuteAsync().ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, silent2.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(User2Token, silent2.AccessToken, "Silent call for Bob should return Bob's token"); + } + + /// + /// Verifies that when two different users (by OID) acquire tokens via UserFIC on the same CCA, + /// AcquireTokenSilent resolves the correct account by OID and returns the correct cached token. + /// + [TestMethod] + public async Task AcquireTokenByUserFic_TwoOids_SilentReturnsCorrectToken_Async() + { + const string User1Upn = "carol@contoso.com"; + const string User1Oid = "oid-carol-3333"; + const string User1Token = "access-token-carol"; + + const string User2Upn = "dave@contoso.com"; + const string User2Oid = "oid-dave-4444"; + const string User2Token = "access-token-dave"; + + using var httpManager = new MockHttpManager(); + httpManager.AddInstanceDiscoveryMockHandler(); + + var app = BuildCCA(httpManager); + + // Acquire token for User 1 (Carol) via UserFIC using UPN + AddMockHandlerForUserFicWithIdentity(httpManager, User1Upn, User1Oid, User1Token); + + var result1 = await (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential(TestConstants.s_scope, User1Upn, FakeAssertion) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(User1Token, result1.AccessToken); + + // Acquire token for User 2 (Dave) via UserFIC using UPN + AddMockHandlerForUserFicWithIdentity(httpManager, User2Upn, User2Oid, User2Token); + + var result2 = await (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential(TestConstants.s_scope, User2Upn, FakeAssertion) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(User2Token, result2.AccessToken); + + // Now retrieve by OID — find the correct account using HomeAccountId.ObjectId + var accounts = await app.GetAccountsAsync().ConfigureAwait(false); + Assert.AreEqual(2, accounts.Count(), "Two accounts should be cached"); + + // Lookup by OID for Carol + var carolAccount = accounts.First(a => + string.Equals(a.HomeAccountId.ObjectId, User1Oid, StringComparison.OrdinalIgnoreCase)); + var silentCarol = await app.AcquireTokenSilent(TestConstants.s_scope, carolAccount).ExecuteAsync().ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, silentCarol.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(User1Token, silentCarol.AccessToken, "OID-based lookup for Carol should return Carol's token"); + + // Lookup by OID for Dave + var daveAccount = accounts.First(a => + string.Equals(a.HomeAccountId.ObjectId, User2Oid, StringComparison.OrdinalIgnoreCase)); + var silentDave = await app.AcquireTokenSilent(TestConstants.s_scope, daveAccount).ExecuteAsync().ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, silentDave.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(User2Token, silentDave.AccessToken, "OID-based lookup for Dave should return Dave's token"); + } + + #endregion } } From 94628808f32ec3b7932777bfd7cb445a3a26584c Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 24 Mar 2026 15:09:07 -0700 Subject: [PATCH 3/6] Improve unit test coverage of caching behavior --- .../UserFederatedIdentityCredentialTests.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs b/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs index 9c2d627dde..cf826348b4 100644 --- a/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs @@ -333,5 +333,102 @@ public async Task AcquireTokenByUserFic_TwoOids_SilentReturnsCorrectToken_Async( } #endregion + + #region High-Level AcquireTokenForAgent Multi-User Tests + + /// + /// Verifies that two calls to AcquireTokenForAgent for different users produce correct tokens, + /// and that a subsequent call for the first user returns a cached token (via AcquireTokenSilent + /// inside AgentTokenRequest) without any additional HTTP calls. + /// + [TestMethod] + public async Task AcquireTokenForAgent_TwoUpns_CacheReturnsCorrectUserToken_Async() + { + // Arrange + const string AgentAppId = "00000000-0000-0000-0000-000000001234"; + const string User1Upn = "alice@contoso.com"; + const string User1Oid = "oid-alice-1111"; + const string User1Token = "access-token-alice"; + + const string User2Upn = "bob@contoso.com"; + const string User2Oid = "oid-bob-2222"; + const string User2Token = "access-token-bob"; + + using var httpManager = new MockHttpManager(); + httpManager.AddInstanceDiscoveryMockHandler(); + + var blueprintCca = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithClientSecret(TestConstants.ClientSecret) + .WithAuthority(TestConstants.AuthorityCommonTenant) + .WithExperimentalFeatures(true) + .WithHttpManager(httpManager) + .BuildConcrete(); + + // --- User 1 (Alice): 3 HTTP calls --- + // Leg 1: Blueprint AcquireTokenForClient (FMI credential, consumed by assertion CCA's client assertion callback) + httpManager.AddMockHandler(new MockHttpMessageHandler + { + ExpectedMethod = HttpMethod.Post, + ResponseMessage = MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage(token: "fmi-credential-token") + }); + + // Leg 2: Assertion CCA AcquireTokenForClient (assertion token) + httpManager.AddMockHandler(new MockHttpMessageHandler + { + ExpectedMethod = HttpMethod.Post, + ResponseMessage = MockHelpers.CreateSuccessfulClientCredentialTokenResponseMessage(token: "assertion-token") + }); + + // Leg 3: Agent CCA AcquireTokenByUserFIC (user token for Alice) + httpManager.AddMockHandler(new MockHttpMessageHandler + { + ExpectedMethod = HttpMethod.Post, + ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage( + User1Oid, User1Upn, TestConstants.s_scope.ToArray(), accessToken: User1Token) + }); + + // Act: AcquireTokenForAgent for User 1 + var agentId1 = AgentIdentity.WithUsername(AgentAppId, User1Upn); + var result1 = await blueprintCca + .AcquireTokenForAgent(TestConstants.s_scope, agentId1) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert: User 1 token from IdP + Assert.AreEqual(User1Token, result1.AccessToken); + Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource); + + // --- User 2 (Bob): only 1 HTTP call (FMI cred + assertion token cached) --- + httpManager.AddMockHandler(new MockHttpMessageHandler + { + ExpectedMethod = HttpMethod.Post, + ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage( + User2Oid, User2Upn, TestConstants.s_scope.ToArray(), accessToken: User2Token) + }); + + // Act: AcquireTokenForAgent for User 2 + var agentId2 = AgentIdentity.WithUsername(AgentAppId, User2Upn); + var result2 = await blueprintCca + .AcquireTokenForAgent(TestConstants.s_scope, agentId2) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert: User 2 token from IdP + Assert.AreEqual(User2Token, result2.AccessToken); + Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource); + + // --- User 1 again: should come from cache (no HTTP calls) --- + var result1Again = await blueprintCca + .AcquireTokenForAgent(TestConstants.s_scope, agentId1) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert: User 1 token from cache + Assert.AreEqual(TokenSource.Cache, result1Again.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(User1Token, result1Again.AccessToken); + } + + #endregion } } From a1c2246e3d8f7a18d4897ee9e4a3cefb91276c6d Mon Sep 17 00:00:00 2001 From: avdunn Date: Wed, 25 Mar 2026 09:31:46 -0700 Subject: [PATCH 4/6] Simplify internal CCA behavior and improve readability --- .../Internal/Requests/AgentTokenRequest.cs | 137 ++++++++++++------ 1 file changed, 89 insertions(+), 48 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs index 0871278bf1..2d6f371d75 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs @@ -11,9 +11,36 @@ namespace Microsoft.Identity.Client.Internal.Requests { + /// + /// Orchestrates a multi-leg token acquisition for agent scenarios. + /// + /// Two CCA instances are involved: + /// + /// 1. Blueprint CCA — the developer-created CCA that holds the real credential (certificate, secret, etc.). + /// It only participates in Leg 1: acquiring an FMI credential via AcquireTokenForClient + WithFmiPath. + /// Its app token cache stores the FMI credential. + /// + /// 2. Agent CCA — an internal CCA keyed by the agent's app ID, created and cached by this class. + /// Its client assertion callback delegates to the Blueprint for FMI credentials (Leg 1). + /// It handles both Leg 2 (AcquireTokenForClient for the assertion token, stored in its app token cache) + /// and Leg 3 (AcquireTokenByUserFederatedIdentityCredential for the user token, stored in its user token cache). + /// + /// Caching behavior: + /// - The Agent CCA instance is persisted in + /// so that subsequent calls for the same agent reuse its in-memory token caches. + /// - On each call, the agent CCA's user token cache is checked first via AcquireTokenSilent. + /// If a cached user token is found, it is returned immediately without executing Legs 2-3. + /// - ForceRefresh skips this silent check, but the Leg 1 (FMI credential) and Leg 2 (assertion token) + /// caches are still honored — only the final user token (Leg 3) is re-acquired from the network. + /// internal class AgentTokenRequest : RequestBase { private readonly AcquireTokenForAgentParameters _agentParameters; + + /// + /// The developer-created CCA that holds the real credential. Used only to acquire + /// FMI credentials (Leg 1) and to store/retrieve the internal Agent CCA instances. + /// private readonly ConfidentialClientApplication _blueprintApplication; public AgentTokenRequest( @@ -35,27 +62,28 @@ protected override async Task ExecuteAsync(CancellationTok string agentAppId = agentIdentity.AgentApplicationId; string authority = AuthenticationRequestParameters.Authority.AuthorityInfo.CanonicalAuthority.ToString(); + // Retrieve (or create) the internal Agent CCA for this agent app ID. + // This CCA is persisted across calls so its app and user token caches are retained. + var agentCca = GetOrCreateAgentCca(agentAppId, authority); + if (!agentIdentity.HasUserIdentifier) { - // App-only flow: get a client credential token for the agent. - // AcquireTokenForClient has built-in cache-first logic, so CCA persistence - // is sufficient — no explicit silent call needed. - var agentCca = GetOrCreateAgentCca(agentAppId, authority); - + // App-only flow: AcquireTokenForClient has built-in cache-first logic, + // so no explicit silent pre-check is needed. return await agentCca .AcquireTokenForClient(AuthenticationRequestParameters.Scope) .ExecuteAsync(cancellationToken) .ConfigureAwait(false); } - // User identity flow - var mainCca = GetOrCreateAgentCca(agentAppId, authority); + // --- User identity flow --- - // Try cache first via AcquireTokenSilent (unless ForceRefresh is set) + // Check the Agent CCA's user token cache for a previously-acquired token for this user. + // ForceRefresh skips this check so a fresh user token is always obtained from the network. if (!_agentParameters.ForceRefresh) { - var cachedResult = await TryAcquireTokenSilentAsync( - mainCca, + var cachedResult = await TryAcquireTokenSilentFromAgentCacheAsync( + agentCca, agentIdentity, AuthenticationRequestParameters.Scope, cancellationToken).ConfigureAwait(false); @@ -65,12 +93,13 @@ protected override async Task ExecuteAsync(CancellationTok } } - // Cache miss or ForceRefresh — execute the full Leg 2 + Leg 3 flow - - // Step 1: Get assertion token via FMI path - var assertionApp = GetOrCreateAssertionCca(agentAppId, authority); + // Cache miss (or ForceRefresh) — execute Leg 2 + Leg 3. - var assertionResult = await assertionApp + // Leg 2: Acquire an assertion token from the Agent CCA's app token cache (or network). + // This is a client credential call scoped to api://AzureADTokenExchange/.default. + // The Agent CCA's assertion callback will invoke Leg 1 (GetFmiCredentialFromBlueprintAsync) + // to authenticate itself, but AcquireTokenForClient's built-in cache handles repeat calls. + var assertionResult = await agentCca .AcquireTokenForClient(new[] { TokenExchangeScope }) .WithFmiPathForClientAssertion(agentAppId) .ExecuteAsync(cancellationToken) @@ -78,10 +107,12 @@ protected override async Task ExecuteAsync(CancellationTok string assertion = assertionResult.AccessToken; - // Step 2: Exchange assertion for user token via UserFIC + // Leg 3: Exchange the assertion for a user-scoped token via UserFIC. + // This is always a network call (acquisition flow, like auth code). + // The result is written to the Agent CCA's user token cache for future silent retrieval. if (agentIdentity.UserObjectId.HasValue) { - return await ((IByUserFederatedIdentityCredential)mainCca) + return await ((IByUserFederatedIdentityCredential)agentCca) .AcquireTokenByUserFederatedIdentityCredential( AuthenticationRequestParameters.Scope, agentIdentity.UserObjectId.Value, @@ -90,7 +121,7 @@ protected override async Task ExecuteAsync(CancellationTok .ConfigureAwait(false); } - return await ((IByUserFederatedIdentityCredential)mainCca) + return await ((IByUserFederatedIdentityCredential)agentCca) .AcquireTokenByUserFederatedIdentityCredential( AuthenticationRequestParameters.Scope, agentIdentity.Username, @@ -100,16 +131,17 @@ protected override async Task ExecuteAsync(CancellationTok } /// - /// Attempts to find a cached token for the specified user on the agent CCA. - /// Returns null if no matching account is found or the silent call fails. + /// Searches the Agent CCA's user token cache for a previously-acquired token + /// matching the specified user identity (by OID or UPN). + /// Returns null if no matching account exists or the cached token is expired. /// - private static async Task TryAcquireTokenSilentAsync( + private static async Task TryAcquireTokenSilentFromAgentCacheAsync( IConfidentialClientApplication agentCca, AgentIdentity agentIdentity, IEnumerable scopes, CancellationToken cancellationToken) { -#pragma warning disable CS0618 // GetAccountsAsync is obsolete for external callers but needed here to enumerate cached accounts +#pragma warning disable CS0618 // GetAccountsAsync is marked obsolete for external callers, but we need it here to enumerate cached accounts on the internal Agent CCA var accounts = await agentCca.GetAccountsAsync().ConfigureAwait(false); #pragma warning restore CS0618 @@ -128,13 +160,15 @@ private static async Task TryAcquireTokenSilentAsync( } catch (MsalUiRequiredException) { - // Token expired or requires interaction — fall through to full flow + // Token expired or requires interaction — fall through to full Leg 2 + Leg 3 flow return null; } } /// - /// Finds an account in the cache that matches the agent identity by OID or UPN. + /// Finds an account in the Agent CCA's cache that matches the user identity. + /// Matches by OID (HomeAccountId.ObjectId) if the caller specified a Guid, + /// otherwise by UPN (Account.Username). Both comparisons are case-insensitive. /// private static IAccount FindMatchingAccount(IEnumerable accounts, AgentIdentity agentIdentity) { @@ -151,39 +185,35 @@ private static IAccount FindMatchingAccount(IEnumerable accounts, Agen protected override KeyValuePair? GetCcsHeader(IDictionary additionalBodyParameters) { - // CCS headers are handled by the internal CCAs' own request handlers. + // CCS headers are handled by the internal Agent CCA's own request handlers. return null; } + #region Agent CCA Construction and Configuration + private const string TokenExchangeScope = "api://AzureADTokenExchange/.default"; private const string AgentCcaKeyPrefix = "agent_"; - private const string AssertionCcaKeyPrefix = "assertion_"; + /// + /// Retrieves the cached internal Agent CCA for the given agent app ID, or creates one + /// if this is the first call. The Agent CCA is stored in the Blueprint's AgentCcaCache + /// so its app and user token caches persist across calls. + /// private IConfidentialClientApplication GetOrCreateAgentCca(string agentAppId, string authority) { string key = AgentCcaKeyPrefix + agentAppId; return _blueprintApplication.AgentCcaCache.GetOrAdd(key, _ => BuildAgentCca(agentAppId, authority)); } - private IConfidentialClientApplication GetOrCreateAssertionCca(string agentAppId, string authority) - { - string key = AssertionCcaKeyPrefix + agentAppId; - return _blueprintApplication.AgentCcaCache.GetOrAdd(key, _ => BuildAssertionApp(agentAppId, authority)); - } - + /// + /// Builds a new internal Agent CCA configured with: + /// - Client ID = the agent's app ID + /// - Authority = the Blueprint's resolved authority + /// - Client assertion callback = delegates to + /// to get an FMI credential from the Blueprint (Leg 1) + /// - HTTP config = propagated from the Blueprint (custom HttpClientFactory, test HttpManager) + /// private IConfidentialClientApplication BuildAgentCca(string agentAppId, string authority) - { - var builder = ConfidentialClientApplicationBuilder - .Create(agentAppId) - .WithAuthority(authority) - .WithExperimentalFeatures(true) - .WithClientAssertion((AssertionRequestOptions _) => GetFmiCredentialAsync(agentAppId)); - - PropagateHttpConfig(builder); - return builder.Build(); - } - - private IConfidentialClientApplication BuildAssertionApp(string agentAppId, string authority) { var builder = ConfidentialClientApplicationBuilder .Create(agentAppId) @@ -191,8 +221,11 @@ private IConfidentialClientApplication BuildAssertionApp(string agentAppId, stri .WithExperimentalFeatures(true) .WithClientAssertion(async (AssertionRequestOptions opts) => { + // When called from AcquireTokenForClient + WithFmiPathForClientAssertion (Leg 2), + // opts.ClientAssertionFmiPath is set to the agent app ID. + // When called from AcquireTokenByUserFIC (Leg 3), it falls back to agentAppId. string fmiPath = opts.ClientAssertionFmiPath ?? agentAppId; - return await GetFmiCredentialAsync(fmiPath).ConfigureAwait(false); + return await GetFmiCredentialFromBlueprintAsync(fmiPath).ConfigureAwait(false); }); PropagateHttpConfig(builder); @@ -200,9 +233,9 @@ private IConfidentialClientApplication BuildAssertionApp(string agentAppId, stri } /// - /// Propagates HTTP configuration from the blueprint CCA to an internal CCA builder, + /// Propagates HTTP configuration from the Blueprint CCA to an internal Agent CCA builder, /// ensuring that custom HTTP client factories (e.g., proxy settings) and internal - /// HTTP managers (used in tests) are shared with the internal CCAs. + /// HTTP managers (used in tests) are shared with the Agent CCA. /// private void PropagateHttpConfig(ConfidentialClientApplicationBuilder builder) { @@ -219,7 +252,13 @@ private void PropagateHttpConfig(ConfidentialClientApplicationBuilder builder) } } - private async Task GetFmiCredentialAsync(string fmiPath) + /// + /// Leg 1: Acquires an FMI credential from the Blueprint CCA. + /// Uses AcquireTokenForClient with WithFmiPath, which has built-in cache-first logic — + /// only the first call hits the network; subsequent calls return the cached FMI credential + /// from the Blueprint's app token cache. + /// + private async Task GetFmiCredentialFromBlueprintAsync(string fmiPath) { var result = await _blueprintApplication .AcquireTokenForClient(new[] { TokenExchangeScope }) @@ -229,5 +268,7 @@ private async Task GetFmiCredentialAsync(string fmiPath) return result.AccessToken; } + + #endregion } } From d67bd288ac80f64a2bd5e0f4528d0e0ff6d3e85d Mon Sep 17 00:00:00 2001 From: avdunn Date: Wed, 25 Mar 2026 13:45:18 -0700 Subject: [PATCH 5/6] Propagate app-level and request-level parameters --- .../Internal/Requests/AgentTokenRequest.cs | 114 +++++++++++++----- 1 file changed, 87 insertions(+), 27 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs index 2d6f371d75..713b5081a1 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Microsoft.Identity.Client.ApiConfig.Parameters; using Microsoft.Identity.Client.Extensibility; +using Microsoft.Identity.Client.Instance; namespace Microsoft.Identity.Client.Internal.Requests { @@ -70,8 +71,8 @@ protected override async Task ExecuteAsync(CancellationTok { // App-only flow: AcquireTokenForClient has built-in cache-first logic, // so no explicit silent pre-check is needed. - return await agentCca - .AcquireTokenForClient(AuthenticationRequestParameters.Scope) + return await PropagateOuterRequestParameters( + agentCca.AcquireTokenForClient(AuthenticationRequestParameters.Scope)) .ExecuteAsync(cancellationToken) .ConfigureAwait(false); } @@ -99,8 +100,8 @@ protected override async Task ExecuteAsync(CancellationTok // This is a client credential call scoped to api://AzureADTokenExchange/.default. // The Agent CCA's assertion callback will invoke Leg 1 (GetFmiCredentialFromBlueprintAsync) // to authenticate itself, but AcquireTokenForClient's built-in cache handles repeat calls. - var assertionResult = await agentCca - .AcquireTokenForClient(new[] { TokenExchangeScope }) + var assertionResult = await PropagateOuterRequestParameters( + agentCca.AcquireTokenForClient(new[] { TokenExchangeScope })) .WithFmiPathForClientAssertion(agentAppId) .ExecuteAsync(cancellationToken) .ConfigureAwait(false); @@ -112,20 +113,22 @@ protected override async Task ExecuteAsync(CancellationTok // The result is written to the Agent CCA's user token cache for future silent retrieval. if (agentIdentity.UserObjectId.HasValue) { - return await ((IByUserFederatedIdentityCredential)agentCca) - .AcquireTokenByUserFederatedIdentityCredential( - AuthenticationRequestParameters.Scope, - agentIdentity.UserObjectId.Value, - assertion) + return await PropagateOuterRequestParameters( + ((IByUserFederatedIdentityCredential)agentCca) + .AcquireTokenByUserFederatedIdentityCredential( + AuthenticationRequestParameters.Scope, + agentIdentity.UserObjectId.Value, + assertion)) .ExecuteAsync(cancellationToken) .ConfigureAwait(false); } - return await ((IByUserFederatedIdentityCredential)agentCca) - .AcquireTokenByUserFederatedIdentityCredential( - AuthenticationRequestParameters.Scope, - agentIdentity.Username, - assertion) + return await PropagateOuterRequestParameters( + ((IByUserFederatedIdentityCredential)agentCca) + .AcquireTokenByUserFederatedIdentityCredential( + AuthenticationRequestParameters.Scope, + agentIdentity.Username, + assertion)) .ExecuteAsync(cancellationToken) .ConfigureAwait(false); } @@ -135,7 +138,7 @@ protected override async Task ExecuteAsync(CancellationTok /// matching the specified user identity (by OID or UPN). /// Returns null if no matching account exists or the cached token is expired. /// - private static async Task TryAcquireTokenSilentFromAgentCacheAsync( + private async Task TryAcquireTokenSilentFromAgentCacheAsync( IConfidentialClientApplication agentCca, AgentIdentity agentIdentity, IEnumerable scopes, @@ -153,8 +156,8 @@ private static async Task TryAcquireTokenSilentFromAgentCa try { - return await agentCca - .AcquireTokenSilent(scopes, matchedAccount) + return await PropagateOuterRequestParameters( + agentCca.AcquireTokenSilent(scopes, matchedAccount)) .ExecuteAsync(cancellationToken) .ConfigureAwait(false); } @@ -211,7 +214,7 @@ private IConfidentialClientApplication GetOrCreateAgentCca(string agentAppId, st /// - Authority = the Blueprint's resolved authority /// - Client assertion callback = delegates to /// to get an FMI credential from the Blueprint (Leg 1) - /// - HTTP config = propagated from the Blueprint (custom HttpClientFactory, test HttpManager) + /// - App-level config = propagated from the Blueprint via /// private IConfidentialClientApplication BuildAgentCca(string agentAppId, string authority) { @@ -228,28 +231,85 @@ private IConfidentialClientApplication BuildAgentCca(string agentAppId, string a return await GetFmiCredentialFromBlueprintAsync(fmiPath).ConfigureAwait(false); }); - PropagateHttpConfig(builder); + PropagateBlueprintConfig(builder); return builder.Build(); } /// - /// Propagates HTTP configuration from the Blueprint CCA to an internal Agent CCA builder, - /// ensuring that custom HTTP client factories (e.g., proxy settings) and internal - /// HTTP managers (used in tests) are shared with the Agent CCA. + /// Propagates app-level configuration from the Blueprint CCA to the Agent CCA builder. + /// Copies properties directly on the builder's internal Config to avoid awkward builder + /// API constraints (e.g., WithLogging throwing if called twice, InstanceDiscoveryResponse + /// requiring JSON round-tripping). This ensures the Agent CCA shares the Blueprint's + /// HTTP behavior, logging, telemetry identity, and instance discovery settings. + /// + /// ExtraQueryParameters and ClientCapabilities are NOT propagated here because + /// they are already merged into the per-request AuthenticationRequestParameters + /// and propagated by . /// - private void PropagateHttpConfig(ConfidentialClientApplicationBuilder builder) + private void PropagateBlueprintConfig(ConfidentialClientApplicationBuilder builder) { var blueprintConfig = _blueprintApplication.ServiceBundle.Config; + var agentConfig = builder.Config; + + // HTTP: factory, retry policy, and internal test HttpManager + agentConfig.HttpClientFactory = blueprintConfig.HttpClientFactory; + agentConfig.DisableInternalRetries = blueprintConfig.DisableInternalRetries; + agentConfig.HttpManager = blueprintConfig.HttpManager; + + // Logging: copy whichever logger the Blueprint uses (IdentityLogger or LoggingCallback) + agentConfig.IdentityLogger = blueprintConfig.IdentityLogger; + agentConfig.LoggingCallback = blueprintConfig.LoggingCallback; + agentConfig.LogLevel = blueprintConfig.LogLevel; + agentConfig.EnablePiiLogging = blueprintConfig.EnablePiiLogging; + agentConfig.IsDefaultPlatformLoggingEnabled = blueprintConfig.IsDefaultPlatformLoggingEnabled; + + // Telemetry: attribute network calls to the same caller + agentConfig.ClientName = blueprintConfig.ClientName; + agentConfig.ClientVersion = blueprintConfig.ClientVersion; + + // Instance discovery: honor the Blueprint's custom metadata or disabled discovery + agentConfig.CustomInstanceDiscoveryMetadata = blueprintConfig.CustomInstanceDiscoveryMetadata; + agentConfig.CustomInstanceDiscoveryMetadataUri = blueprintConfig.CustomInstanceDiscoveryMetadataUri; + agentConfig.IsInstanceDiscoveryEnabled = blueprintConfig.IsInstanceDiscoveryEnabled; + } + + /// + /// Propagates per-request parameters from the outer AcquireTokenForAgent call to an inner + /// token request builder (Leg 2, Leg 3, or Silent). This ensures that caller-specified + /// correlation IDs, claims challenges, tenant overrides, and extra query parameters + /// flow through to the Agent CCA's network calls. + /// + private T PropagateOuterRequestParameters(T builder) + where T : AbstractAcquireTokenParameterBuilder + { + var outerParams = AuthenticationRequestParameters; + + // Correlation ID: chain inner calls to the same trace + builder.WithCorrelationId(outerParams.CorrelationId); + + // Claims: propagate merged claims + client capabilities so the inner request + // includes any conditional access challenge from the caller + if (!string.IsNullOrEmpty(outerParams.ClaimsAndClientCapabilities)) + { + builder.WithClaims(outerParams.ClaimsAndClientCapabilities); + } - if (blueprintConfig.HttpClientFactory != null) + // Tenant override: if the caller used .WithTenantId() on AcquireTokenForAgent, + // apply the same override to the inner call + if (outerParams.AuthorityOverride != null) { - builder.WithHttpClientFactory(blueprintConfig.HttpClientFactory); + var overrideAuthority = Authority.CreateAuthority(outerParams.AuthorityOverride); + builder.WithTenantId(overrideAuthority.TenantId); } - if (blueprintConfig.HttpManager != null) + // Extra query parameters: already merged (app-level + request-level) in outerParams + if (outerParams.ExtraQueryParameters != null && outerParams.ExtraQueryParameters.Count > 0) { - builder.WithHttpManager(blueprintConfig.HttpManager); + builder.CommonParameters.ExtraQueryParameters = + new Dictionary(outerParams.ExtraQueryParameters, StringComparer.OrdinalIgnoreCase); } + + return builder; } /// From ed0ceb7c3ab7660463292c3a2d8cac09e0d4aaf6 Mon Sep 17 00:00:00 2001 From: avdunn Date: Thu, 26 Mar 2026 10:14:47 -0700 Subject: [PATCH 6/6] Various PR feedback items --- ...erFederatedIdentityCredentialParameters.cs | 11 ++ .../Internal/Requests/AgentTokenRequest.cs | 43 +++--- .../UserFederatedIdentityCredentialTests.cs | 137 ++++++++++++++++++ 3 files changed, 168 insertions(+), 23 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs index f81182eb70..e01fded030 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Text; using Microsoft.Identity.Client.Core; namespace Microsoft.Identity.Client.ApiConfig.Parameters @@ -17,6 +18,16 @@ internal class AcquireTokenByUserFederatedIdentityCredentialParameters : IAcquir /// public void LogParameters(ILoggerAdapter logger) { + if (logger.IsLoggingEnabled(LogLevel.Info)) + { + var builder = new StringBuilder(); + builder.AppendLine("=== AcquireTokenByUserFederatedIdentityCredentialParameters ==="); + builder.AppendLine("SendX5C: " + SendX5C); + builder.AppendLine("ForceRefresh: " + ForceRefresh); + builder.AppendLine("UserIdentifiedByOid: " + UserObjectId.HasValue); + builder.AppendLine("Assertion set: " + !string.IsNullOrEmpty(Assertion)); + logger.Info(builder.ToString()); + } } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs index 713b5081a1..042c981c7e 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs @@ -212,23 +212,37 @@ private IConfidentialClientApplication GetOrCreateAgentCca(string agentAppId, st /// Builds a new internal Agent CCA configured with: /// - Client ID = the agent's app ID /// - Authority = the Blueprint's resolved authority - /// - Client assertion callback = delegates to - /// to get an FMI credential from the Blueprint (Leg 1) + /// - Client assertion callback = Leg 1 (FMI credential from Blueprint) /// - App-level config = propagated from the Blueprint via /// private IConfidentialClientApplication BuildAgentCca(string agentAppId, string authority) { + // The assertion callback lambda is stored inside the Agent CCA, which lives for the + // lifetime of the Blueprint CCA (persisted in AgentCcaCache). Only long-lived objects + // should be captured — specifically the Blueprint CCA reference and the agent app ID. + // Capturing 'this' (the per-request AgentTokenRequest) would pin per-request state + // (AuthenticationRequestParameters, RequestContext, CancellationToken, etc.) in memory + // indefinitely and risk using stale request data on future assertion callback invocations. + var blueprint = _blueprintApplication; + var builder = ConfidentialClientApplicationBuilder .Create(agentAppId) .WithAuthority(authority) .WithExperimentalFeatures(true) .WithClientAssertion(async (AssertionRequestOptions opts) => { - // When called from AcquireTokenForClient + WithFmiPathForClientAssertion (Leg 2), - // opts.ClientAssertionFmiPath is set to the agent app ID. - // When called from AcquireTokenByUserFIC (Leg 3), it falls back to agentAppId. + // Leg 1: Acquire an FMI credential from the Blueprint CCA. + // AcquireTokenForClient has built-in cache-first logic — only the first call + // hits the network; subsequent calls return the cached FMI credential. string fmiPath = opts.ClientAssertionFmiPath ?? agentAppId; - return await GetFmiCredentialFromBlueprintAsync(fmiPath).ConfigureAwait(false); + + var result = await blueprint + .AcquireTokenForClient(new[] { TokenExchangeScope }) + .WithFmiPath(fmiPath) + .ExecuteAsync() + .ConfigureAwait(false); + + return result.AccessToken; }); PropagateBlueprintConfig(builder); @@ -312,23 +326,6 @@ private T PropagateOuterRequestParameters(T builder) return builder; } - /// - /// Leg 1: Acquires an FMI credential from the Blueprint CCA. - /// Uses AcquireTokenForClient with WithFmiPath, which has built-in cache-first logic — - /// only the first call hits the network; subsequent calls return the cached FMI credential - /// from the Blueprint's app token cache. - /// - private async Task GetFmiCredentialFromBlueprintAsync(string fmiPath) - { - var result = await _blueprintApplication - .AcquireTokenForClient(new[] { TokenExchangeScope }) - .WithFmiPath(fmiPath) - .ExecuteAsync() - .ConfigureAwait(false); - - return result.AccessToken; - } - #endregion } } diff --git a/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs b/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs index cf826348b4..593e31e73f 100644 --- a/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using Microsoft.Identity.Client; using Microsoft.Identity.Client.OAuth2; @@ -177,6 +178,102 @@ public void AcquireTokenByUserFic_EmptyAssertion_ThrowsArgumentNullException() assertion: string.Empty)); } + #region OID-Based UserFIC Tests + + private static readonly Guid FakeUserOid = new Guid("11111111-2222-3333-4444-555555555555"); + + /// + /// Verifies that when the Guid overload of AcquireTokenByUserFederatedIdentityCredential is used, + /// the token request sends "user_id" (OID) instead of "username" (UPN) in the POST body. + /// + [TestMethod] + public async Task AcquireTokenByUserFic_WithOid_SendsUserIdParameter_Async() + { + // Arrange + using var httpManager = new MockHttpManager(); + httpManager.AddInstanceDiscoveryMockHandler(); + + httpManager.AddMockHandler(new MockHttpMessageHandler + { + ExpectedUrl = TestConstants.AuthorityCommonTenant + "oauth2/v2.0/token", + ExpectedMethod = HttpMethod.Post, + ExpectedPostData = new Dictionary + { + { OAuth2Parameter.GrantType, OAuth2GrantType.UserFic }, + { OAuth2Parameter.UserId, FakeUserOid.ToString("D") }, + { OAuth2Parameter.UserFederatedIdentityCredential, FakeAssertion } + }, + // Verify that "username" is NOT sent when using the OID overload + UnExpectedPostData = new Dictionary + { + { OAuth2Parameter.Username, "" } + }, + ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage() + }); + + var app = BuildCCA(httpManager); + + // Act + var result = await (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential( + TestConstants.s_scope, + FakeUserOid, + FakeAssertion) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + } + + /// + /// Verifies that the UPN overload sends "username" and NOT "user_id" in the POST body. + /// This is the inverse of the OID test and ensures the two paths are mutually exclusive. + /// + [TestMethod] + public async Task AcquireTokenByUserFic_WithUpn_SendsUsernameParameter_Async() + { + // Arrange + using var httpManager = new MockHttpManager(); + httpManager.AddInstanceDiscoveryMockHandler(); + + httpManager.AddMockHandler(new MockHttpMessageHandler + { + ExpectedUrl = TestConstants.AuthorityCommonTenant + "oauth2/v2.0/token", + ExpectedMethod = HttpMethod.Post, + ExpectedPostData = new Dictionary + { + { OAuth2Parameter.GrantType, OAuth2GrantType.UserFic }, + { OAuth2Parameter.Username, FakeUsername }, + { OAuth2Parameter.UserFederatedIdentityCredential, FakeAssertion } + }, + // Verify that "user_id" is NOT sent when using the UPN overload + UnExpectedPostData = new Dictionary + { + { OAuth2Parameter.UserId, "" } + }, + ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage() + }); + + var app = BuildCCA(httpManager); + + // Act + var result = await (app as IByUserFederatedIdentityCredential) + .AcquireTokenByUserFederatedIdentityCredential( + TestConstants.s_scope, + FakeUsername, + FakeAssertion) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + } + + #endregion + #region Multi-User Cache Tests (Low-Level API) /// @@ -429,6 +526,46 @@ public async Task AcquireTokenForAgent_TwoUpns_CacheReturnsCorrectUserToken_Asyn Assert.AreEqual(User1Token, result1Again.AccessToken); } + /// + /// Verifies that a pre-cancelled CancellationToken propagates through the multi-leg + /// agent flow (AgentTokenRequest → inner AcquireTokenForClient → token request pipeline) + /// and causes an OperationCanceledException without making any token HTTP calls. + /// The cancellation is detected at TokenClient.SendTokenRequestAsync, which calls + /// CancellationToken.ThrowIfCancellationRequested() before issuing the HTTP request. + /// + [TestMethod] + public async Task AcquireTokenForAgent_WithPreCancelledToken_ThrowsOperationCanceledException_Async() + { + // Arrange + const string AgentAppId = "00000000-0000-0000-0000-000000001234"; + const string UserUpn = "alice@contoso.com"; + + using var httpManager = new MockHttpManager(); + httpManager.AddInstanceDiscoveryMockHandler(); + + var blueprintCca = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithClientSecret(TestConstants.ClientSecret) + .WithAuthority(TestConstants.AuthorityCommonTenant) + .WithExperimentalFeatures(true) + .WithHttpManager(httpManager) + .BuildConcrete(); + + var agentId = AgentIdentity.WithUsername(AgentAppId, UserUpn); + + var tokenSource = new CancellationTokenSource(); + tokenSource.Cancel(); + + // Act & Assert – the cancelled token propagates through AgentTokenRequest.ExecuteAsync + // into the inner Leg 2 AcquireTokenForClient call, where TokenClient detects the + // cancellation and throws OperationCanceledException before any HTTP request is made. + await AssertException.TaskThrowsAsync( + () => blueprintCca + .AcquireTokenForAgent(TestConstants.s_scope, agentId) + .ExecuteAsync(tokenSource.Token)) + .ConfigureAwait(false); + } + #endregion } }