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..e01fded030 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenByUserFederatedIdentityCredentialParameters.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; +using System.Text; using Microsoft.Identity.Client.Core; namespace Microsoft.Identity.Client.ApiConfig.Parameters @@ -8,6 +10,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; } @@ -15,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/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..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; @@ -197,6 +198,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 +221,17 @@ AcquireTokenByRefreshTokenParameterBuilder IByRefreshToken.AcquireTokenByRefresh refreshToken); } + /// + public AcquireTokenForAgentParameterBuilder AcquireTokenForAgent( + IEnumerable scopes, + AgentIdentity agentIdentity) + { + return AcquireTokenForAgentParameterBuilder.Create( + ClientExecutorFactory.CreateConfidentialClientExecutor(this), + scopes, + agentIdentity); + } + /// public ITokenCache AppTokenCache => AppTokenCacheInternal; @@ -218,6 +243,13 @@ AcquireTokenByRefreshTokenParameterBuilder IByRefreshToken.AcquireTokenByRefresh // 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/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..042c981c7e --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AgentTokenRequest.cs @@ -0,0 +1,331 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +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 +{ + /// + /// 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( + 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(); + + // 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: AcquireTokenForClient has built-in cache-first logic, + // so no explicit silent pre-check is needed. + return await PropagateOuterRequestParameters( + agentCca.AcquireTokenForClient(AuthenticationRequestParameters.Scope)) + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false); + } + + // --- User identity flow --- + + // 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 TryAcquireTokenSilentFromAgentCacheAsync( + agentCca, + agentIdentity, + AuthenticationRequestParameters.Scope, + cancellationToken).ConfigureAwait(false); + if (cachedResult != null) + { + return cachedResult; + } + } + + // Cache miss (or ForceRefresh) — execute Leg 2 + Leg 3. + + // 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 PropagateOuterRequestParameters( + agentCca.AcquireTokenForClient(new[] { TokenExchangeScope })) + .WithFmiPathForClientAssertion(agentAppId) + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false); + + string assertion = assertionResult.AccessToken; + + // 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 PropagateOuterRequestParameters( + ((IByUserFederatedIdentityCredential)agentCca) + .AcquireTokenByUserFederatedIdentityCredential( + AuthenticationRequestParameters.Scope, + agentIdentity.UserObjectId.Value, + assertion)) + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false); + } + + return await PropagateOuterRequestParameters( + ((IByUserFederatedIdentityCredential)agentCca) + .AcquireTokenByUserFederatedIdentityCredential( + AuthenticationRequestParameters.Scope, + agentIdentity.Username, + assertion)) + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + /// 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 async Task TryAcquireTokenSilentFromAgentCacheAsync( + IConfidentialClientApplication agentCca, + AgentIdentity agentIdentity, + IEnumerable scopes, + CancellationToken cancellationToken) + { +#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 + + IAccount matchedAccount = FindMatchingAccount(accounts, agentIdentity); + if (matchedAccount == null) + { + return null; + } + + try + { + return await PropagateOuterRequestParameters( + agentCca.AcquireTokenSilent(scopes, matchedAccount)) + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false); + } + catch (MsalUiRequiredException) + { + // Token expired or requires interaction — fall through to full Leg 2 + Leg 3 flow + return null; + } + } + + /// + /// 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) + { + 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 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_"; + + /// + /// 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)); + } + + /// + /// Builds a new internal Agent CCA configured with: + /// - Client ID = the agent's app ID + /// - Authority = the Blueprint's resolved authority + /// - 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) => + { + // 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; + + var result = await blueprint + .AcquireTokenForClient(new[] { TokenExchangeScope }) + .WithFmiPath(fmiPath) + .ExecuteAsync() + .ConfigureAwait(false); + + return result.AccessToken; + }); + + PropagateBlueprintConfig(builder); + return builder.Build(); + } + + /// + /// 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 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); + } + + // Tenant override: if the caller used .WithTenantId() on AcquireTokenForAgent, + // apply the same override to the inner call + if (outerParams.AuthorityOverride != null) + { + var overrideAuthority = Authority.CreateAuthority(outerParams.AuthorityOverride); + builder.WithTenantId(overrideAuthority.TenantId); + } + + // Extra query parameters: already merged (app-level + request-level) in outerParams + if (outerParams.ExtraQueryParameters != null && outerParams.ExtraQueryParameters.Count > 0) + { + builder.CommonParameters.ExtraQueryParameters = + new Dictionary(outerParams.ExtraQueryParameters, StringComparer.OrdinalIgnoreCase); + } + + return builder; + } + + #endregion + } +} 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..7137656585 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,439 @@ 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 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) { Assert.IsNotNull(fmiPath, "fmiPath cannot be null"); @@ -121,5 +567,7 @@ private static async Task GetAppCredentialAsync(string fmiPath) 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 036afe33f0..593e31e73f 100644 --- a/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/RequestsTests/UserFederatedIdentityCredentialTests.cs @@ -3,7 +3,9 @@ using System; 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; @@ -175,5 +177,395 @@ public void AcquireTokenByUserFic_EmptyAssertion_ThrowsArgumentNullException() username: FakeUsername, 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) + + /// + /// 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 + + #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); + } + + /// + /// 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 } }