Skip to content

Refactor CCA instance management in agentic flows#3930

Merged
Avery-Dunn merged 3 commits into
avdunn/agentic-fic-scenario-fixfrom
avdunn/agentic-fic-refactor
Jul 8, 2026
Merged

Refactor CCA instance management in agentic flows#3930
Avery-Dunn merged 3 commits into
avdunn/agentic-fic-scenario-fixfrom
avdunn/agentic-fic-refactor

Conversation

@Avery-Dunn

@Avery-Dunn Avery-Dunn commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

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 GetOrBuildAgentUserFicCcaAsync that built agent CCAs via ConfidentialClientApplicationBuilder.Create(agentAppId) with manual .WithAuthority(), .WithHttpClientFactory(), and .WithCacheOptions() calls — bypassing the normal BuildConfidentialClientApplicationAsync path. This meant agent CCAs missed:

  • CreateWithApplicationOptions() (AzureRegion, ClientCapabilities, ClientName/Version)
  • .WithLogging() (structured logging)
  • _tokenCacheProvider.Initialize() (distributed cache support)
  • B2C/CIAM authority handling
  • .WithExperimentalFeatures()

In addition, there are existing GetOrBuildConfidentialClientApplicationAsync / _applicationsByAuthorityClientId infrastructure 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

GetOrBuildConfidentialClientApplicationAsync now accepts an optional agentAppId parameter. When set, the cache key becomes {blueprintKey}:agent:{agentAppId}, storing agent CCAs in the shared _applicationsByAuthorityClientId dictionary alongside normal CCAs.

The authenticationScheme is not explicitly in the agent suffix because different schemes resolve to different MergedOptions, which produce different GetApplicationKey() base keys — so scheme isolation is implicit in the blueprint key portion.

2. Credential customization callback

BuildConfidentialClientApplicationAsync now accepts an optional clientAssertionProvider callback (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()
  • Authority handling (OIDC/B2C/standard)
  • Cache options (shared + distributed)
  • _tokenCacheProvider.Initialize() on both AppTokenCache and UserTokenCache

Deviation from suggestion: The reviewer proposed Func<ConfidentialClientApplicationBuilder, string, Task> (exposing the builder + authority). We use Func<AssertionRequestOptions, Task<string>> (the MSAL assertion callback directly), which keeps the builder encapsulated — GetOrBuildAgentUserFicCcaAsync constructs 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, BuildConfidentialClientApplicationAsync creates a fresh ConfidentialClientApplicationOptions, populates it via MergedOptions.UpdateConfidentialClientApplicationOptionsFromMergedOptions(mergedOptions, ccaOptions), then sets ccaOptions.ClientId = agentAppId. The blueprint's lazily-cached ConfidentialClientApplicationOptions instance is never mutated.

4. Earlier agent flow detection

GetAuthenticationResultForUserInternalAsync now calls TryGetAuthenticationResultForAgentUserFicAsync before GetOrBuildConfidentialClientApplicationAsync. For agent requests, the blueprint CCA is only built lazily inside the assertion callback when Leg 1 actually fires. The IConfidentialClientApplication application parameter was removed from TryGetAuthenticationResultForAgentUserFicAsync entirely since the agent CCA gets its authority from MergedOptions.

Additional changes beyond the original comment

  • Selective agent eviction: DOS eviction only removes entries with :agent: in the key — the blueprint CCA is preserved. The old separate-dictionary design cleared everything.
  • Removed _agentUserFicCcas and _agentCcaSemaphores: Two entire ConcurrentDictionary fields eliminated. Agent CCAs reuse _applicationsByAuthorityClientId and _appSemaphores.
  • Agent redirect URI skip: Agent CCAs skip WithRedirectUri() since redirect URIs aren't meaningful for client-credential + assertion flows.
  • _applicationsByAuthorityClientId visibility: Changed from private to internal to support test assertions on shared dictionary contents.

How this compares to the original (master) agent flow

The master branch already stored agent CCAs in _applicationsByAuthorityClientId via the normal builder path — but it did so through a fundamentally different mechanism:

Aspect Master (original) This refactor
CCA key differentiation GetMergedOptions() creates a new MergedOptions with the agent's ClientId, which naturally produces a different GetApplicationKey() Explicit :agent:{agentAppId} suffix appended to the blueprint's key
Configuration completeness GetMergedOptions() creates a bare new MergedOptions() with only ~7 fields copied (missing ClientName, ClientVersion, LogLevel, EnablePiiLogging, EnableCacheSynchronization, etc.) UpdateConfidentialClientApplicationOptionsFromMergedOptions copies all fields from the fully-populated blueprint MergedOptions
Token flow Piggybacked on ROPC (AcquireTokenByUsernamePassword with password = "password") Native MSAL UserFIC API (AcquireTokenByUserFederatedIdentityCredential)
Leg 1 (FMI token) OidcIdpSignedAssertionProvider → re-enters TokenAcquisition via ITokenAcquirerFactory Direct assertion callback → blueprint CCA's AcquireTokenForClient().WithFmiPath()
Silent cache lookup Depended on ClaimsPrincipal.GetMsalAccountId() (typically null in agentic scenarios — this was the reported bug) _agentUserFicAccountIds dictionary tracks MSAL account identifiers independently
DOS protection None Threshold eviction clears only :agent: entries, preserving the blueprint CCA

In 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

  1. Key format coupling: The :agent: sentinel in cache keys is a string convention. If GetApplicationKey() 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.

  2. _agentUserFicAccountIds remains 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 uses ClaimsPrincipal instead).

  3. 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 OidcIdpSignedAssertionProvider could also fail during callback execution.

Test coverage

  • AgentCca_StoredInSharedDictionary_WithAgentKeySegment — verifies agent and blueprint CCAs coexist in _applicationsByAuthorityClientId with correct key segments
  • AgentCcaEviction_DoesNotAffect_BlueprintCca — verifies selective eviction preserves blueprint entries
  • All existing agent tests (UPN/OID acquisition, silent cache, shared cache survival, eviction threshold) pass unchanged
  • Full test suite (992-1020 tests across TFMs) passes with no regressions

@Avery-Dunn Avery-Dunn requested a review from a team as a code owner July 7, 2026 17:31
@Avery-Dunn Avery-Dunn changed the base branch from master to avdunn/agentic-fic-scenario-fix July 7, 2026 17:31
// 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be part of GetApplicationKey

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 / BuildConfidentialClientApplicationAsync via 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.

Comment thread src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs
bool isTokenBinding)
bool isTokenBinding,
string? agentAppId = null,
Func<AssertionRequestOptions, Task<string>>? clientAssertionProvider = null)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're calling this on every token acquisition. Isn't this expensive? The callback captures external variables etc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can further optimize allocations here by using a StringBuilder

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, adjusted in latest commit.

bool isTokenBinding)
bool isTokenBinding,
string? agentAppId = null,
Func<AssertionRequestOptions, Task<string>>? clientAssertionProvider = null)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename to agenticAssertionProvider to make it clear this is not a general purpose assertion provider.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed in latest commit

@bgavrilMS bgavrilMS left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved with comments.

@Avery-Dunn Avery-Dunn merged commit bcbfea8 into avdunn/agentic-fic-scenario-fix Jul 8, 2026
1 check passed
@Avery-Dunn Avery-Dunn deleted the avdunn/agentic-fic-refactor branch July 8, 2026 16:01
Avery-Dunn added a commit that referenced this pull request Jul 13, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants