← Duende SkillsCONTENT HISTORY

Update to Duende Skills

Snapshot Sep 30, 2026 · 23:14 UTC · version 0.3.0

Collection source: not recorded for this historical snapshot.

WHAT CHANGED · RULE-BASED ANALYSIS

First saved snapshot

No earlier snapshot is available to establish a change.

Compare saved observations

Download comparison JSON
Full technical diff · 0 changed fields
Full snapshot data
{
  "description": "Token management patterns using Duende.AccessTokenManagement. Covers client credential token caching, user token refresh, token storage, HttpClientFactory integration, DPoP support, and common configuration pitfalls. Also includes Blazor Server token management.",
  "included_files": [
    {
      "relative_path": "docs/api-delegation.md",
      "size_in_bytes": 2380
    },
    {
      "relative_path": "docs/blazor-server.md",
      "size_in_bytes": 4740
    },
    {
      "relative_path": "docs/client-assertions.md",
      "size_in_bytes": 4237
    },
    {
      "relative_path": "docs/customization.md",
      "size_in_bytes": 4093
    },
    {
      "relative_path": "docs/dpop.md",
      "size_in_bytes": 3431
    }
  ],
  "name": "token-management",
  "skill_md_contents": "---\nname: token-management\ndescription: Token management patterns using Duende.AccessTokenManagement. Covers client credential token caching, user token refresh, token storage, HttpClientFactory integration, DPoP support, and common configuration pitfalls. Also includes Blazor Server token management.\ninvocable: false\n---\n\n# Token Management\n\n## When to Use This Skill\n\nUse this skill when:\n- Building a .NET worker service or daemon that calls APIs using the client credentials flow\n- Building an ASP.NET Core web application that calls APIs on behalf of the currently logged-in user\n- Integrating `Duende.AccessTokenManagement` or `Duende.AccessTokenManagement.OpenIdConnect` with `IHttpClientFactory`\n- Configuring token caching — in-memory, distributed (Redis), or hybrid — for machine-to-machine tokens\n- Adding DPoP (Demonstrating Proof-of-Possession) key binding to access tokens\n- Implementing API-to-API delegation where a downstream service calls further APIs with either user tokens or client credentials\n- Revoking refresh tokens on user sign-out\n\n## Core Principles\n\n1. **Prefer Automatic Over Manual** — Use `IHttpClientFactory`-integrated clients; they acquire, cache, refresh, and attach tokens transparently. Call `GetAccessTokenAsync` manually only when the factory pattern is insufficient.\n2. **Never Cache Tokens in Code** — The library owns the cache. Do not store tokens in instance fields, static variables, or application-managed caches. Call the service on every request and let it serve from cache.\n3. **`SaveTokens = true` Is Required for User Tokens** — The OIDC handler must persist tokens into the authentication session. This is the most common misconfiguration.\n4. **Refresh Tokens Must Be Revoked at Sign-Out** — Call `e.HttpContext.RevokeRefreshTokenAsync()` in `OnSigningOut` to revoke the refresh token at the authorization server, preventing reuse after logout.\n5. **v4 Uses `HybridCache`; v3 Uses `IDistributedCache`** — The caching layer changed between major versions. v4's `HybridCache` is two-tier and automatic; v3 requires an explicit `AddDistributedMemoryCache()` or Redis registration.\n6. **Resiliency Is Included in `AddClientCredentialsHttpClient`** — This registration adds a once-retry handler for `401 Unauthorized` responses (handles token expiry and DPoP nonce challenges). When using `AddClientCredentialsTokenHandler` directly, add it explicitly.\n\n## Related Skills\n\n- `aspnetcore-authentication` — cookie and OIDC handler setup required for user token management\n- `identityserver-configuration` — configuring the authorization server that issues tokens\n- `oauth-oidc-protocols` — protocol fundamentals underlying client credentials and refresh token flows\n- `duende-bff` — BFF pattern integrates this library automatically for proxied API calls\n\nDocs: https://docs.duendesoftware.com/accesstokenmanagement/\n\n---\n\n## Pattern 1: Machine-to-Machine (Client Credentials) — Worker Services\n\n### Package\n\n```bash\ndotnet add package Duende.AccessTokenManagement\n```\n\n### Registration\n\n```csharp\n// ✅ Register one or more named client definitions\nservices.AddClientCredentialsTokenManagement()\n    .AddClient(\"catalog.client\", client =>\n    {\n        client.TokenEndpoint = new Uri(\"https://sts.company.com/connect/token\");\n        client.ClientId = ClientId.Parse(\"6f59b670-990f-4ef7-856f-0dd584ed1fac\");\n        client.ClientSecret = ClientSecret.Parse(\"d0c17c6a-ba47-4654-a874-f6d576cdf799\");\n        client.Scope = Scope.Parse(\"catalog inventory\");\n    })\n    .AddClient(\"invoice.client\", client =>\n    {\n        client.TokenEndpoint = new Uri(\"https://sts.company.com/connect/token\");\n        client.ClientId = ClientId.Parse(\"ff8ac57f-5ade-47f1-b8cd-4c2424672351\");\n        client.ClientSecret = ClientSecret.Parse(\"4dbbf8ec-d62a-4639-b0db-aa5357a0cf46\");\n        client.Scope = Scope.Parse(\"invoice customers\");\n    });\n```\n\nAvailable client options:\n- `TokenEndpoint` — URL of the OAuth token endpoint\n- `ClientId` / `ClientSecret` — client credentials\n- `ClientCredentialStyle` — `AuthorizationHeader` (default) or `PostBody`\n- `Scope` — requested scope (optional; overridable per request)\n- `Resource` — resource indicator per RFC 8707 (optional)\n- `HttpClientName` — custom backchannel HTTP client name from the factory\n- `DPoPJsonWebKey` — JWK for DPoP-bound tokens (see Pattern 5)\n\n### Automatic via HttpClientFactory (Recommended)\n\n```csharp\n// ✅ Named client — token acquired, cached, and attached automatically\nservices.AddClientCredentialsHttpClient(\n    \"invoices\",\n    ClientCredentialsClientName.Parse(\"invoice.client\"),\n    client => { client.BaseAddress = new Uri(\"https://apis.company.com/invoice/\"); });\n\n// ✅ Typed client — identical behaviour, strongly typed\nservices.AddHttpClient<CatalogClient>(client =>\n    {\n        client.BaseAddress = new Uri(\"https://apis.company.com/catalog/\");\n    })\n    .AddClientCredentialsTokenHandler(ClientCredentialsClientName.Parse(\"catalog.client\"));\n```\n\nUsage — no token code required at the call site:\n\n```csharp\npublic sealed class WorkerHttpClient(IHttpClientFactory factory) : BackgroundService\n{\n    protected override async Task ExecuteAsync(CancellationToken stoppingToken)\n    {\n        while (!stoppingToken.IsCancellationRequested)\n        {\n            // ✅ Token acquired, cached, and refreshed transparently\n            var client = factory.CreateClient(\"invoices\");\n            var response = await client.GetAsync(\"list\", stoppingToken);\n            // ...\n        }\n    }\n}\n```\n\n> **Resiliency handler** — `AddClientCredentialsHttpClient` automatically adds a resiliency handler that retries once on `401 Unauthorized`. This covers token expiry and DPoP nonce challenges. When using `AddClientCredentialsTokenHandler` directly, add it explicitly:\n>\n> ```csharp\n> services.AddHttpClient<CatalogClient>(...)\n>     .AddDefaultAccessTokenResiliency()\n>     .AddClientCredentialsTokenHandler(\"catalog.client\");\n> ```\n\n### Manual Token Retrieval (Advanced)\n\n```csharp\n// ✅ Inject IClientCredentialsTokenManager (v4)\npublic sealed class WorkerManual(\n    IHttpClientFactory factory,\n    IClientCredentialsTokenManager tokenManager) : BackgroundService\n{\n    protected override async Task ExecuteAsync(CancellationToken stoppingToken)\n    {\n        while (!stoppingToken.IsCancellationRequested)\n        {\n            var tokenResult = await tokenManager.GetAccessTokenAsync(\n                ClientCredentialsClientName.Parse(\"catalog.client\"),\n                ct: stoppingToken);\n\n            if (!tokenResult.Succeeded)\n            {\n                // log and handle — do not call .GetToken() without checking first\n                await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);\n                continue;\n            }\n\n            var token = tokenResult.GetToken();\n            var client = factory.CreateClient();\n            client.SetBearerToken(token.AccessToken.ToString());\n            var response = await client.GetAsync(\"https://apis.company.com/catalog/list\", stoppingToken);\n            // ...\n        }\n    }\n}\n```\n\n> In v3, the service was `IClientCredentialsTokenManagementService` and the result was read via `.Value`. In v4 it is `IClientCredentialsTokenManager` and the result is `TokenResult<ClientCredentialsToken>` — use `.Succeeded` / `.GetToken()`.\n\n---\n\n## Pattern 2: User Token Management — Web Applications\n\n### Package\n\n```bash\ndotnet add package Duende.AccessTokenManagement.OpenIdConnect\n```\n\n### Registration\n\n```csharp\n// ✅ Full setup: cookie + OIDC handler + token management\nbuilder.Services.AddAuthentication(options =>\n    {\n        options.DefaultScheme = \"cookie\";\n        options.DefaultChallengeScheme = \"oidc\";\n    })\n    .AddCookie(\"cookie\", options =>\n    {\n        options.Cookie.Name = \"web\";\n        // ✅ Revoke refresh token at sign-out\n        options.Events.OnSigningOut = async e =>\n        {\n            await e.HttpContext.RevokeRefreshTokenAsync();\n        };\n    })\n    .AddOpenIdConnect(\"oidc\", options =>\n    {\n        options.Authority = \"https://sts.company.com\";\n        options.ClientId = \"webapp\";\n        options.ClientSecret = \"secret\";\n        options.ResponseType = \"code\";\n        options.ResponseMode = \"query\";\n\n        options.Scope.Clear();\n        options.Scope.Add(\"openid\");\n        options.Scope.Add(\"profile\");\n        options.Scope.Add(\"email\");\n        options.Scope.Add(\"invoice\");\n        options.Scope.Add(\"offline_access\"); // ← required for refresh tokens\n\n        options.GetClaimsFromUserInfoEndpoint = true;\n        options.MapInboundClaims = false;\n\n        // ✅ REQUIRED — persists access and refresh tokens into the auth session\n        options.SaveTokens = true;\n    });\n\n// ✅ Adds all token management services\nbuilder.Services.AddOpenIdConnectAccessTokenManagement();\n```\n\n> **`SaveTokens = true` is mandatory.** Without it, the library cannot read or refresh the user's access token. This is the most common misconfiguration causing `InvalidOperationException` at runtime.\n\n### Automatic via HttpClientFactory (Recommended)\n\n```csharp\n// ✅ Named client using the current user's access token\nbuilder.Services.AddUserAccessTokenHttpClient(\n    \"invoices\",\n    configureClient: client =>\n    {\n        client.BaseAddress = new Uri(\"https://api.company.com/invoices/\");\n    });\n\n// ✅ Typed client using the current user's access token\nbuilder.Services.AddHttpClient<InvoiceClient>(client =>\n    {\n        client.BaseAddress = new Uri(\"https://api.company.com/invoices/\");\n    })\n    .AddUserAccessTokenHandler();\n\n// ✅ Named client using a client credentials token (machine-to-machine, user-independent)\nbuilder.Services.AddClientAccessTokenHttpClient(\n    \"masterdata.client\",\n    configureClient: client =>\n    {\n        client.BaseAddress = new Uri(\"https://api.company.com/masterdata/\");\n    });\n\n// ✅ Typed client using a client credentials token\nbuilder.Services.AddHttpClient<MasterDataClient>(client =>\n    {\n        client.BaseAddress = new Uri(\"https://api.company.com/masterdata/\");\n    })\n    .AddClientAccessTokenHandler();\n```\n\nUsage in a controller:\n\n```csharp\npublic sealed class ApiController(IHttpClientFactory httpClientFactory) : Controller\n{\n    public async Task<IActionResult> CallApi(CancellationToken ct)\n    {\n        // ✅ Token attached automatically; refreshed silently if expired\n        var client = httpClientFactory.CreateClient(\"invoices\");\n        var response = await client.GetAsync(\"list\", ct);\n        // ...\n    }\n}\n```\n\n### Manual Token Retrieval (Advanced)\n\n```csharp\n// ✅ v4 — inject IUserTokenManager\npublic sealed class HomeController(\n    IHttpClientFactory httpClientFactory,\n    IUserTokenManager userTokenManager) : Controller\n{\n    public async Task<IActionResult> CallApi(CancellationToken ct)\n    {\n        var token = await userTokenManager.GetAccessTokenAsync(User, ct: ct);\n        var client = httpClientFactory.CreateClient();\n        client.SetBearerToken(token.Value);\n        var response = await client.GetAsync(\"https://api.company.com/invoices\", ct);\n        // ...\n    }\n}\n```\n\n`HttpContext` extension methods are also available:\n\n```csharp\n// ✅ User access token — refreshed automatically via refresh token if expired\nvar userToken = await HttpContext.GetUserAccessTokenAsync();\n\n// ✅ Client credentials token — re-requested from the token server if expired\nvar clientToken = await HttpContext.GetClientAccessTokenAsync();\n\n// ✅ Revoke refresh token explicitly (also wired into OnSigningOut above)\nawait HttpContext.RevokeRefreshTokenAsync();\n```\n\n### gRPC Support\n\nUse `AddUserAccessTokenHandler` and `AddClientAccessTokenHandler` when registering typed gRPC clients:\n\n```csharp\n// ✅ gRPC client using the current user's access token\nbuilder.Services.AddGrpcClient<Greeter.GreeterClient>(o =>\n{\n    o.Address = new Uri(\"https://grpc.company.com\");\n})\n.AddUserAccessTokenHandler();\n\n// ✅ gRPC client using a client credentials token\nbuilder.Services.AddGrpcClient<Inventory.InventoryClient>(o =>\n{\n    o.Address = new Uri(\"https://grpc.company.com\");\n})\n.AddClientAccessTokenHandler();\n```\n\n---\n\n## Pattern 3: Token Caching\n\n### v4 — HybridCache (Default)\n\nIn v4, client credentials tokens are cached using `HybridCache` (ASP.NET Core 9+). It is two-tier: in-memory L1 + optional remote L2. No explicit registration is required for the default in-memory tier.\n\n```csharp\n// ✅ Add a distributed remote cache (e.g., Redis) — HybridCache picks it up automatically as L2\nbuilder.Services.AddStackExchangeRedisCache(options =>\n{\n    options.Configuration = builder.Configuration.GetConnectionString(\"Redis\");\n});\n```\n\nGlobal cache options:\n\n```csharp\n// ✅ Configure lifetime buffer and key prefix\nservices.AddClientCredentialsTokenManagement(options =>\n{\n    // Cache tokens 60 s before they expire to avoid serving a near-expired token\n    options.CacheLifetimeBuffer = 60;\n    options.CacheKeyPrefix = \"MyApp.ATM::\";\n});\n```\n\nDefault cache key format:\n\n```\n{CacheKeyPrefix}::{client_name}::hashed({scope})::hashed({resource})\n```\n\n`scope` and `resource` values are MD5-hashed to keep key length bounded. Implement `IClientCredentialsCacheKeyGenerator` to supply custom keys when adding custom `TokenRequestParameters`.\n\n### v3 — IDistributedCache\n\n```csharp\n// ✅ v3: must explicitly register a distributed cache implementation\nservices.AddDistributedMemoryCache(); // development / single-instance only\n\n// ✅ v3: Redis for production\nservices.AddStackExchangeRedisCache(options =>\n{\n    options.Configuration = \"redis.company.com:6379\";\n});\n\nservices.AddClientCredentialsTokenManagement(options =>\n{\n    options.CacheLifetimeBuffer = 60;\n});\n```\n\n### Encrypting Cached Tokens (v4)\n\nWhen sharing a remote cache with other applications, encrypt tokens at rest using a custom `IHybridCacheSerializer<ClientCredentialsToken>`:\n\n```csharp\n// ✅ Register the encrypted serializer for the ClientCredentialsToken type\nservices.AddHybridCache()\n    .AddSerializer<ClientCredentialsToken, EncryptedHybridCacheSerializer>();\n\nservices.AddDataProtection();\n\npublic sealed class EncryptedHybridCacheSerializer : IHybridCacheSerializer<ClientCredentialsToken>\n{\n    private readonly IDataProtector _protector;\n\n    public EncryptedHybridCacheSerializer(IDataProtectionProvider provider)\n    {\n        _protector = provider.CreateProtector(\"ClientCredentialsToken\");\n    }\n\n    public ClientCredentialsToken Deserialize(ReadOnlySequence<byte> source)\n    {\n        var unprotected = _protector.Unprotect(source.ToArray());\n        return JsonSerializer.Deserialize<ClientCredentialsToken>(unprotected)!;\n    }\n\n    public void Serialize(ClientCredentialsToken value, IBufferWriter<byte> target)\n    {\n        var json = JsonSerializer.SerializeToUtf8Bytes(value);\n        target.Write(_protector.Protect(json));\n    }\n}\n```\n\n### Scoping a Custom Cache to This Library Only\n\n```csharp\n// ✅ Inject a custom HybridCache only for AccessTokenManagement (uses service keys)\nservices.AddKeyedSingleton<HybridCache>(\n    ServiceProviderKeys.ClientCredentialsTokenCache,\n    new MyCustomCacheImplementation());\n```\n\n---\n\n## Pattern 4: Configuration Options\n\n### `ClientCredentialsTokenManagementOptions`\n\n```csharp\nservices.AddClientCredentialsTokenManagement(options =>\n{\n    options.CacheLifetimeBuffer = 60;            // seconds subtracted from token lifetime in cache\n    options.CacheKeyPrefix = \"MyApp.ATM::\";      // prefix for all cache keys\n});\n```\n\n### `UserTokenManagementOptions`\n\n```csharp\nbuilder.Services.AddOpenIdConnectAccessTokenManagement(options =>\n{\n    // Override the OIDC challenge scheme if not using the default\n    options.ChallengeScheme = \"oidc\";\n\n    // Enable separate token stores per OIDC scheme (multi-provider setups)\n    options.UseChallengeSchemeScopedTokens = false;\n\n    // Scope and resource sent when requesting client credentials tokens from\n    // the configured OIDC provider (cannot be inferred from OIDC metadata)\n    options.ClientCredentialsScope = \"api1 api2\";\n    options.ClientCredentialsResource = \"urn:myapi\";\n\n    // How client credentials are sent to the token endpoint\n    options.ClientCredentialStyle = ClientCredentialStyle.PostBody;\n\n    // DPoP key for all user token requests from this application\n    options.DPoPJsonWebKey = jwk;\n});\n```\n\n### Per-Request Parameter Overrides\n\n```csharp\n// ✅ Force a fresh token even if a cached one exists\nvar token = await tokenManager.GetAccessTokenAsync(\n    ClientCredentialsClientName.Parse(\"catalog.client\"),\n    new TokenRequestParameters { ForceRenewal = true },\n    ct: stoppingToken);\n\n// ✅ Override scope per user token request\nvar token = await userTokenManager.GetAccessTokenAsync(\n    User,\n    new UserTokenRequestParameters\n    {\n        Scope = \"invoice:write\",\n        ForceRenewal = false,\n        ChallengeScheme = \"oidc\"\n    },\n    ct: ct);\n```\n\nFor `IHttpClientFactory` clients, parameters are wired at registration time:\n\n```csharp\nbuilder.Services.AddUserAccessTokenHttpClient(\n    \"invoices\",\n    parameters: new UserTokenRequestParameters { ForceRenewal = true },\n    configureClient: client => { client.BaseAddress = new Uri(\"https://api.company.com/invoices/\"); });\n```\n\n---\n\n## Pattern 5: DPoP (Demonstrating Proof-of-Possession)\n\nDPoP binds an access token to a client-held asymmetric key, preventing replay attacks even if the token is stolen. Configure it by setting `DPoPJsonWebKey` on the client credentials client or `UserTokenManagementOptions`, and optionally implement `IDPoPKeyStore` for runtime key rotation.\n\n> **Full details in sub-document** — See [`docs/dpop.md`](docs/dpop.md) for JWK generation, per-client configuration, `IDPoPKeyStore`, session size implications, and the key-persistence pitfall.\n\n---\n\n## Pattern 6: API-to-API Token Delegation\n\nAn API can either forward the user's access token to a downstream API (when the downstream accepts the same audience) or use a dedicated client credentials token (when the downstream requires a service identity).\n\n> **Full details in sub-document** — See [`docs/api-delegation.md`](docs/api-delegation.md) for both approaches with complete code examples and a decision guide.\n\n---\n\n## Pattern 7: Dynamic Client Configuration\n\nUse `IConfigureNamedOptions<ClientCredentialsClient>` when token endpoint configuration must be resolved at runtime — for example, from OIDC discovery:\n\n```csharp\npublic sealed class ClientCredentialsConfigureOptions(DiscoveryCache cache)\n    : IConfigureNamedOptions<ClientCredentialsClient>\n{\n    public void Configure(string? name, ClientCredentialsClient options)\n    {\n        if (name == \"catalog.client\")\n        {\n            // ✅ Resolve token endpoint from OIDC discovery document\n            var disco = cache.GetAsync().GetAwaiter().GetResult();\n            options.TokenEndpoint = new Uri(disco.TokenEndpoint);\n            options.ClientId = ClientId.Parse(\"...\");\n            options.ClientSecret = ClientSecret.Parse(\"...\");\n            options.Scope = Scope.Parse(\"catalog\");\n        }\n    }\n\n    public void Configure(ClientCredentialsClient options) => Configure(string.Empty, options);\n}\n\n// Registration\nservices.AddClientCredentialsTokenManagement();\nservices.AddSingleton(new DiscoveryCache(\"https://sts.company.com\"));\nservices.AddSingleton<IConfigureOptions<ClientCredentialsClient>, ClientCredentialsConfigureOptions>();\n```\n\n---\n\n## Pattern 8: Custom Token Storage\n\n### User Tokens — Replace the Default Cookie Session Store\n\nBy default, user access and refresh tokens are stored inside the ASP.NET Core authentication cookie. Replace `IUserTokenStore` when this is insufficient — for example, when using server-side sessions:\n\n```csharp\n// ✅ Register a custom implementation backed by server-side session storage\nbuilder.Services.AddSingleton<IUserTokenStore, ServerSideSessionUserTokenStore>();\n```\n\n### Client Credentials — Replace the Cache Implementation\n\n```csharp\n// ✅ Override the entire cache with a custom IClientCredentialsTokenCache\nservices.AddSingleton<IClientCredentialsTokenCache, MyCustomTokenCache>();\n```\n\n---\n\n## Pattern 9: Blazor Server Token Management\n\nBlazor Server circuits outlive the initial HTTP request. Once a circuit is established, `HttpContext` is `null`, making the default cookie-based `IUserTokenStore` unusable. Use `AddBlazorServerAccessTokenManagement<T>()` with a persistent `IUserTokenStore` (e.g., database-backed), and capture tokens in `OnTokenValidated` during the initial OIDC flow.\n\n> **Full details in sub-document** — See [`docs/blazor-server.md`](docs/blazor-server.md) for the full `IUserTokenStore` implementation, `OnTokenValidated` setup, and the `HttpContext`-null pitfall.\n\n---\n\n## Pattern 10: Client Assertions (private_key_jwt)\n\nUse `IClientAssertionService` to authenticate with signed JWTs instead of shared client secrets. **CRITICAL**: set the JWT `Audience` to the authorization server's issuer URL — NOT the token endpoint URL. Using the token endpoint URL was the root cause of CVE-2025-27370 and CVE-2025-27371.\n\n> **Full details in sub-document** — See [`docs/client-assertions.md`](docs/client-assertions.md) for the full `IClientAssertionService` implementation, correct audience configuration, and CVE context.\n\n---\n\n## Pattern 11: Custom Token Request Customization\n\nUse `ITokenRequestCustomizer` (v4) to dynamically modify token request parameters per outgoing HTTP request — useful for multi-tenant scenarios where different tenants require different API resources or scopes. Use the `with` expression to create a modified copy of `baseParameters`; do not mutate it.\n\n> **Full details in sub-document** — See [`docs/customization.md`](docs/customization.md) for the full `ITokenRequestCustomizer` implementation and registration pattern.\n\n---\n\n## Pattern 12: Custom Token Retrieval\n\nImplement `AccessTokenRequestHandler.ITokenRetriever` to completely replace the default token retrieval logic with custom selection or caching behavior.\n\n> **Full details in sub-document** — See [`docs/customization.md`](docs/customization.md) for the full `ITokenRetriever` implementation and `AddHttpMessageHandler` registration pattern.\n\n---\n\n## Sub-Documents\n\nLoad these sub-documents when the user's question specifically targets one of these areas:\n\n| Document | Description | When to Load |\n|----------|-------------|--------------|\n| [docs/dpop.md](docs/dpop.md) | DPoP proof-of-possession token binding | DPoP, `DPoPJsonWebKey`, `IDPoPKeyStore`, key generation, key rotation |\n| [docs/blazor-server.md](docs/blazor-server.md) | Blazor Server circuit-scoped token management | Blazor, SignalR circuit, `HttpContext` null, `AddBlazorServerAccessTokenManagement` |\n| [docs/client-assertions.md](docs/client-assertions.md) | Client assertions (private_key_jwt) + CVE guidance | `IClientAssertionService`, JWT client auth, CVE-2025-27370, CVE-2025-27371 |\n| [docs/customization.md](docs/customization.md) | `ITokenRequestCustomizer` & `ITokenRetriever` | Multi-tenant token params, custom token retrieval, `AccessTokenRequestHandler` |\n| [docs/api-delegation.md](docs/api-delegation.md) | API-to-API delegation patterns | Downstream API calls, forwarding user tokens, service identity |\n\n---\n\n## Complete Example: Web App with User and Client Credentials\n\n```csharp\n// Program.cs\nvar builder = WebApplication.CreateBuilder(args);\n\n// User token management via OIDC\nbuilder.Services.AddAuthentication(options =>\n{\n    options.DefaultScheme = \"cookie\";\n    options.DefaultChallengeScheme = \"oidc\";\n})\n.AddCookie(\"cookie\", options =>\n{\n    options.Cookie.Name = \"web\";\n    options.Events.OnSigningOut = async e =>\n    {\n        await e.HttpContext.RevokeRefreshTokenAsync();\n    };\n})\n.AddOpenIdConnect(\"oidc\", options =>\n{\n    options.Authority = \"https://identity.example.com\";\n    options.ClientId = \"web_app\";\n    options.ClientSecret = \"secret\";\n    options.ResponseType = \"code\";\n    options.Scope.Add(\"api1\");\n    options.Scope.Add(\"offline_access\");\n    options.SaveTokens = true;\n});\n\nbuilder.Services.AddOpenIdConnectAccessTokenManagement();\n\n// User token HTTP client — attaches the logged-in user's access token\nbuilder.Services.AddUserAccessTokenHttpClient(\"user-api\",\n    configureClient: client =>\n    {\n        client.BaseAddress = new Uri(\"https://api.example.com\");\n    });\n\n// Client credentials for service-to-service (v4 types)\nbuilder.Services.AddClientCredentialsTokenManagement()\n    .AddClient(ClientCredentialsClientName.Parse(\"service-client\"), client =>\n    {\n        client.TokenEndpoint = new Uri(\"https://identity.example.com/connect/token\");\n        client.ClientId = ClientId.Parse(\"web_app_service\");\n        client.ClientSecret = ClientSecret.Parse(\"service_secret\");\n        client.Scope = Scope.Parse(\"backend.api\");\n    });\n\nbuilder.Services.AddClientCredentialsHttpClient(\"service-api\",\n    ClientCredentialsClientName.Parse(\"service-client\"),\n    configureClient: client =>\n    {\n        client.BaseAddress = new Uri(\"https://backend.example.com\");\n    });\n\nvar app = builder.Build();\n\napp.UseAuthentication();\napp.UseAuthorization();\n\n// User-context API call\napp.MapGet(\"/user-data\", async (IHttpClientFactory factory) =>\n{\n    var client = factory.CreateClient(\"user-api\");\n    var response = await client.GetAsync(\"/data\");\n    return Results.Ok(await response.Content.ReadAsStringAsync());\n}).RequireAuthorization();\n\n// Service-to-service API call (no user context needed)\napp.MapGet(\"/backend-data\", async (IHttpClientFactory factory) =>\n{\n    var client = factory.CreateClient(\"service-api\");\n    var response = await client.GetAsync(\"/internal/data\");\n    return Results.Ok(await response.Content.ReadAsStringAsync());\n});\n\napp.Run();\n```\n\n---\n\n## Common Pitfalls\n\n### 1. Missing `SaveTokens = true` for User Tokens\n\n```csharp\n// ❌ Tokens never stored in session — library throws InvalidOperationException at runtime\n.AddOpenIdConnect(\"oidc\", options =>\n{\n    // SaveTokens not set — defaults to false\n});\n\n// ✅ Always set it when using AddOpenIdConnectAccessTokenManagement\noptions.SaveTokens = true;\n```\n\n### 2. Missing `offline_access` Scope\n\n```csharp\n// ❌ No refresh token issued — access token expires and user must re-authenticate\noptions.Scope.Add(\"openid\");\noptions.Scope.Add(\"profile\");\n// offline_access missing\n\n// ✅\noptions.Scope.Add(\"offline_access\");\n```\n\n### 3. Not Revoking Refresh Tokens at Sign-Out\n\n```csharp\n// ❌ Refresh token remains valid at the authorization server after sign-out\n.AddCookie(\"cookie\", options =>\n{\n    // No OnSigningOut handler — refresh token never revoked\n});\n\n// ✅\n.AddCookie(\"cookie\", options =>\n{\n    options.Events.OnSigningOut = async e =>\n    {\n        await e.HttpContext.RevokeRefreshTokenAsync();\n    };\n});\n```\n\n### 4. Caching Tokens Manually Alongside the Library\n\n```csharp\n// ❌ Double-caching — your cache won't stay in sync; stale token after expiry\nprivate string? _cachedToken;\n\npublic async Task<string> GetToken()\n{\n    if (_cachedToken != null) return _cachedToken;\n    var token = await _tokenManager.GetAccessTokenAsync(...);\n    _cachedToken = token.AccessToken.ToString(); // never invalidated\n    return _cachedToken;\n}\n\n// ✅ Call GetAccessTokenAsync every time — the library serves from cache transparently\npublic async Task<string> GetToken(CancellationToken ct)\n{\n    var result = await _tokenManager\n        .GetAccessTokenAsync(ClientCredentialsClientName.Parse(\"my.client\"), ct: ct)\n        .GetToken();\n    return result.AccessToken.ToString();\n}\n```\n\n### 5. Calling `.GetToken()` Without Checking `Succeeded`\n\n```csharp\n// ❌ .GetToken() throws InvalidOperationException when token retrieval fails;\n// the actual error is swallowed unless you inspect Succeeded first\nvar tokenResult = await tokenManager.GetAccessTokenAsync(...);\nvar token = tokenResult.GetToken(); // throws when Succeeded == false\n\n// ✅ Check success before accessing the token value\nvar tokenResult = await tokenManager.GetAccessTokenAsync(...);\nif (!tokenResult.Succeeded)\n{\n    logger.LogError(\"Failed to obtain access token\");\n    return Problem(\"Authentication failure\", statusCode: StatusCodes.Status503ServiceUnavailable);\n}\nvar token = tokenResult.GetToken();\n```\n\n### 6. Using `AddClientCredentialsTokenHandler` Without Resiliency\n\n```csharp\n// ❌ A 401 from an expired token is returned directly to the caller — no retry\nservices.AddHttpClient<CatalogClient>(...)\n    .AddClientCredentialsTokenHandler(\"catalog.client\");\n\n// ✅ Add the resiliency handler before the token handler\nservices.AddHttpClient<CatalogClient>(...)\n    .AddDefaultAccessTokenResiliency()\n    .AddClientCredentialsTokenHandler(\"catalog.client\");\n```\n\n### 7. v3 — Forgetting to Register a Distributed Cache\n\n```csharp\n// ❌ v3: no cache registered — runtime exception on first token request\nservices.AddClientCredentialsTokenManagement()\n    .AddClient(\"catalog.client\", client => { /* ... */ });\n// Missing: services.AddDistributedMemoryCache();\n\n// ✅ v3: always register a distributed cache (in-memory for dev, Redis for prod)\nservices.AddDistributedMemoryCache();\n```\n\n### 8. Regenerating DPoP Keys on Every Process Restart\n\n```csharp\n// ❌ New key generated on every restart — all previously issued DPoP-bound tokens\n// become unusable, causing 401 errors until new tokens are obtained\nvar rsaKey = new RsaSecurityKey(RSA.Create(2048)); // ephemeral — lost on restart\n\n// ✅ Load from stable, secure storage\nvar jwkJson = configuration[\"DPoP:JsonWebKey\"]; // from Key Vault / secrets\nservices.AddClientCredentialsTokenManagement()\n    .AddClient(\"my.client\", client =>\n    {\n        client.DPoPJsonWebKey = jwkJson;\n    });\n```\n\n### 9. Setting Client Assertion Audience to the Token Endpoint URL\n\n```csharp\n// ❌ Audience set to the token endpoint — security vulnerability\n// Root cause of CVE-2025-27370 and CVE-2025-27371\nAudience = \"https://identity.example.com/connect/token\"\n\n// ✅ Audience must be the authorization server's issuer URL\nAudience = \"https://identity.example.com\"\n```\n\n> CVE-2025-27370 and CVE-2025-27371 were caused by this exact mistake. Authorization servers that accept both values allow token endpoint confusion attacks.\n\n### 10. Using `HttpContext` to Access Tokens in Blazor Server Components\n\n```csharp\n// ❌ HttpContext is null after circuit establishment — this will fail at runtime\nvar token = await HttpContext.GetUserAccessTokenAsync(); // throws NullReferenceException\n\n// ✅ Use AddBlazorServerAccessTokenManagement<T>() with a custom IUserTokenStore\nbuilder.Services.AddOpenIdConnectAccessTokenManagement()\n    .AddBlazorServerAccessTokenManagement<ServerSideTokenStore>();\n// Capture tokens in OnTokenValidated (see Pattern 9)\n```\n\n### 11. Setting `CacheLifetimeBuffer` to 0\n\n```csharp\n// ❌ Buffer set to 0 — tokens served until exact expiry; a token may expire\n// in transit between retrieval and use at the API, causing unnecessary 401s\nservices.AddClientCredentialsTokenManagement(options =>\n{\n    options.CacheLifetimeBuffer = 0;\n});\n\n// ✅ Keep the default (60 s) or set a positive value that accounts for network latency\nservices.AddClientCredentialsTokenManagement(options =>\n{\n    options.CacheLifetimeBuffer = 60; // default — refresh 60 s before expiry\n});\n```\n\n---\n\n## Version Reference: v3 → v4\n\n| Area | v3 | v4 |\n|---|---|---|\n| Client credentials service | `IClientCredentialsTokenManagementService` | `IClientCredentialsTokenManager` |\n| User token service | `IUserTokenManagementService` | `IUserTokenManager` |\n| Token result type | `TokenResponse` — read `.Value` | `TokenResult<T>` — use `.Succeeded` / `.GetToken()` |\n| Client name type | `string` | `ClientCredentialsClientName` (strongly typed) |\n| Token cache | `IDistributedCache` (explicit `AddDistributedMemoryCache()` required) | `HybridCache` (automatic; picks up `IDistributedCache` as remote L2 tier) |\n| Resiliency | Manual | `AddDefaultAccessTokenResiliency()` built into `AddClientCredentialsHttpClient` |\n\n---\n\n## Resources\n\n- [Access Token Management Overview](https://docs.duendesoftware.com/accesstokenmanagement/)\n- [Service Workers / Background Tasks](https://docs.duendesoftware.com/accesstokenmanagement/workers/)\n- [Web Applications (User Tokens)](https://docs.duendesoftware.com/accesstokenmanagement/web-apps/)\n- [Blazor Server](https://docs.duendesoftware.com/accesstokenmanagement/blazor-server/)\n- [Advanced: Client Credentials Options](https://docs.duendesoftware.com/accesstokenmanagement/advanced/client-credentials/)\n- [Advanced: User Token Options](https://docs.duendesoftware.com/accesstokenmanagement/advanced/user-tokens/)\n- [Advanced: Client Assertions](https://docs.duendesoftware.com/accesstokenmanagement/advanced/client-assertions/)\n- [Advanced: DPoP](https://docs.duendesoftware.com/accesstokenmanagement/advanced/dpop/)\n- [Advanced: Extensibility](https://docs.duendesoftware.com/accesstokenmanagement/advanced/extensibility/)\n- [v3 → v4 Upgrade Guide](https://docs.duendesoftware.com/accesstokenmanagement/upgrading/atm-v3-to-v4/)\n- [NuGet: Duende.AccessTokenManagement](https://www.nuget.org/packages/Duende.AccessTokenManagement/)\n- [NuGet: Duende.AccessTokenManagement.OpenIdConnect](https://www.nuget.org/packages/Duende.AccessTokenManagement.OpenIdConnect/)\n- [GitHub: DuendeSoftware/foss (access-token-management)](https://github.com/DuendeSoftware/foss/tree/main/access-token-management)\n"
}

SHA-256 of public snapshot: 2f31c7d532026ddb4d42de830d5a602052515a0b19f5bd51c079156edef31ae1