Refactor CCA instance management in agentic flows#3930
Conversation
| // For agent CCAs, incorporate the agent app ID into the cache key so each agent | ||
| // gets its own CCA instance while sharing the same dictionary and lifecycle. | ||
| string key = GetApplicationKey(mergedOptions, isTokenBinding); | ||
| if (agentAppId is not null) |
There was a problem hiding this comment.
This should be part of GetApplicationKey
There was a problem hiding this comment.
GetApplicationKey now accepts an optional agentAppId parameter and appends the :agent:{agentAppId}suffix internally. The manual suffix concatenation in GetOrBuildConfidentialClientApplicationAsync has been removed
| // found by new CCAs via AcquireTokenSilent. | ||
| if (agentAppId is not null) | ||
| { | ||
| int agentCount = _applicationsByAuthorityClientId.Keys.Count(k => k.IndexOf(":agent:", StringComparison.Ordinal) >= 0); |
There was a problem hiding this comment.
This is O(n) operation, it can be quite expensive. How about just clearing the entire dictionary if it reaches 10000. Tokens are cached anyway.
There was a problem hiding this comment.
Replaced the O(n) agent-only scan with a simpler _applicationsByAuthorityClientId.Clear().
This should be safe because _applicationsByAuthorityClientId should only be populated by CCA instances that use the shared cache.
There was a problem hiding this comment.
Pull request overview
This PR refactors agentic User FIC confidential client application (CCA) instance management so agent CCAs are built through the same unified builder/configuration path as “blueprint” CCAs, while still maintaining per-agent CCA isolation and selective eviction behavior.
Changes:
- Unifies agent CCA construction with
GetOrBuildConfidentialClientApplicationAsync/BuildConfidentialClientApplicationAsyncvia an agent-specific cache-key suffix and an MSAL client-assertion callback. - Moves agent-flow detection earlier so the blueprint CCA is built lazily (only when Leg 1 actually executes inside the assertion callback).
- Updates and adds tests to validate agent entries coexist in the shared CCA dictionary and that agent eviction does not remove the blueprint CCA.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs | Updates eviction-related tests to target agent entries within the shared CCA dictionary and adds coverage for shared-dictionary storage + blueprint preservation. |
| src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs | Refactors agent CCA caching/building into the shared CCA infrastructure, adds agent key suffixing + selective eviction, and introduces an assertion-provider hook to reuse the standard builder path. |
| bool isTokenBinding) | ||
| bool isTokenBinding, | ||
| string? agentAppId = null, | ||
| Func<AssertionRequestOptions, Task<string>>? clientAssertionProvider = null) |
There was a problem hiding this comment.
This clientAssertionProvider is not part of the app key. I think it only makes sense for this callback to exist when an agentAppid is also defined (which implicitly defines the key). In other words, we expect the CCA for 1 agentAppId to map to a single clientAssertionProvider. In other words - agentAppId and clientAssertionProvider need to both be NULL or both be defined.
There was a problem hiding this comment.
Another way of doing this would be have GetOrBuildConfidentialClientApplicationAsync(mergedOptions, isTokenBiding, (agentAppId, blueprintOptions) and for GetOrBuildConfidentialClientApplicationAsync to create the FUNC on the fly. This allows it you to have a key that includes details of blueprintOptions as well.
But it can be overkill.
There was a problem hiding this comment.
Added an ArgumentException guard at the top of BuildConfidentialClientApplicationAsync that throws if exactly one of agentAppId/clientAssertionProvider is null.
The overload with a tuple shouldn't be necessary, the guard should adequately enforce them being paired.
| // Capture authenticationScheme so the callback resolves the correct blueprint. | ||
| string? capturedAuthScheme = authenticationScheme; | ||
|
|
||
| Func<AssertionRequestOptions, Task<string>> assertionCallback = async (AssertionRequestOptions options) => |
There was a problem hiding this comment.
You're calling this on every token acquisition. Isn't this expensive? The callback captures external variables etc.
There was a problem hiding this comment.
Added an early cache-hit check in GetOrBuildAgentUserFicCcaAsync that returns the cached CCA before constructing the closure.
Some quick performance testing showed the closure allocation itself was pretty negligible compared to the total size of the call, but the early return avoids entering the async state machine in GetOrBuildConfidentialClientApplicationAsync, yielding ~15% time improvement on cache-hit calls compared to the original design (~105 µs to ~88 µs)
| { | ||
| string credentialId = string.Join("-", mergedOptions.ClientCredentials?.Select(c => c.Id) ?? Enumerable.Empty<string>()); | ||
|
|
||
| string baseKey = DefaultTokenAcquirerFactoryImplementation.GetKey(mergedOptions.Authority, mergedOptions.ClientId, mergedOptions.AzureRegion) + credentialId; |
There was a problem hiding this comment.
You can further optimize allocations here by using a StringBuilder
There was a problem hiding this comment.
Good point, adjusted in latest commit.
| bool isTokenBinding) | ||
| bool isTokenBinding, | ||
| string? agentAppId = null, | ||
| Func<AssertionRequestOptions, Task<string>>? clientAssertionProvider = null) |
There was a problem hiding this comment.
rename to agenticAssertionProvider to make it clear this is not a general purpose assertion provider.
There was a problem hiding this comment.
Renamed in latest commit
* Fix #3840: Use native MSAL UserFIC API for agentic UPN flows Replace ROPC piggybacking with MSAL's native AcquireTokenByUserFederatedIdentityCredential API using the multi-CCA pattern (blueprint + per-agent CCAs with assertion callbacks). This enables proper token caching for agentic User FIC flows when ClaimsPrincipal is null, eliminating 2-4 unnecessary network round-trips per bot message. Phase 1: UPN-based flows only. OID-based flows remain on the existing ROPC+add-in path pending MSAL .NET support for the OID overload. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add OID UserFIC support, bump MSAL to 4.84.2, remove add-in - Bump MSAL .NET from 4.84.1 to 4.84.2 (adds Guid userObjectId overload for AcquireTokenByUserFederatedIdentityCredential) - Extend TryGetAuthenticationResultForAgentUserFicAsync to handle both UPN-based and OID-based agentic flows via native MSAL APIs - Remove AgentUserIdentityMsalAddIn (ROPC body-rewriting workaround) and its registration in AddAgentIdentities — no longer needed - Remove dead agent identity extraction code from ROPC path - Add 3 OID-specific tests: cache on second call, fresh ClaimsPrincipal per call, and UPN/OID cache isolation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Better handling of national cloud endpoints for FIC scope * Fix #3840: Use native MSAL UserFIC API for agentic UPN flows Replace ROPC piggybacking with MSAL's native AcquireTokenByUserFederatedIdentityCredential API using the multi-CCA pattern (blueprint + per-agent CCAs with assertion callbacks). This enables proper token caching for agentic User FIC flows when ClaimsPrincipal is null, eliminating 2-4 unnecessary network round-trips per bot message. Phase 1: UPN-based flows only. OID-based flows remain on the existing ROPC+add-in path pending MSAL .NET support for the OID overload. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add OID UserFIC support, bump MSAL to 4.84.2, remove add-in - Bump MSAL .NET from 4.84.1 to 4.84.2 (adds Guid userObjectId overload for AcquireTokenByUserFederatedIdentityCredential) - Extend TryGetAuthenticationResultForAgentUserFicAsync to handle both UPN-based and OID-based agentic flows via native MSAL APIs - Remove AgentUserIdentityMsalAddIn (ROPC body-rewriting workaround) and its registration in AddAgentIdentities — no longer needed - Remove dead agent identity extraction code from ROPC path - Add 3 OID-specific tests: cache on second call, fresh ClaimsPrincipal per call, and UPN/OID cache isolation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Better handling of national cloud endpoints for FIC scope * Add agent CCA instance eviction strategy * PR feedback * Fix test issue * More logging and less overrides * Remove upn/oid from agentic flow logs * Enable shared static cache for agent CCAs and add cache isolation tests - Default agent User FIC CCAs to use EnableSharedCacheOptions so tokens survive CCA eviction (backed by process-level static dictionaries) - Add UseSharedCacheForAgentCcas internal property (default true) for test controllability - Add 4 shared cache isolation tests: 1. Multi-agent/multi-user correctness (initial + silent) 2. Cache miss with wrong agent/user (strict isolation) 3. Per-instance cache: tokens lost on eviction (UseSharedCache=false) 4. Shared cache: tokens survive CCA re-creation after eviction Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clean up tests and comments: consolidate redundant tests, fix stale comments - Remove 4 redundant tests: - WorksWithNonNullClaimsPrincipal (subsumed by CacheWorksWithNewClaimsPrincipalPerCall) - OidCacheWorksWithNewClaimsPrincipalPerCall (OID basic caching already covered) - DifferentAgentOrUser_DoesNotReturnCachedToken (covered by MultiAgentMultiUser test) - ReturnsZero_WhenNothingExpired (trivial, covered by DoesNotRemoveRecentlyAccessed) - Update GetOrBuildAgentUserFicCcaAsync docstring to reflect shared cache design - Remove fragile line-number reference in comment - Clean up verbose metacommentary in test comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Replace periodic sweep with simple size-threshold eviction Replace the timer-based idle sweep (AgentCcaEntry, Stopwatch timestamps, SweepExpiredAgentCcas, EnsureAgentCcaSweepTimerStarted) with a simple size-threshold clear: when _agentUserFicCcas exceeds AgentCcaMaxCount (default 10,000), clear the entire dictionary. This is safe because tokens live in MSAL's shared static cache (enabled via EnableSharedCacheOptions), not per-CCA instance caches. Clearing the dictionary only discards lightweight CCA wrapper objects. New CCAs built with the same clientId will find cached tokens via AcquireTokenSilent. Removes: - AgentCcaEntry class (timestamps, Touch, IsExpired) - AgentCcaMaxIdleMilliseconds, AgentCcaSweepInterval properties - _agentCcaSweepTimer field - EnsureAgentCcaSweepTimerStarted(), SweepExpiredAgentCcas() methods - System.Diagnostics using (Stopwatch no longer needed) - 4 sweep eviction tests (timer, touch, companion cleanup, selective) Adds: - AgentCcaMaxCount property (default 10,000) - Inline size check after CCA creation - 1 size-threshold eviction test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback: normalize agentAppId, handle Guid OID values, clear semaphores on eviction, fix stale XML docs and log message - Normalize agentAppId to uppercase to prevent duplicate CCAs from GUID casing - Accept Guid objects (not just strings) for OID via ToString() fallback - Clear _agentCcaSemaphores alongside CCA/account dictionaries on threshold eviction - Fix stale XML docs referencing timestamp-based eviction (now size-threshold) - Add shared-cache caveat to AgentCcaMaxCount doc - Fix _agentUserFicAccountIds doc (opportunistic cleanup, not MSAL-driven eviction) - Update log message from 'sweep evicted' to 'cache cleared (exceeded size threshold)' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CS8602 on older TFMs and drop unnecessary agentAppId normalization Revert agentAppId.ToUpperInvariant() — existing ID Web patterns (GetApplicationKey, _applicationsByAuthorityClientId) do not normalize client IDs, so adding case-normalization only in the agentic flow would be inconsistent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback: simplify OID detection, use MSAL TenantId, remove ExtractTenant, drop WithExperimentalFeatures - Simplify OID detection to Guid.TryParse(userIdObj?.ToString(), ...) instead of complex || assignment with null-forgiving operator - Use AssertionRequestOptions.TenantId directly with WithTenantId() for Leg 1 tenant propagation, replacing custom ExtractTenantFromTokenEndpointIfSameInstance - Remove ExtractTenantFromTokenEndpointIfSameInstance method and its 4 tests (OidcIdpSignedAssertionProvider's copy in OidcFIC project is unaffected) - Remove WithExperimentalFeatures() from agent CCA builder — none of the APIs used (WithClientAssertion, WithFmiPath, AcquireTokenByUserFederatedIdentityCredential) require it Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Tighten catch * Remove unneeded shared cache toggle * Refactor CCA instance management in agentic flows (#3930) * Refactor agent CCA instance management * PR feedback * PR feedback --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This PR is meant to address this comment in the main agentic refactoring PR: #3842 (comment)
Managing CCA instances is the main expected pain point of the new agentic behavior supported in recent versions of MSAL. This sort of per-agent CCA instance management will become irrelevant once MSAL extensibility APIs are improved, however until those improvements are made and released ID Web should manage the per-agent CCA instances in a proper way.
Concern
The original PR introduced a dedicated
GetOrBuildAgentUserFicCcaAsyncthat built agent CCAs viaConfidentialClientApplicationBuilder.Create(agentAppId)with manual.WithAuthority(),.WithHttpClientFactory(), and.WithCacheOptions()calls — bypassing the normalBuildConfidentialClientApplicationAsyncpath. This meant agent CCAs missed:CreateWithApplicationOptions()(AzureRegion, ClientCapabilities, ClientName/Version).WithLogging()(structured logging)_tokenCacheProvider.Initialize()(distributed cache support).WithExperimentalFeatures()In addition, there are existing
GetOrBuildConfidentialClientApplicationAsync/_applicationsByAuthorityClientIdinfrastructure that we could adjust to build the CCAs rather than keeping a parallel track for the per agent CCAs.Changes compared to parent PR
1. Cache key suffix
GetOrBuildConfidentialClientApplicationAsyncnow accepts an optionalagentAppIdparameter. When set, the cache key becomes{blueprintKey}:agent:{agentAppId}, storing agent CCAs in the shared_applicationsByAuthorityClientIddictionary alongside normal CCAs.The
authenticationSchemeis not explicitly in the agent suffix because different schemes resolve to differentMergedOptions, which produce differentGetApplicationKey()base keys — so scheme isolation is implicit in the blueprint key portion.2. Credential customization callback
BuildConfidentialClientApplicationAsyncnow accepts an optionalclientAssertionProvidercallback (Func<AssertionRequestOptions, Task<string>>). When provided, the builder calls.WithClientAssertion(clientAssertionProvider)instead of.WithClientCredentialsAsync(...). All other configuration is shared:CreateWithApplicationOptions(ccaOptions)— authority, AzureRegion, ClientCapabilities, ClientName/Version.WithHttpClientFactory(),.WithLogging(),.WithExperimentalFeatures()_tokenCacheProvider.Initialize()on both AppTokenCache and UserTokenCacheDeviation from suggestion: The reviewer proposed
Func<ConfidentialClientApplicationBuilder, string, Task>(exposing the builder + authority). We useFunc<AssertionRequestOptions, Task<string>>(the MSAL assertion callback directly), which keeps the builder encapsulated —GetOrBuildAgentUserFicCcaAsyncconstructs the callback and passes it to the unified builder rather than receiving a raw builder to configure.3. Clone/derive blueprint options
For agent CCAs,
BuildConfidentialClientApplicationAsynccreates a freshConfidentialClientApplicationOptions, populates it viaMergedOptions.UpdateConfidentialClientApplicationOptionsFromMergedOptions(mergedOptions, ccaOptions), then setsccaOptions.ClientId = agentAppId. The blueprint's lazily-cachedConfidentialClientApplicationOptionsinstance is never mutated.4. Earlier agent flow detection
GetAuthenticationResultForUserInternalAsyncnow callsTryGetAuthenticationResultForAgentUserFicAsyncbeforeGetOrBuildConfidentialClientApplicationAsync. For agent requests, the blueprint CCA is only built lazily inside the assertion callback when Leg 1 actually fires. TheIConfidentialClientApplication applicationparameter was removed fromTryGetAuthenticationResultForAgentUserFicAsyncentirely since the agent CCA gets its authority fromMergedOptions.Additional changes beyond the original comment
:agent:in the key — the blueprint CCA is preserved. The old separate-dictionary design cleared everything._agentUserFicCcasand_agentCcaSemaphores: Two entireConcurrentDictionaryfields eliminated. Agent CCAs reuse_applicationsByAuthorityClientIdand_appSemaphores.WithRedirectUri()since redirect URIs aren't meaningful for client-credential + assertion flows._applicationsByAuthorityClientIdvisibility: Changed fromprivatetointernalto support test assertions on shared dictionary contents.How this compares to the original (master) agent flow
The master branch already stored agent CCAs in
_applicationsByAuthorityClientIdvia the normal builder path — but it did so through a fundamentally different mechanism:GetMergedOptions()creates a newMergedOptionswith the agent'sClientId, which naturally produces a differentGetApplicationKey():agent:{agentAppId}suffix appended to the blueprint's keyGetMergedOptions()creates a barenew MergedOptions()with only ~7 fields copied (missing ClientName, ClientVersion, LogLevel, EnablePiiLogging, EnableCacheSynchronization, etc.)UpdateConfidentialClientApplicationOptionsFromMergedOptionscopies all fields from the fully-populated blueprintMergedOptionsAcquireTokenByUsernamePasswordwithpassword = "password")AcquireTokenByUserFederatedIdentityCredential)OidcIdpSignedAssertionProvider→ re-entersTokenAcquisitionviaITokenAcquirerFactoryAcquireTokenForClient().WithFmiPath()ClaimsPrincipal.GetMsalAccountId()(typically null in agentic scenarios — this was the reported bug)_agentUserFicAccountIdsdictionary tracks MSAL account identifiers independently:agent:entries, preserving the blueprint CCAIn short, the master design used the shared dictionary but with incomplete configuration cloning and a semantically wrong token flow (ROPC). This refactor restores the shared-dictionary approach while fixing both the configuration completeness (full options cloning) and the token flow (native UserFIC API).
Risks and unknowns
Key format coupling: The
:agent:sentinel in cache keys is a string convention. IfGetApplicationKey()ever produces keys containing:agent:for non-agent scenarios, the eviction logic would incorrectly clear those entries. This is unlikely given the current key format (authority + clientId + azureRegion + credentialId), but worth noting._agentUserFicAccountIdsremains separate: Unlike CCA instances, account identifiers are not stored in the shared dictionary. This is intentional — they map(agent, user, tenant)tuples to MSAL account identifiers, which is a fundamentally different data structure from the CCA cache. There's no existing mechanism for this in the normal flow (which usesClaimsPrincipalinstead).Blueprint CCA built lazily: For agent-only traffic, the blueprint CCA is not built until the assertion callback fires during Leg 1. If the assertion callback fails (e.g., credential loading error), the error surfaces inside the agent CCA's token acquisition rather than during CCA construction. This matches the old behavior where
OidcIdpSignedAssertionProvidercould also fail during callback execution.Test coverage
AgentCca_StoredInSharedDictionary_WithAgentKeySegment— verifies agent and blueprint CCAs coexist in_applicationsByAuthorityClientIdwith correct key segmentsAgentCcaEviction_DoesNotAffect_BlueprintCca— verifies selective eviction preserves blueprint entries