From duende-skills
Manages OAuth2/OIDC tokens in .NET using Duende.AccessTokenManagement. Covers client credentials, user token refresh, DPoP, and HttpClientFactory integration.
How this skill is triggered — by the user, by Claude, or both
Slash command
/duende-skills:token-managementThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Use this skill when:
Use this skill when:
Duende.AccessTokenManagement or Duende.AccessTokenManagement.OpenIdConnect with IHttpClientFactoryIHttpClientFactory-integrated clients; they acquire, cache, refresh, and attach tokens transparently. Call GetAccessTokenAsync manually only when the factory pattern is insufficient.SaveTokens = true Is Required for User Tokens — The OIDC handler must persist tokens into the authentication session. This is the most common misconfiguration.e.HttpContext.RevokeRefreshTokenAsync() in OnSigningOut to revoke the refresh token at the authorization server, preventing reuse after logout.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.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.aspnetcore-authentication — cookie and OIDC handler setup required for user token managementidentityserver-configuration — configuring the authorization server that issues tokensoauth-oidc-protocols — protocol fundamentals underlying client credentials and refresh token flowsduende-bff — BFF pattern integrates this library automatically for proxied API callsDocs: https://docs.duendesoftware.com/identityserver/tokens/management
dotnet add package Duende.AccessTokenManagement
// ✅ Register one or more named client definitions
services.AddClientCredentialsTokenManagement()
.AddClient("catalog.client", client =>
{
client.TokenEndpoint = new Uri("https://sts.company.com/connect/token");
client.ClientId = ClientId.Parse("6f59b670-990f-4ef7-856f-0dd584ed1fac");
client.ClientSecret = ClientSecret.Parse("d0c17c6a-ba47-4654-a874-f6d576cdf799");
client.Scope = Scope.Parse("catalog inventory");
})
.AddClient("invoice.client", client =>
{
client.TokenEndpoint = new Uri("https://sts.company.com/connect/token");
client.ClientId = ClientId.Parse("ff8ac57f-5ade-47f1-b8cd-4c2424672351");
client.ClientSecret = ClientSecret.Parse("4dbbf8ec-d62a-4639-b0db-aa5357a0cf46");
client.Scope = Scope.Parse("invoice customers");
});
Available client options:
TokenEndpoint — URL of the OAuth token endpointClientId / ClientSecret — client credentialsClientCredentialStyle — AuthorizationHeader (default) or PostBodyScope — requested scope (optional; overridable per request)Resource — resource indicator per RFC 8707 (optional)HttpClientName — custom backchannel HTTP client name from the factoryDPoPJsonWebKey — JWK for DPoP-bound tokens (see Pattern 5)// ✅ Named client — token acquired, cached, and attached automatically
services.AddClientCredentialsHttpClient(
"invoices",
ClientCredentialsClientName.Parse("invoice.client"),
client => { client.BaseAddress = new Uri("https://apis.company.com/invoice/"); });
// ✅ Typed client — identical behaviour, strongly typed
services.AddHttpClient<CatalogClient>(client =>
{
client.BaseAddress = new Uri("https://apis.company.com/catalog/");
})
.AddClientCredentialsTokenHandler(ClientCredentialsClientName.Parse("catalog.client"));
Usage — no token code required at the call site:
public sealed class WorkerHttpClient(IHttpClientFactory factory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// ✅ Token acquired, cached, and refreshed transparently
var client = factory.CreateClient("invoices");
var response = await client.GetAsync("list", stoppingToken);
// ...
}
}
}
Resiliency handler —
AddClientCredentialsHttpClientautomatically adds a resiliency handler that retries once on401 Unauthorized. This covers token expiry and DPoP nonce challenges. When usingAddClientCredentialsTokenHandlerdirectly, add it explicitly:services.AddHttpClient<CatalogClient>(...) .AddDefaultAccessTokenResiliency() .AddClientCredentialsTokenHandler("catalog.client");
// ✅ Inject IClientCredentialsTokenManager (v4)
public sealed class WorkerManual(
IHttpClientFactory factory,
IClientCredentialsTokenManager tokenManager) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var tokenResult = await tokenManager.GetAccessTokenAsync(
ClientCredentialsClientName.Parse("catalog.client"),
ct: stoppingToken);
if (!tokenResult.Succeeded)
{
// log and handle — do not call .GetToken() without checking first
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
continue;
}
var token = tokenResult.GetToken();
var client = factory.CreateClient();
client.SetBearerToken(token.AccessToken.ToString());
var response = await client.GetAsync("https://apis.company.com/catalog/list", stoppingToken);
// ...
}
}
}
In v3, the service was
IClientCredentialsTokenManagementServiceand the result was read via.Value. In v4 it isIClientCredentialsTokenManagerand the result isTokenResult<ClientCredentialsToken>— use.Succeeded/.GetToken().
dotnet add package Duende.AccessTokenManagement.OpenIdConnect
// ✅ Full setup: cookie + OIDC handler + token management
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "cookie";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookie", options =>
{
options.Cookie.Name = "web";
// ✅ Revoke refresh token at sign-out
options.Events.OnSigningOut = async e =>
{
await e.HttpContext.RevokeRefreshTokenAsync();
};
})
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://sts.company.com";
options.ClientId = "webapp";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.ResponseMode = "query";
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Scope.Add("invoice");
options.Scope.Add("offline_access"); // ← required for refresh tokens
options.GetClaimsFromUserInfoEndpoint = true;
options.MapInboundClaims = false;
// ✅ REQUIRED — persists access and refresh tokens into the auth session
options.SaveTokens = true;
});
// ✅ Adds all token management services
builder.Services.AddOpenIdConnectAccessTokenManagement();
SaveTokens = trueis mandatory. Without it, the library cannot read or refresh the user's access token. This is the most common misconfiguration causingInvalidOperationExceptionat runtime.
// ✅ Named client using the current user's access token
builder.Services.AddUserAccessTokenHttpClient(
"invoices",
configureClient: client =>
{
client.BaseAddress = new Uri("https://api.company.com/invoices/");
});
// ✅ Typed client using the current user's access token
builder.Services.AddHttpClient<InvoiceClient>(client =>
{
client.BaseAddress = new Uri("https://api.company.com/invoices/");
})
.AddUserAccessTokenHandler();
// ✅ Named client using a client credentials token (machine-to-machine, user-independent)
builder.Services.AddClientAccessTokenHttpClient(
"masterdata.client",
configureClient: client =>
{
client.BaseAddress = new Uri("https://api.company.com/masterdata/");
});
// ✅ Typed client using a client credentials token
builder.Services.AddHttpClient<MasterDataClient>(client =>
{
client.BaseAddress = new Uri("https://api.company.com/masterdata/");
})
.AddClientAccessTokenHandler();
Usage in a controller:
public sealed class ApiController(IHttpClientFactory httpClientFactory) : Controller
{
public async Task<IActionResult> CallApi(CancellationToken ct)
{
// ✅ Token attached automatically; refreshed silently if expired
var client = httpClientFactory.CreateClient("invoices");
var response = await client.GetAsync("list", ct);
// ...
}
}
// ✅ v4 — inject IUserTokenManager
public sealed class HomeController(
IHttpClientFactory httpClientFactory,
IUserTokenManager userTokenManager) : Controller
{
public async Task<IActionResult> CallApi(CancellationToken ct)
{
var token = await userTokenManager.GetAccessTokenAsync(User, ct: ct);
var client = httpClientFactory.CreateClient();
client.SetBearerToken(token.Value);
var response = await client.GetAsync("https://api.company.com/invoices", ct);
// ...
}
}
HttpContext extension methods are also available:
// ✅ User access token — refreshed automatically via refresh token if expired
var userToken = await HttpContext.GetUserAccessTokenAsync();
// ✅ Client credentials token — re-requested from the token server if expired
var clientToken = await HttpContext.GetClientAccessTokenAsync();
// ✅ Revoke refresh token explicitly (also wired into OnSigningOut above)
await HttpContext.RevokeRefreshTokenAsync();
Use AddUserAccessTokenHandler and AddClientAccessTokenHandler when registering typed gRPC clients:
// ✅ gRPC client using the current user's access token
builder.Services.AddGrpcClient<Greeter.GreeterClient>(o =>
{
o.Address = new Uri("https://grpc.company.com");
})
.AddUserAccessTokenHandler();
// ✅ gRPC client using a client credentials token
builder.Services.AddGrpcClient<Inventory.InventoryClient>(o =>
{
o.Address = new Uri("https://grpc.company.com");
})
.AddClientAccessTokenHandler();
In 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.
// ✅ Add a distributed remote cache (e.g., Redis) — HybridCache picks it up automatically as L2
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
});
Global cache options:
// ✅ Configure lifetime buffer and key prefix
services.AddClientCredentialsTokenManagement(options =>
{
// Cache tokens 60 s before they expire to avoid serving a near-expired token
options.CacheLifetimeBuffer = 60;
options.CacheKeyPrefix = "MyApp.ATM::";
});
Default cache key format:
{CacheKeyPrefix}::{client_name}::hashed({scope})::hashed({resource})
scope and resource values are MD5-hashed to keep key length bounded. Implement IClientCredentialsCacheKeyGenerator to supply custom keys when adding custom TokenRequestParameters.
// ✅ v3: must explicitly register a distributed cache implementation
services.AddDistributedMemoryCache(); // development / single-instance only
// ✅ v3: Redis for production
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "redis.company.com:6379";
});
services.AddClientCredentialsTokenManagement(options =>
{
options.CacheLifetimeBuffer = 60;
});
When sharing a remote cache with other applications, encrypt tokens at rest using a custom IHybridCacheSerializer<ClientCredentialsToken>:
// ✅ Register the encrypted serializer for the ClientCredentialsToken type
services.AddHybridCache()
.AddSerializer<ClientCredentialsToken, EncryptedHybridCacheSerializer>();
services.AddDataProtection();
public sealed class EncryptedHybridCacheSerializer : IHybridCacheSerializer<ClientCredentialsToken>
{
private readonly IDataProtector _protector;
public EncryptedHybridCacheSerializer(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("ClientCredentialsToken");
}
public ClientCredentialsToken Deserialize(ReadOnlySequence<byte> source)
{
var unprotected = _protector.Unprotect(source.ToArray());
return JsonSerializer.Deserialize<ClientCredentialsToken>(unprotected)!;
}
public void Serialize(ClientCredentialsToken value, IBufferWriter<byte> target)
{
var json = JsonSerializer.SerializeToUtf8Bytes(value);
target.Write(_protector.Protect(json));
}
}
// ✅ Inject a custom HybridCache only for AccessTokenManagement (uses service keys)
services.AddKeyedSingleton<HybridCache>(
ServiceProviderKeys.ClientCredentialsTokenCache,
new MyCustomCacheImplementation());
ClientCredentialsTokenManagementOptionsservices.AddClientCredentialsTokenManagement(options =>
{
options.CacheLifetimeBuffer = 60; // seconds subtracted from token lifetime in cache
options.CacheKeyPrefix = "MyApp.ATM::"; // prefix for all cache keys
});
UserTokenManagementOptionsbuilder.Services.AddOpenIdConnectAccessTokenManagement(options =>
{
// Override the OIDC challenge scheme if not using the default
options.ChallengeScheme = "oidc";
// Enable separate token stores per OIDC scheme (multi-provider setups)
options.UseChallengeSchemeScopedTokens = false;
// Scope and resource sent when requesting client credentials tokens from
// the configured OIDC provider (cannot be inferred from OIDC metadata)
options.ClientCredentialsScope = "api1 api2";
options.ClientCredentialsResource = "urn:myapi";
// How client credentials are sent to the token endpoint
options.ClientCredentialStyle = ClientCredentialStyle.PostBody;
// DPoP key for all user token requests from this application
options.DPoPJsonWebKey = jwk;
});
// ✅ Force a fresh token even if a cached one exists
var token = await tokenManager.GetAccessTokenAsync(
ClientCredentialsClientName.Parse("catalog.client"),
new TokenRequestParameters { ForceRenewal = true },
ct: stoppingToken);
// ✅ Override scope per user token request
var token = await userTokenManager.GetAccessTokenAsync(
User,
new UserTokenRequestParameters
{
Scope = "invoice:write",
ForceRenewal = false,
ChallengeScheme = "oidc"
},
ct: ct);
For IHttpClientFactory clients, parameters are wired at registration time:
builder.Services.AddUserAccessTokenHttpClient(
"invoices",
parameters: new UserTokenRequestParameters { ForceRenewal = true },
configureClient: client => { client.BaseAddress = new Uri("https://api.company.com/invoices/"); });
DPoP 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.
Full details in sub-document — See
docs/dpop.mdfor JWK generation, per-client configuration,IDPoPKeyStore, session size implications, and the key-persistence pitfall.
An 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).
Full details in sub-document — See
docs/api-delegation.mdfor both approaches with complete code examples and a decision guide.
Use IConfigureNamedOptions<ClientCredentialsClient> when token endpoint configuration must be resolved at runtime — for example, from OIDC discovery:
public sealed class ClientCredentialsConfigureOptions(DiscoveryCache cache)
: IConfigureNamedOptions<ClientCredentialsClient>
{
public void Configure(string? name, ClientCredentialsClient options)
{
if (name == "catalog.client")
{
// ✅ Resolve token endpoint from OIDC discovery document
var disco = cache.GetAsync().GetAwaiter().GetResult();
options.TokenEndpoint = new Uri(disco.TokenEndpoint);
options.ClientId = ClientId.Parse("...");
options.ClientSecret = ClientSecret.Parse("...");
options.Scope = Scope.Parse("catalog");
}
}
public void Configure(ClientCredentialsClient options) => Configure(string.Empty, options);
}
// Registration
services.AddClientCredentialsTokenManagement();
services.AddSingleton(new DiscoveryCache("https://sts.company.com"));
services.AddSingleton<IConfigureOptions<ClientCredentialsClient>, ClientCredentialsConfigureOptions>();
By 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:
// ✅ Register a custom implementation backed by server-side session storage
builder.Services.AddSingleton<IUserTokenStore, ServerSideSessionUserTokenStore>();
// ✅ Override the entire cache with a custom IClientCredentialsTokenCache
services.AddSingleton<IClientCredentialsTokenCache, MyCustomTokenCache>();
Blazor 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.
Full details in sub-document — See
docs/blazor-server.mdfor the fullIUserTokenStoreimplementation,OnTokenValidatedsetup, and theHttpContext-null pitfall.
Use 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.
Full details in sub-document — See
docs/client-assertions.mdfor the fullIClientAssertionServiceimplementation, correct audience configuration, and CVE context.
Use 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.
Full details in sub-document — See
docs/customization.mdfor the fullITokenRequestCustomizerimplementation and registration pattern.
Implement AccessTokenRequestHandler.ITokenRetriever to completely replace the default token retrieval logic with custom selection or caching behavior.
Full details in sub-document — See
docs/customization.mdfor the fullITokenRetrieverimplementation andAddHttpMessageHandlerregistration pattern.
Load these sub-documents when the user's question specifically targets one of these areas:
| Document | Description | When to Load |
|---|---|---|
| docs/dpop.md | DPoP proof-of-possession token binding | DPoP, DPoPJsonWebKey, IDPoPKeyStore, key generation, key rotation |
| docs/blazor-server.md | Blazor Server circuit-scoped token management | Blazor, SignalR circuit, HttpContext null, AddBlazorServerAccessTokenManagement |
| docs/client-assertions.md | Client assertions (private_key_jwt) + CVE guidance | IClientAssertionService, JWT client auth, CVE-2025-27370, CVE-2025-27371 |
| docs/customization.md | ITokenRequestCustomizer & ITokenRetriever | Multi-tenant token params, custom token retrieval, AccessTokenRequestHandler |
| docs/api-delegation.md | API-to-API delegation patterns | Downstream API calls, forwarding user tokens, service identity |
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// User token management via OIDC
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "cookie";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookie", options =>
{
options.Cookie.Name = "web";
options.Events.OnSigningOut = async e =>
{
await e.HttpContext.RevokeRefreshTokenAsync();
};
})
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://identity.example.com";
options.ClientId = "web_app";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.Scope.Add("api1");
options.Scope.Add("offline_access");
options.SaveTokens = true;
});
builder.Services.AddOpenIdConnectAccessTokenManagement();
// User token HTTP client — attaches the logged-in user's access token
builder.Services.AddUserAccessTokenHttpClient("user-api",
configureClient: client =>
{
client.BaseAddress = new Uri("https://api.example.com");
});
// Client credentials for service-to-service (v4 types)
builder.Services.AddClientCredentialsTokenManagement()
.AddClient(ClientCredentialsClientName.Parse("service-client"), client =>
{
client.TokenEndpoint = new Uri("https://identity.example.com/connect/token");
client.ClientId = ClientId.Parse("web_app_service");
client.ClientSecret = ClientSecret.Parse("service_secret");
client.Scope = Scope.Parse("backend.api");
});
builder.Services.AddClientCredentialsHttpClient("service-api",
ClientCredentialsClientName.Parse("service-client"),
configureClient: client =>
{
client.BaseAddress = new Uri("https://backend.example.com");
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// User-context API call
app.MapGet("/user-data", async (IHttpClientFactory factory) =>
{
var client = factory.CreateClient("user-api");
var response = await client.GetAsync("/data");
return Results.Ok(await response.Content.ReadAsStringAsync());
}).RequireAuthorization();
// Service-to-service API call (no user context needed)
app.MapGet("/backend-data", async (IHttpClientFactory factory) =>
{
var client = factory.CreateClient("service-api");
var response = await client.GetAsync("/internal/data");
return Results.Ok(await response.Content.ReadAsStringAsync());
});
app.Run();
SaveTokens = true for User Tokens// ❌ Tokens never stored in session — library throws InvalidOperationException at runtime
.AddOpenIdConnect("oidc", options =>
{
// SaveTokens not set — defaults to false
});
// ✅ Always set it when using AddOpenIdConnectAccessTokenManagement
options.SaveTokens = true;
offline_access Scope// ❌ No refresh token issued — access token expires and user must re-authenticate
options.Scope.Add("openid");
options.Scope.Add("profile");
// offline_access missing
// ✅
options.Scope.Add("offline_access");
// ❌ Refresh token remains valid at the authorization server after sign-out
.AddCookie("cookie", options =>
{
// No OnSigningOut handler — refresh token never revoked
});
// ✅
.AddCookie("cookie", options =>
{
options.Events.OnSigningOut = async e =>
{
await e.HttpContext.RevokeRefreshTokenAsync();
};
});
// ❌ Double-caching — your cache won't stay in sync; stale token after expiry
private string? _cachedToken;
public async Task<string> GetToken()
{
if (_cachedToken != null) return _cachedToken;
var token = await _tokenManager.GetAccessTokenAsync(...);
_cachedToken = token.AccessToken.ToString(); // never invalidated
return _cachedToken;
}
// ✅ Call GetAccessTokenAsync every time — the library serves from cache transparently
public async Task<string> GetToken(CancellationToken ct)
{
var result = await _tokenManager
.GetAccessTokenAsync(ClientCredentialsClientName.Parse("my.client"), ct: ct)
.GetToken();
return result.AccessToken.ToString();
}
.GetToken() Without Checking Succeeded// ❌ .GetToken() throws InvalidOperationException when token retrieval fails;
// the actual error is swallowed unless you inspect Succeeded first
var tokenResult = await tokenManager.GetAccessTokenAsync(...);
var token = tokenResult.GetToken(); // throws when Succeeded == false
// ✅ Check success before accessing the token value
var tokenResult = await tokenManager.GetAccessTokenAsync(...);
if (!tokenResult.Succeeded)
{
logger.LogError("Failed to obtain access token");
return Problem("Authentication failure", statusCode: StatusCodes.Status503ServiceUnavailable);
}
var token = tokenResult.GetToken();
AddClientCredentialsTokenHandler Without Resiliency// ❌ A 401 from an expired token is returned directly to the caller — no retry
services.AddHttpClient<CatalogClient>(...)
.AddClientCredentialsTokenHandler("catalog.client");
// ✅ Add the resiliency handler before the token handler
services.AddHttpClient<CatalogClient>(...)
.AddDefaultAccessTokenResiliency()
.AddClientCredentialsTokenHandler("catalog.client");
// ❌ v3: no cache registered — runtime exception on first token request
services.AddClientCredentialsTokenManagement()
.AddClient("catalog.client", client => { /* ... */ });
// Missing: services.AddDistributedMemoryCache();
// ✅ v3: always register a distributed cache (in-memory for dev, Redis for prod)
services.AddDistributedMemoryCache();
// ❌ New key generated on every restart — all previously issued DPoP-bound tokens
// become unusable, causing 401 errors until new tokens are obtained
var rsaKey = new RsaSecurityKey(RSA.Create(2048)); // ephemeral — lost on restart
// ✅ Load from stable, secure storage
var jwkJson = configuration["DPoP:JsonWebKey"]; // from Key Vault / secrets
services.AddClientCredentialsTokenManagement()
.AddClient("my.client", client =>
{
client.DPoPJsonWebKey = jwkJson;
});
// ❌ Audience set to the token endpoint — security vulnerability
// Root cause of CVE-2025-27370 and CVE-2025-27371
Audience = "https://identity.example.com/connect/token"
// ✅ Audience must be the authorization server's issuer URL
Audience = "https://identity.example.com"
CVE-2025-27370 and CVE-2025-27371 were caused by this exact mistake. Authorization servers that accept both values allow token endpoint confusion attacks.
HttpContext to Access Tokens in Blazor Server Components// ❌ HttpContext is null after circuit establishment — this will fail at runtime
var token = await HttpContext.GetUserAccessTokenAsync(); // throws NullReferenceException
// ✅ Use AddBlazorServerAccessTokenManagement<T>() with a custom IUserTokenStore
builder.Services.AddOpenIdConnectAccessTokenManagement()
.AddBlazorServerAccessTokenManagement<ServerSideTokenStore>();
// Capture tokens in OnTokenValidated (see Pattern 9)
CacheLifetimeBuffer to 0// ❌ Buffer set to 0 — tokens served until exact expiry; a token may expire
// in transit between retrieval and use at the API, causing unnecessary 401s
services.AddClientCredentialsTokenManagement(options =>
{
options.CacheLifetimeBuffer = 0;
});
// ✅ Keep the default (60 s) or set a positive value that accounts for network latency
services.AddClientCredentialsTokenManagement(options =>
{
options.CacheLifetimeBuffer = 60; // default — refresh 60 s before expiry
});
| Area | v3 | v4 |
|---|---|---|
| Client credentials service | IClientCredentialsTokenManagementService | IClientCredentialsTokenManager |
| User token service | IUserTokenManagementService | IUserTokenManager |
| Token result type | TokenResponse — read .Value | TokenResult<T> — use .Succeeded / .GetToken() |
| Client name type | string | ClientCredentialsClientName (strongly typed) |
| Token cache | IDistributedCache (explicit AddDistributedMemoryCache() required) | HybridCache (automatic; picks up IDistributedCache as remote L2 tier) |
| Resiliency | Manual | AddDefaultAccessTokenResiliency() built into AddClientCredentialsHttpClient |
npx claudepluginhub duendesoftware/duende-skills --plugin duende-skillsProvides CDSS development patterns for drug interaction checking, dose validation, clinical scoring (NEWS2, qSOFA), and alert classification integrated into EMR workflows.