Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
44a045b
Fix #3840: Use native MSAL UserFIC API for agentic UPN flows
Avery-Dunn Jun 4, 2026
a267646
Add OID UserFIC support, bump MSAL to 4.84.2, remove add-in
Avery-Dunn Jun 5, 2026
9821f57
Better handling of national cloud endpoints for FIC scope
Avery-Dunn Jun 5, 2026
5952470
Fix #3840: Use native MSAL UserFIC API for agentic UPN flows
Avery-Dunn Jun 4, 2026
aa4b404
Add OID UserFIC support, bump MSAL to 4.84.2, remove add-in
Avery-Dunn Jun 5, 2026
e4b3f16
Better handling of national cloud endpoints for FIC scope
Avery-Dunn Jun 5, 2026
e3f4ee1
Merge branch 'avdunn/agentic-fic-scenario-fix' of https://github.com/…
Avery-Dunn Jun 5, 2026
8127263
Add agent CCA instance eviction strategy
Avery-Dunn Jun 16, 2026
3cb9ae8
PR feedback
Avery-Dunn Jun 16, 2026
c3d8ef3
Merge branch 'master' into avdunn/agentic-fic-scenario-fix
Avery-Dunn Jun 16, 2026
dd7a1f2
Fix test issue
Avery-Dunn Jun 16, 2026
b063974
Merge branch 'avdunn/agentic-fic-scenario-fix' of https://github.com/…
Avery-Dunn Jun 16, 2026
ffef916
More logging and less overrides
Avery-Dunn Jun 17, 2026
c0532be
Merge branch 'master' into avdunn/agentic-fic-scenario-fix
Avery-Dunn Jun 26, 2026
dc7585a
Remove upn/oid from agentic flow logs
Avery-Dunn Jun 26, 2026
da1b63d
Enable shared static cache for agent CCAs and add cache isolation tests
Avery-Dunn Jun 26, 2026
14a4849
Clean up tests and comments: consolidate redundant tests, fix stale c…
Avery-Dunn Jun 26, 2026
19dcddc
Replace periodic sweep with simple size-threshold eviction
Avery-Dunn Jun 26, 2026
e694d1e
Merge remote-tracking branch 'origin/master' into avdunn/agentic-fic-…
Avery-Dunn Jun 29, 2026
e813abd
Address review feedback: normalize agentAppId, handle Guid OID values…
Avery-Dunn Jun 30, 2026
ae8a04a
Fix CS8602 on older TFMs and drop unnecessary agentAppId normalization
Avery-Dunn Jun 30, 2026
956f849
Address review feedback: simplify OID detection, use MSAL TenantId, r…
Avery-Dunn Jul 7, 2026
4473dc4
Tighten catch
Avery-Dunn Jul 7, 2026
1c3956d
Remove unneeded shared cache toggle
Avery-Dunn Jul 7, 2026
bcbfea8
Refactor CCA instance management in agentic flows (#3930)
Avery-Dunn Jul 8, 2026
4520e10
Merge branch 'master' into avdunn/agentic-fic-scenario-fix
Avery-Dunn Jul 8, 2026
1d174c9
Merge branch 'master' into avdunn/agentic-fic-scenario-fix
Avery-Dunn Jul 9, 2026
82628cd
Merge branch 'master' into avdunn/agentic-fic-scenario-fix
Avery-Dunn Jul 13, 2026
a3c52a7
Merge branch 'master' into avdunn/agentic-fic-scenario-fix
Avery-Dunn Jul 13, 2026
b7cad39
Merge branch 'master' into avdunn/agentic-fic-scenario-fix
Avery-Dunn Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,28 @@ class OAuthConstants
private readonly ConcurrentDictionary<string, IConfidentialClientApplication?> _applicationsByAuthorityClientId = new();
private readonly ConcurrentDictionary<string, SemaphoreSlim> _appSemaphores = new();

/// <summary>
/// Caches agent CCAs for the native User FIC flow. Each agent CCA uses an assertion
/// callback that chains to the blueprint CCA for Leg 1 (FMI token acquisition).
/// Key format: "{agentAppId}:{authenticationScheme}".
Comment thread
Avery-Dunn marked this conversation as resolved.
Outdated
/// </summary>
private readonly ConcurrentDictionary<string, IConfidentialClientApplication> _agentUserFicCcas = new();
Comment thread
Avery-Dunn marked this conversation as resolved.
Outdated
Comment thread
Avery-Dunn marked this conversation as resolved.
Outdated
private readonly ConcurrentDictionary<string, SemaphoreSlim> _agentCcaSemaphores = new();

/// <summary>
/// Maps (agentAppId, username, tenantId) tuples to MSAL account identifiers for the
/// native User FIC flow. This is needed because AcquireTokenSilent requires an IAccount,
/// which can only be obtained from GetAccountAsync(identifier) using an identifier that
/// comes back from a prior token acquisition. In all other ID Web flows, this identifier
/// is stored in the ClaimsPrincipal (via GetMsalAccountId / oid+tid claims). In the
/// agentic scenario, however, ClaimsPrincipal is typically null or freshly created per
/// request (bot/service pattern), so there is no persistent object to write back to.
/// This dictionary fills that role, keyed by "{agentAppId}:{USERNAME}:{TENANTID}".
/// Entries are cleaned up when MSAL evicts the corresponding account from its cache.
/// </summary>
Comment thread
Avery-Dunn marked this conversation as resolved.
private readonly ConcurrentDictionary<string, string> _agentUserFicAccountIds = new();
private static readonly string[] s_ficScopes = new[] { "api://AzureADTokenExchange/.default" };
Comment thread
Avery-Dunn marked this conversation as resolved.

private const string TokenBindingParameterName = "IsTokenBinding";
private const int MaxCertificateRetries = 1;
protected readonly IMsalHttpClientFactory _httpClientFactory;
Expand Down Expand Up @@ -403,6 +425,16 @@ private async Task<AuthenticationResult> GetAuthenticationResultForUserInternalA
MergedOptions mergedOptions,
TokenAcquisitionOptions? tokenAcquisitionOptions)
{
// Handle UPN-based agentic User FIC flow using MSAL's native UserFIC API.
// This bypasses the ROPC piggybacking approach and provides proper cache behavior.
// OID-based agentic flows continue through the existing ROPC + add-in path below.
var agentUserFicResult = await TryGetAuthenticationResultForAgentUserFicAsync(
application, tenantId, scopes, mergedOptions, tokenAcquisitionOptions).ConfigureAwait(false);
if (agentUserFicResult is not null)
{
return agentUserFicResult;
}

string? username = null;
string? password = null;
string? agentIdentity = string.Empty;
Expand Down Expand Up @@ -526,6 +558,194 @@ private async Task<AuthenticationResult> GetAuthenticationResultForUserInternalA
return authenticationResult;
}

/// <summary>
/// Handles UPN-based agentic User FIC flow using MSAL's native
/// <see cref="IByUserFederatedIdentityCredential.AcquireTokenByUserFederatedIdentityCredential"/>
/// API. This replaces the ROPC piggybacking approach for UPN scenarios, providing proper
/// token cache behavior via MSAL's built-in cache.
///
/// The flow follows the multi-CCA pattern:
/// Leg 1: Blueprint CCA acquires FMI token (T1) for the agent — handled transparently
/// by the agent CCA's assertion callback (see <see cref="GetOrBuildAgentUserFicCcaAsync"/>).
/// Leg 2: Agent CCA acquires instance token (T2) via AcquireTokenForClient.
/// Leg 3: Agent CCA exchanges T2 + username for a user-scoped token via native UserFIC.
///
/// On subsequent calls, AcquireTokenSilent returns the cached token without network calls.
/// Unlike other ID Web flows where the MSAL account identifier is stored in the
/// ClaimsPrincipal (via oid/tid claims), the agentic scenario typically has a null or
/// request-scoped ClaimsPrincipal — so account identifiers are tracked in
/// <see cref="_agentUserFicAccountIds"/> instead.
/// </summary>
/// <returns>An <see cref="AuthenticationResult"/> if this is a UPN-based agentic flow;
/// <c>null</c> if the request is not an agentic UPN flow (OID or regular ROPC).</returns>
private async Task<AuthenticationResult?> TryGetAuthenticationResultForAgentUserFicAsync(
IConfidentialClientApplication application,
string? tenantId,
IEnumerable<string> scopes,
MergedOptions mergedOptions,
TokenAcquisitionOptions? tokenAcquisitionOptions)
{
var extraParameters = tokenAcquisitionOptions?.ExtraParameters;

// Only handle UPN-based agentic flows (AgentIdentityKey + UsernameKey).
// OID-based flows (UserIdKey) continue through the existing ROPC + add-in path.
if (extraParameters is null
|| !extraParameters.TryGetValue(Constants.AgentIdentityKey, out object? agentObj)
|| !extraParameters.ContainsKey(Constants.UsernameKey))
{
return null;
}

string? agentAppId = agentObj as string;
string? username = extraParameters[Constants.UsernameKey] as string;
if (string.IsNullOrEmpty(agentAppId) || string.IsNullOrEmpty(username))
{
return null;
}
Comment thread
Avery-Dunn marked this conversation as resolved.

string? authScheme = tokenAcquisitionOptions?.AuthenticationOptionsName;
var agentCca = await GetOrBuildAgentUserFicCcaAsync(
agentAppId!, application.Authority, authScheme).ConfigureAwait(false);

bool forceRefresh = tokenAcquisitionOptions?.ForceRefresh ?? false;

// Try silent retrieval first using a stored account identifier from a prior call.
// Include tenantId in the key so cross-tenant calls don't collide.
string normalizedTenant = tenantId?.ToUpperInvariant() ?? string.Empty;
string accountLookupKey = $"{agentAppId}:{username!.ToUpperInvariant()}:{normalizedTenant}";
if (!forceRefresh
Comment thread
Avery-Dunn marked this conversation as resolved.
&& _agentUserFicAccountIds.TryGetValue(accountLookupKey, out string? cachedAccountId)
&& !string.IsNullOrEmpty(cachedAccountId))
{
var account = await agentCca.GetAccountAsync(cachedAccountId).ConfigureAwait(false);
if (account is not null)
{
try
{
var silentBuilder = agentCca.AcquireTokenSilent(
scopes.Except(_scopesRequestedByMsal),
account);
if (!string.IsNullOrEmpty(tenantId))
{
silentBuilder.WithTenantId(tenantId);
}

return await silentBuilder.ExecuteAsync().ConfigureAwait(false);
}
catch (MsalUiRequiredException ex)
{
Logger.TokenAcquisitionError(_logger, ex.Message, ex);
}
Comment thread
Copilot marked this conversation as resolved.
}
else
{
// Account was evicted from MSAL's cache — remove stale mapping.
_agentUserFicAccountIds.TryRemove(accountLookupKey, out _);
}
}

// Leg 2: Get the agent's instance token (T2).
// The assertion callback handles Leg 1 (blueprint → T1) transparently.
var leg2Builder = agentCca.AcquireTokenForClient(s_ficScopes);
if (!string.IsNullOrEmpty(tenantId))
{
leg2Builder.WithTenantId(tenantId);
}

var leg2 = await leg2Builder.ExecuteAsync().ConfigureAwait(false);

// Leg 3: Exchange T2 + username for a user-scoped token via native UserFIC.
var leg3Builder = ((IByUserFederatedIdentityCredential)agentCca)
.AcquireTokenByUserFederatedIdentityCredential(
scopes.Except(_scopesRequestedByMsal),
username,
leg2.AccessToken);

if (!string.IsNullOrEmpty(tenantId))
{
leg3Builder.WithTenantId(tenantId);
}

var result = await leg3Builder.ExecuteAsync().ConfigureAwait(false);

// Store the account identifier for subsequent silent lookups.
// This parallels how other ID Web flows write oid/tid claims back into the
// ClaimsPrincipal after acquisition (see line ~541 in the ROPC path). Here,
// ClaimsPrincipal is unavailable, so we use _agentUserFicAccountIds instead.
if (result.Account?.HomeAccountId is not null)
{
_agentUserFicAccountIds[accountLookupKey] = result.Account.HomeAccountId.Identifier;
}

return result;
}

/// <summary>
/// Gets or builds an agent CCA for the native User FIC flow. Each agent CCA uses an
/// assertion callback that chains back to the blueprint CCA for Leg 1 (FMI token).
/// The agent CCA's in-memory cache provides natural token isolation per agent.
/// </summary>
Comment thread
Avery-Dunn marked this conversation as resolved.
private async Task<IConfidentialClientApplication> GetOrBuildAgentUserFicCcaAsync(
string agentAppId,
string authority,
string? authenticationScheme)
{
// Include authenticationScheme in the CCA cache key so different schemes
// (pointing to different blueprint credentials) get separate CCAs.
string ccaCacheKey = $"{agentAppId}:{authenticationScheme ?? string.Empty}";

Comment thread
Avery-Dunn marked this conversation as resolved.
Outdated
if (_agentUserFicCcas.TryGetValue(ccaCacheKey, out var existing))
{
return existing;
}

var semaphore = _agentCcaSemaphores.GetOrAdd(ccaCacheKey, _ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync().ConfigureAwait(false);
try
{
if (_agentUserFicCcas.TryGetValue(ccaCacheKey, out var app))
{
return app;
}

// Capture authenticationScheme for the assertion callback closure.
string? capturedAuthScheme = authenticationScheme;

var newApp = ConfidentialClientApplicationBuilder
.Create(agentAppId)
.WithClientAssertion(async (AssertionRequestOptions options) =>
{
// Leg 1: Blueprint acquires FMI token (T1) for this agent.
// AcquireTokenForClient checks cache first — only the first call
// or an expired T1 hits the network.
MergedOptions blueprintOptions = _tokenAcquisitionHost.GetOptions(capturedAuthScheme, out _);
var blueprintCca = await GetOrBuildConfidentialClientApplicationAsync(
blueprintOptions, isTokenBinding: false).ConfigureAwait(false);

var leg1 = await blueprintCca
.AcquireTokenForClient(s_ficScopes)
.WithFmiPath(agentAppId)
.WithSendX5C(blueprintOptions.SendX5C)
.ExecuteAsync(options.CancellationToken)
.ConfigureAwait(false);

return leg1.AccessToken;
})
.WithAuthority(authority)
.WithHttpClientFactory(_httpClientFactory)
.WithInstanceDiscovery(false) // Blueprint already validated the authority.
Comment thread
Avery-Dunn marked this conversation as resolved.
Outdated
.WithExperimentalFeatures()
.Build();

_agentUserFicCcas[ccaCacheKey] = newApp;
return newApp;
}
finally
{
semaphore.Release();
}
}

private void LogAuthResult(AuthenticationResult? authenticationResult)
{
if (authenticationResult != null)
Expand Down
Loading
Loading