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
}
}