← Duende SkillsCONTENT HISTORYWHAT CHANGED · RULE-BASED ANALYSIS
Update to Duende Skills
Snapshot Sep 30, 2026 · 23:14 UTC · version 0.3.0
Collection source: not recorded for this historical snapshot.
First saved snapshot
No earlier snapshot is available to establish a change.
Compare saved observations
Download comparison JSONFull 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