Skip to content

Unify claims principal cache identity - #69312

Open
javiercn wants to merge 11 commits into
mainfrom
javiercn/unify-principal-cache-identity
Open

javiercn wants to merge 11 commits into
mainfrom
javiercn/unify-principal-cache-identity

Conversation

@javiercn

@javiercn javiercn commented Sep 15, 2026

Copy link
Copy Markdown
Member

Overview

This branch centralizes the existing Antiforgery claim-UID algorithm as an internal shared-source ClaimsPrincipal identifier, then adopts it in Antiforgery, MVC Cache Tag Helper (including distributed caching), and Components CacheView. The hard constraint is byte-for-byte Antiforgery compatibility: identity/claim traversal, UTF-8 framing, sorting, and SHA-256 output remain unchanged so deployed tokens stay valid. No public API signatures change; Output Caching and Response Caching behavior remain unchanged.

The complete change comprises ten commits (5002ac85f14e through 423f6a47ed) and 16 files. CacheView user-varied keys intentionally turn over. Cache Tag Helper now unconditionally versions its internal key format, so all existing memory/distributed entries become misses and expire normally rather than risking overlap with the former unescaped Identity.Name suffix.

Design

The shared primitive is internal source, compiled into each consumer assembly:

// src/Shared/SecurityHelper/SecurityHelper.cs
// Callers provide the SHA-256 destination; no result array is allocated.
internal const int UserIdentifierSize = SHA256.HashSizeInBytes;

public static bool TryGetUserIdentifier(
    ClaimsPrincipal? principal,
    Span<byte> destination);

The identifier contract, documented on the existing Cache Tag Helper and CacheView VaryByUser properties, is:

  • Traverse authenticated identities in principal order.
  • Within each identity, check exact-ordinal claim types in precedence order: sub, ClaimTypes.NameIdentifier, then ClaimTypes.Upn; hash the selected claim's ordinal (type, value, issuer) tuple.
  • If no recognized claim is available, collect every claim from authenticated identities, sort by claim type exactly as Antiforgery did, and hash every (type, value, issuer) tuple. Equal-type fallback order intentionally retains the previous runtime sort behavior.
  • Return false for a null principal or when authenticated identities contribute no claims.
  • Applications must provide trusted, unique, stable claim tuples and issuers; identity and relevant claim ordering must be deterministic, and claims transformations must run before key generation.

Authentication type and Identity.Name are not part of the shared identifier. Cache consumers separately domain claimless authenticated principals from anonymous principals because content can depend on IsAuthenticated. Antiforgery's later Identity.Name fallback remains Antiforgery-specific.

Cache Tag Helper keeps the existing VaryByUser property and vary-by-user attribute. Versioning is an unconditional private cache-key token, not a new parameter: keys begin <CacheTagHelper-or-DistributedCacheTagHelper>||v1||<key>, and user variance still uses VaryByUser. The public CacheView, CacheTagHelper, and DistributedCacheTagHelper type documentation now states consistently that generated keys are framework implementation details, may change between ASP.NET Core product versions, and can cause older entries to become misses and expire normally; it does not expose a stable key format or promise migration.

Output Caching continues to reject authenticated/Authorization requests under its default policy, with custom VaryByValue as explicit opt-in; Response Caching continues to follow HTTP shared-cache rules. Neither subsystem gains principal-based variance or a new API.

Implementation

The common path scans standard list-backed principals and identities as spans and returns as soon as it finds the first recognized claim. Only unusual identity enumerables, the all-claims fallback, or serialized inputs over 256 bytes use pooled buffers:

// src/Shared/SecurityHelper/SecurityHelper.cs
private static bool TryGetUserIdentifier(
    ReadOnlySpan<ClaimsIdentity> identities,
    Span<byte> destination)
{
    for (var i = 0; i < identities.Length; i++)
    {
        var identity = identities[i];
        if (!identity.IsAuthenticated)
        {
            continue;
        }

        var identifierClaim = FindUserIdentifierClaim(identity);
        if (identifierClaim is not null)
        {
            ComputeSha256(identifierClaim, destination);
            return true; // common well-known-claim path: no pool rental
        }
    }

    // No recognized claim: rent, collect authenticated claims, sort by ordinal type, and hash.
    Claim[] rentedClaims = ArrayPool<Claim>.Shared.Rent(InitialPoolSize);
    // (collection/growth omitted)
    var claims = rentedClaims.AsSpan(0, claimCount);
    claims.Sort(static (a, b) => string.Compare(a.Type, b.Type, StringComparison.Ordinal));
    ComputeSha256(claims, destination);
    return true;
}

Exact ClaimsIdentity instances use the direct scan; derived identities preserve virtual FindFirst(Predicate<Claim>) dispatch because changing an override's result could invalidate existing Antiforgery tokens. Hash input preserves BinaryWriter.Write(string) framing: UTF-8 bytes preceded by a 7-bit encoded byte length. For example, byte length 200 is encoded as 0xC8, 0x01 before the payload:

// src/Shared/SecurityHelper/SecurityHelper.cs
var buffer = totalSize <= StackAllocThreshold
    ? stackalloc byte[StackAllocThreshold]
    : (rentedBuffer = ArrayPool<byte>.Shared.Rent(totalSize));

var offset = Write7BitEncodedString(buffer, claim.Type);
offset += Write7BitEncodedString(buffer[offset..], claim.Value);
offset += Write7BitEncodedString(buffer[offset..], claim.Issuer);
SHA256.HashData(buffer[..offset], destination);

Antiforgery retains its service/interface boundary and delegates only extraction:

// src/Antiforgery/src/Internal/DefaultClaimUidExtractor.cs
public bool TryExtractClaimUidBytes(ClaimsPrincipal claimsPrincipal, Span<byte> destination)
{
    Debug.Assert(claimsPrincipal != null);
    return SecurityHelper.TryGetUserIdentifier(claimsPrincipal, destination);
}

CacheView appends one of three disjoint domains inside its enclosing SHA-256 key:

// src/Components/Endpoints/src/CacheView/CacheViewKeyResolver.cs
Span<byte> userIdentifier = stackalloc byte[SecurityHelper.UserIdentifierSize];
if (!SecurityHelper.TryGetUserIdentifier(user, userIdentifier))
{
    AppendLengthPrefixedString(
        hash,
        SecurityHelper.IsAuthenticated(user) ? "AuthenticatedWithoutIdentifier" : "Anonymous");
    return;
}

AppendLengthPrefixedString(hash, "Identifier");
hash.AppendData(userIdentifier);

MVC Cache Tag Helper uses the same three states in both equality and generated text. Its format version is unconditional and the existing user token is unchanged:

// src/Mvc/Mvc.TagHelpers/src/Cache/CacheTagKey.cs
var builder = new StringBuilder(_prefix);
builder
    .Append(CacheKeyTokenSeparator)
    .Append(CacheKeyVersion) // "v1" for every key, regardless of vary options
    .Append(CacheKeyTokenSeparator)
    .Append(Key);

// Later, only when VaryByUser is enabled:
builder
    .Append(CacheKeyTokenSeparator)
    .Append(VaryByUserName) // still "VaryByUser"
    .Append(CacheKeyTokenSeparator)
    .Append(_userIdentifierName);

The version token prevents a legacy entry for the same logical key—...||VaryByUser||<unescaped Identity.Name>—from matching the new format. Without a versioned domain, an old name NoIdentifier exactly matched the initial new anonymous suffix, and Identifier||<digest> could match an identified suffix. The prerequisite is narrow (a surviving distributed entry and matching old name), but the consequence is cross-user cached-content reuse.

TagHelpers' shared helper copy uses Microsoft.AspNetCore.Mvc.TagHelpers.Internal, selected by MVC_TAGHELPERS, avoiding a collision with referenced MVC assemblies without suppressing CS0436. Antiforgery and Components Endpoints compile it under Microsoft.Extensions.Internal.

Outcome

Boundary Red evidence Green evidence
Cache Tag Helper identifier collision Distinct NameIdentifier-only users with null names produced equal keys; memory and distributed rows failed. Both rows pass, plus tuple, precedence, fallback, and disabled-variance coverage.
CacheView semantic divergence Equal NameIdentifier plus different sub values produced equal keys under the old NameIdentifier-first algorithm. sub precedence passes, with issuer, fallback, AuthenticationType, and marker coverage.
Claimless authenticated separation Before 409406c6c7, targeted TagHelpers failed 3/3 and Components failed 1/1 because anonymous and authenticated-without-claims keys were equal. Three-state domains pass in CacheTagKey equality/generated text and CacheView hashes.
Legacy key isolation Initial new keys matched old keys for names NoIdentifier and `Identifier
Area Exact validation Result
Shared helper dotnet test .\src\Shared\test\Shared.Tests\Microsoft.AspNetCore.Shared.Tests.csproj --filter "FullyQualifiedName~SecurityHelperTests" --no-restore -v:q -p:UseIisNativeAssets=false 28 passed
Antiforgery dotnet test .\src\Antiforgery\test\Microsoft.AspNetCore.Antiforgery.Test.csproj --no-restore -v:q -p:UseIisNativeAssets=false 186 passed
MVC TagHelpers dotnet test .\src\Mvc\Mvc.TagHelpers\test\Microsoft.AspNetCore.Mvc.TagHelpers.Test.csproj --no-restore -v:q -p:UseIisNativeAssets=false 946 passed
Components Endpoints dotnet test .\src\Components\Endpoints\test\Microsoft.AspNetCore.Components.Endpoints.Tests.csproj --no-restore -v:q -p:UseIisNativeAssets=false 880 passed
Public docs builds MVC TagHelpers and Components Endpoints source projects, XML docs enabled succeeded, 0 warnings/errors
Output Caching Focused authenticated/Authorization default-policy checks 2 passed
Response Caching Focused HTTP shared-cache checks 11 passed
Benchmark project Release build with UseIisNativeAssets=false succeeded
Hygiene Changed-file formatting, git diff --check, public API inspection passed; no API baseline changes

Antiforgery compatibility is pinned at both layers, including known digest yhXE+2v4zSXHtRHmzm4cmrhZca2J0g7yTUwtUerdeF4= and long multibyte UTF-8/multi-byte-length digest n68b/ma1cEcRL2AyEiy3YE4zE+qGpgMJda+FFN+oqAk=. Extractor-level tests retain the original scenarios with fixed digest outputs.

Benchmark scenario Previous Current Ratio Previous allocation Current allocation
Subject fast path 420.3 ns 432.6 ns 1.03 120 B 0 B
Typical NameIdentifier 564.9 ns 456.7 ns 0.81 160 B 0 B
Small claims fallback 1,013.0 ns 864.7 ns 0.85 360 B 0 B
Multiple identities 546.9 ns 463.3 ns 0.85 200 B 0 B
Large fallback stress 67.07 us 64.85 us 0.96 2,280 B 0 B

The subject result was noisy and shows a nominal 3% time regression while removing 120 B; other measured scenarios improved and all standard warmed paths reached 0 B/op. These are extraction microbenchmarks, not end-to-end request measurements. Custom enumerable/derived identity implementations can allocate internally, and an ArrayPool miss can allocate.

Copilot AI lite review requested due to automatic review settings September 15, 2026 11:55
@github-actions github-actions Bot added area-blazor Includes: Blazor, Razor Components area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates area-security Includes: antiforgery, CSP, and security features other than authentication and authorization labels Sep 15, 2026

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.

🟡 Changes recommended

Critical anonymous/authenticated cache-key collisions and a moderate legacy-key collision remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Unifies authenticated-user identity derivation across Antiforgery, Cache Tag Helpers, and CacheView while preserving Antiforgery compatibility.

Changes:

  • Centralizes claim identifier extraction and hashing in SecurityHelper.
  • Updates cache keys with identifier markers and shared identity behavior.
  • Adds documentation and focused compatibility and cache-key tests.

Unresolved findings:

  • CacheViewKeyResolver.cs: critical (1 vote)—authenticated principals without identifiers can share keys with anonymous users.
  • CacheTagKey.cs: critical (1 vote)—the same anonymous/authenticated collision exists.
  • CacheTagKey.cs: moderate (1 vote)—legacy distributed-cache keys may collide with the new format.
File summaries
File Summary
src/Shared/test/Shared.Tests/SecurityHelperTests.cs Tests shared identifier extraction and hashing.
src/Shared/SecurityHelper/SecurityHelper.cs Provides the shared user-identifier algorithm.
src/Mvc/Mvc.TagHelpers/test/CacheTagKeyTest.cs Tests Cache Tag Helper key behavior.
src/Mvc/Mvc.TagHelpers/src/Microsoft.AspNetCore.Mvc.TagHelpers.csproj Includes shared helper sources.
src/Mvc/Mvc.TagHelpers/src/CacheTagHelperBase.cs Documents user-variation behavior.
src/Mvc/Mvc.TagHelpers/src/Cache/CacheTagKey.cs Generates claim-based cache keys; contains unresolved collision findings.
src/Components/Endpoints/test/CacheViewKeyResolverTest.cs Tests CacheView key resolution.
src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj Includes shared helper sources.
src/Components/Endpoints/src/CacheView/CacheViewKeyResolver.cs Generates CacheView user keys; contains an unresolved collision finding.
src/Components/Endpoints/src/CacheView/CacheView.cs Documents CacheView user variation.
src/Antiforgery/test/DefaultClaimUidExtractorTest.cs Verifies Antiforgery compatibility.
src/Antiforgery/src/Microsoft.AspNetCore.Antiforgery.csproj Includes shared helper sources.
src/Antiforgery/src/Internal/DefaultClaimUidExtractor.cs Delegates identifier extraction to shared logic.
Review details

Suppressed comments (1)

src/Mvc/Mvc.TagHelpers/src/Cache/CacheTagKey.cs:156

  • This changes the user-varying suffix without changing the legacy VaryByUser token, so old distributed-cache entries are not guaranteed to become misses. For example, an old entry for an authenticated user whose Identity.Name is NoIdentifier has exactly the new anonymous key, allowing personalized content to be reused for a no-identifier request; an old name of Identifier||<digest> can similarly collide with the new identifier form. Version or otherwise namespace the user-varying key format (and update both helper variants' expectations) so no legacy key can match.
                .Append(VaryByUserName)
                .Append(CacheKeyTokenSeparator)
                .Append(_userIdentifier is null ? NoUserIdentifierName : UserIdentifierName);
  • Files reviewed: 13/13 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Components/Endpoints/src/CacheView/CacheViewKeyResolver.cs
Comment thread src/Mvc/Mvc.TagHelpers/src/Cache/CacheTagKey.cs Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-blazor Includes: Blazor, Razor Components area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates area-security Includes: antiforgery, CSP, and security features other than authentication and authorization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants