← 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
{
"name": "aspnetcore-authentication",
"description": "ASP.NET Core authentication middleware configuration including OpenID Connect, JWT Bearer, cookie authentication, authentication schemes, challenge/forbid flows, and external identity provider integration.",
"included_files": [],
"skill_md_contents": "---\nname: aspnetcore-authentication\ndescription: ASP.NET Core authentication middleware configuration including OpenID Connect, JWT Bearer, cookie authentication, authentication schemes, challenge/forbid flows, and external identity provider integration.\ninvocable: false\n---\n\n# ASP.NET Core Authentication\n\n## When to Use This Skill\n\nUse this skill when:\n- Configuring OIDC authentication in an ASP.NET Core web application\n- Setting up JWT Bearer authentication for an API\n- Managing authentication schemes (cookies, OIDC, JWT, external providers)\n- Implementing challenge, sign-in, sign-out, and forbid flows\n- Debugging authentication failures (401s, redirect loops, claim mapping issues)\n- Integrating with Duende IdentityServer as an OpenID Connect provider\n- Configuring token validation parameters\n\n## Core Principles\n\n1. **Authentication ≠ Authorization** — Authentication establishes *who* the user is. Authorization (see `aspnetcore-authorization`) determines *what* they can do.\n2. **Scheme-Based Architecture** — ASP.NET Core authentication is built around named schemes. Each scheme has a handler that knows how to authenticate, challenge, and sign out.\n3. **Cookies for Web Apps, JWT for APIs** — Web applications use cookie authentication (with OIDC for login). APIs use JWT Bearer or introspection.\n4. **Never Roll Your Own** — Use the built-in OIDC and JWT Bearer handlers. They handle nonce validation, key rotation, token validation, and dozens of edge cases.\n5. **Claim Type Mapping Matters** — The OIDC handler maps JWT claim types to .NET claim types by default. Disable this for predictable claim names.\n\n## Related Skills\n\n- `aspnetcore-authorization` — Policy-based authorization after authentication\n- `identityserver-configuration` — Server-side client and resource configuration\n- `identityserver-sessions-providers` — Server-side sessions to reduce cookie size and maintain IdP-side data\n- `oauth-oidc-protocols` — Protocol fundamentals underlying these handlers\n- `token-management` — Automatic token refresh with Duende.AccessTokenManagement\n\nDocs: https://docs.duendesoftware.com/identityserver/apis/aspnetcore/jwt/\n\n---\n\n## Pattern 1: OIDC Authentication for Web Applications\n\nThe most common pattern — a server-rendered web app authenticating users via Duende IdentityServer:\n\n```csharp\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.Services.AddAuthentication(options =>\n{\n options.DefaultScheme = \"Cookies\";\n options.DefaultChallengeScheme = \"oidc\";\n})\n.AddCookie(\"Cookies\", options =>\n{\n options.Cookie.Name = \"myapp\";\n options.Cookie.SameSite = SameSiteMode.Lax;\n options.ExpireTimeSpan = TimeSpan.FromHours(8);\n options.SlidingExpiration = true;\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\"; // Authorization code flow\n\n // Map scopes to request\n options.Scope.Clear();\n options.Scope.Add(\"openid\");\n options.Scope.Add(\"profile\");\n options.Scope.Add(\"email\");\n options.Scope.Add(\"api1\");\n options.Scope.Add(\"offline_access\"); // For refresh tokens\n\n // Save tokens in the authentication cookie\n options.SaveTokens = true;\n\n // Disable Microsoft's JWT claim type mapping\n options.MapInboundClaims = false;\n\n // Where to get additional user claims\n options.GetClaimsFromUserInfoEndpoint = true;\n\n options.TokenValidationParameters = new TokenValidationParameters\n {\n NameClaimType = \"name\",\n RoleClaimType = \"role\"\n };\n});\n\nvar app = builder.Build();\napp.UseAuthentication();\napp.UseAuthorization();\n```\n\n### Critical Settings Explained\n\n| Setting | Why | Default |\n|---------|-----|---------|\n| `MapInboundClaims = false` | Prevents renaming `sub` → `http://schemas.xmlsoap.org/.../nameidentifier` | `true` (maps) |\n| `SaveTokens = true` | Stores access/refresh tokens in the cookie for later API calls | `false` |\n| `GetClaimsFromUserInfoEndpoint = true` | Fetches full profile claims from userinfo | `false` |\n| `ResponseType = \"code\"` | Authorization code flow (PKCE is automatic in .NET 7+) | `\"code\"` (.NET 7+; was `\"code id_token\"` in earlier versions) |\n\n---\n\n## Pattern 2: JWT Bearer Authentication for APIs\n\nAPIs validate access tokens issued by IdentityServer:\n\n```csharp\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.Services.AddAuthentication(\"Bearer\")\n .AddJwtBearer(\"Bearer\", options =>\n {\n options.Authority = \"https://identity.example.com\";\n options.Audience = \"catalog-api\"; // Must match ApiResource name\n\n options.MapInboundClaims = false; // Must be included\n\n options.TokenValidationParameters = new TokenValidationParameters\n {\n ValidateAudience = true,\n ValidAudience = \"catalog-api\",\n NameClaimType = \"name\",\n RoleClaimType = \"role\"\n };\n });\n\nbuilder.Services.AddAuthorization();\n\nvar app = builder.Build();\napp.UseAuthentication();\napp.UseAuthorization();\n\n// Protect endpoints\napp.MapGet(\"/products\", () => Results.Ok())\n .RequireAuthorization();\n```\n\n### Multiple Audiences\n\nWhen an API accepts tokens from multiple resources:\n\n```csharp\noptions.TokenValidationParameters = new TokenValidationParameters\n{\n ValidateAudience = true,\n ValidAudiences = new[] { \"catalog-api\", \"shared-api\" }\n};\n```\n\n---\n\n## Pattern 3: Reference Token Introspection\n\nFor APIs that validate reference tokens (opaque tokens) instead of JWTs:\n\n```csharp\nbuilder.Services.AddAuthentication(\"Bearer\")\n .AddOAuth2Introspection(\"Bearer\", options =>\n {\n options.Authority = \"https://identity.example.com\";\n options.ClientId = \"catalog-api\";\n options.ClientSecret = \"api-secret\";\n });\n```\n\n> Install the `Duende.AspNetCore.Authentication.JwtBearer` package which supports both JWT and reference token validation, switching automatically based on the token format.\n\n### Combined JWT + Reference Token Support\n\n```csharp\nbuilder.Services.AddAuthentication(\"Bearer\")\n .AddJwtBearer(\"Bearer\", options =>\n {\n options.Authority = \"https://identity.example.com\";\n options.MapInboundClaims = false;\n\n // The Duende JWT handler can forward to introspection for reference tokens\n options.ForwardDefaultSelector = Selector.ForwardReferenceToken(\"introspection\");\n })\n .AddOAuth2Introspection(\"introspection\", options =>\n {\n options.Authority = \"https://identity.example.com\";\n options.ClientId = \"catalog-api\";\n options.ClientSecret = \"api-secret\";\n });\n```\n\n---\n\n## Pattern 4: Understanding Authentication Schemes\n\nASP.NET Core uses named authentication schemes. Each scheme is handled by a specific handler.\n\n### Default Schemes\n\n```csharp\nbuilder.Services.AddAuthentication(options =>\n{\n // Used for [Authorize] attribute and User.Identity\n options.DefaultScheme = \"Cookies\";\n\n // Used when authentication is required (401 → redirect to login)\n options.DefaultChallengeScheme = \"oidc\";\n\n // Used when access is denied (403)\n options.DefaultForbidScheme = \"oidc\";\n\n // Used when signing in (setting the cookie after OIDC callback)\n options.DefaultSignInScheme = \"Cookies\";\n\n // Used when signing out\n options.DefaultSignOutScheme = \"oidc\";\n});\n```\n\n### The Authentication Flow\n\n```\nRequest → [UseAuthentication] → Cookie handler reads cookie\n ├─ Valid cookie → User is authenticated\n └─ No cookie → User is anonymous\n [UseAuthorization] → [Authorize] attribute checks\n ├─ Authenticated → proceed\n └─ Not authenticated → Challenge\n └─ OIDC handler redirects to IdentityServer\n └─ User logs in → callback → cookie created\n```\n\n---\n\n## Pattern 5: Claim Type Mapping\n\nBy default, the Microsoft OIDC handler remaps JWT claims to XML-based .NET claim types. This causes confusion:\n\n### The Mapping Problem\n\n| JWT Claim | .NET Default Mapping | After `MapInboundClaims = false` |\n|-----------|---------------------|----------------------------------|\n| `sub` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` | `sub` |\n| `name` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name` | `name` |\n| `role` | `http://schemas.microsoft.com/ws/2008/06/identity/claims/role` | `role` |\n| `email` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` | `email` |\n\n### The Fix — Always Disable Mapping\n\n```csharp\n// ✅ On the OIDC handler\noptions.MapInboundClaims = false;\n\n// ✅ On the JWT Bearer handler\noptions.MapInboundClaims = false;\n\n// ✅ Then tell ASP.NET Core which claims to use for Name and Role\noptions.TokenValidationParameters = new TokenValidationParameters\n{\n NameClaimType = \"name\", // Strongly recommended to use \"name\"\n RoleClaimType = \"role\" // Strongly recommended to use \"role\"\n};\n```\n\n> **Why this matters:** Without this, `User.FindFirst(\"sub\")` returns `null` because the claim was renamed. You'd need to use the verbose XML URI instead.\n\n---\n\n## Pattern 6: OIDC Handler Events\n\nThe OIDC handler exposes events for customizing the authentication pipeline:\n\n```csharp\n.AddOpenIdConnect(\"oidc\", options =>\n{\n // ... other options ...\n\n options.Events = new OpenIdConnectEvents\n {\n // Customize the authorize request (e.g., add acr_values)\n OnRedirectToIdentityProvider = context =>\n {\n context.ProtocolMessage.AcrValues = \"tenant:myorg\";\n return Task.CompletedTask;\n },\n\n // Handle tokens after successful authentication\n OnTokenValidated = context =>\n {\n // Add custom claims to the identity\n var identity = context.Principal!.Identity as ClaimsIdentity;\n identity?.AddClaim(new Claim(\"app_version\", \"2.0\"));\n return Task.CompletedTask;\n },\n\n // Handle sign-out redirect\n OnRedirectToIdentityProviderForSignOut = context =>\n {\n // Customize the logout redirect\n return Task.CompletedTask;\n },\n\n // Handle failures\n OnRemoteFailure = context =>\n {\n context.HandleResponse();\n context.Response.Redirect(\"/error?message=\" +\n Uri.EscapeDataString(context.Failure?.Message ?? \"Unknown error\"));\n return Task.CompletedTask;\n }\n };\n});\n```\n\n### Common Event Use Cases\n\n| Event | Use Case |\n|-------|----------|\n| `OnRedirectToIdentityProvider` | Add `acr_values`, `login_hint`, or custom parameters |\n| `OnTokenValidated` | Transform claims, load additional user data |\n| `OnTokenResponseReceived` | Inspect raw token response |\n| `OnRemoteFailure` | Custom error handling for failed logins |\n| `OnSignedOutCallbackRedirect` | Custom post-logout redirect |\n\n---\n\n## Pattern 7: Sign-Out\n\nProper sign-out must clear both the local cookie and the IdentityServer session:\n\n```csharp\n// In a Razor Page or Controller\napp.MapGet(\"/logout\", async (HttpContext ctx) =>\n{\n // Signs out of both the cookie and IdentityServer\n await ctx.SignOutAsync(\"Cookies\");\n await ctx.SignOutAsync(\"oidc\");\n});\n```\n\n### The Sign-Out Flow\n\n```\n1. Client calls SignOutAsync(\"Cookies\") → clears local cookie\n2. Client calls SignOutAsync(\"oidc\") → redirects to IS /connect/endsession\n3. IdentityServer clears its session\n4. IdentityServer notifies other clients → front-channel or back-channel logout\n5. IdentityServer redirects to PostLogoutRedirectUri\n```\n\n> **Important:** Calling only `SignOutAsync(\"Cookies\")` without `SignOutAsync(\"oidc\")` leaves the IdentityServer session active. The user will be silently re-authenticated on the next challenge.\n\n---\n\n## Pattern 8: Accessing Stored Tokens\n\nWhen `SaveTokens = true`, the access token, refresh token, and ID token are stored in the authentication cookie:\n\n```csharp\n// In a controller or middleware\nvar accessToken = await HttpContext.GetTokenAsync(\"access_token\");\nvar refreshToken = await HttpContext.GetTokenAsync(\"refresh_token\");\nvar idToken = await HttpContext.GetTokenAsync(\"id_token\");\nvar expiresAt = await HttpContext.GetTokenAsync(\"expires_at\");\n\n// Use the access token to call an API\nhttpClient.SetBearerToken(accessToken);\n```\n\n> **Better approach:** Use `Duende.AccessTokenManagement` (see `token-management` skill) which handles token refresh, caching, and rotation automatically instead of manually managing stored tokens.\n\n---\n\n## Pattern 9: mTLS (Certificate-Bound Tokens) with the OIDC Handler\n\nWhen IdentityServer issues certificate-bound tokens via mTLS (RFC 8705), the OIDC client must (1) present its client certificate on all back-channel calls and (2) target the mTLS endpoint aliases. The stock handler does neither on its own.\n\n### Step 1 — Present the client certificate on back-channel calls\n\nSet `BackchannelHttpHandler` so code redemption, refresh, and userinfo calls run over a mutually-authenticated TLS channel. No client secret is needed — the certificate authenticates the client:\n\n```csharp\nvar clientCert = X509CertificateLoader.LoadPkcs12(File.ReadAllBytes(\"client.p12\"), \"password\");\n\n.AddOpenIdConnect(\"oidc\", options =>\n{\n options.Authority = \"https://identity.example.com\";\n options.ClientId = \"mtls.client\";\n // no ClientSecret — the certificate authenticates the client\n options.ResponseType = \"code\";\n options.MapInboundClaims = false;\n options.SaveTokens = true;\n\n options.BackchannelHttpHandler = new SocketsHttpHandler\n {\n SslOptions = new SslClientAuthenticationOptions\n {\n ClientCertificates = new X509CertificateCollection { clientCert }\n }\n };\n});\n```\n\n### Step 2 — Point the handler at `mtls_endpoint_aliases`\n\nThe stock OIDC handler reads endpoints from standard discovery metadata and does **not** understand `mtls_endpoint_aliases` — it would call the non-mTLS `token_endpoint`. Wrap the standard configuration manager and rewrite the endpoints to their mTLS aliases:\n\n```csharp\npublic sealed class MtlsConfigurationManager : IConfigurationManager<OpenIdConnectConfiguration>\n{\n private readonly ConfigurationManager<OpenIdConnectConfiguration> _inner;\n\n public MtlsConfigurationManager(ConfigurationManager<OpenIdConnectConfiguration> inner)\n => _inner = inner;\n\n public async Task<OpenIdConnectConfiguration> GetConfigurationAsync(CancellationToken ct)\n {\n var config = await _inner.GetConfigurationAsync(ct);\n\n if (config.AdditionalData.TryGetValue(\"mtls_endpoint_aliases\", out var raw)\n && raw is JsonElement aliases)\n {\n config.TokenEndpoint = aliases.GetProperty(\"token_endpoint\").GetString();\n config.IntrospectionEndpoint = aliases.GetProperty(\"introspection_endpoint\").GetString();\n config.DeviceAuthorizationEndpoint = aliases.GetProperty(\"device_authorization_endpoint\").GetString();\n // .NET 9+ auto-uses PAR when advertised — rewrite it too, or the handler\n // pushes to the non-mTLS PAR endpoint and the certificate binding is lost\n config.PushedAuthorizationRequestEndpoint = aliases.GetProperty(\"pushed_authorization_request_endpoint\").GetString();\n // revocation has no strongly-typed slot — keep the mTLS value in AdditionalData\n config.AdditionalData[\"revocation_endpoint\"] = aliases.GetProperty(\"revocation_endpoint\").GetString();\n }\n\n return config;\n }\n\n public void RequestRefresh() => _inner.RequestRefresh();\n}\n\n// Wire it onto the handler\n.AddOpenIdConnect(\"oidc\", options =>\n{\n // ... options from Step 1 ...\n options.ConfigurationManager = new MtlsConfigurationManager(\n new ConfigurationManager<OpenIdConnectConfiguration>(\n $\"{options.Authority}/.well-known/openid-configuration\",\n new OpenIdConnectConfigurationRetriever(),\n new HttpDocumentRetriever { RequireHttps = true }));\n});\n```\n\n> **PAR + mTLS on .NET 9+:** because the handler automatically uses PAR when the server advertises a `pushed_authorization_request_endpoint`, you **must** rewrite that endpoint to the mTLS alias as well. Otherwise the pushed authorization request goes to the non-mTLS endpoint and the certificate binding is lost.\n\n---\n\n## Common Pitfalls\n\n### 1. Forgetting MapInboundClaims\n\n```csharp\n// ❌ WRONG — Claims have XML URIs, User.FindFirst(\"sub\") returns null\n.AddOpenIdConnect(\"oidc\", options =>\n{\n options.Authority = \"https://identity.example.com\";\n // MapInboundClaims defaults to true\n});\n\n// ✅ CORRECT\n.AddOpenIdConnect(\"oidc\", options =>\n{\n options.Authority = \"https://identity.example.com\";\n options.MapInboundClaims = false;\n});\n```\n\n### 2. Missing UseAuthentication Before UseAuthorization\n\n```csharp\n// ❌ WRONG — Authorization middleware can't see the authenticated user\napp.UseAuthorization();\napp.UseAuthentication(); // Too late!\n\n// ✅ CORRECT — Authentication must come first\napp.UseAuthentication();\napp.UseAuthorization();\n```\n\n### 3. Not Clearing Scopes Before Adding\n\n```csharp\n// ❌ WRONG — Default scopes (openid, profile) are already added\noptions.Scope.Add(\"openid\"); // Duplicate!\noptions.Scope.Add(\"profile\"); // Duplicate!\noptions.Scope.Add(\"api1\");\n\n// ✅ CORRECT — Clear defaults first\noptions.Scope.Clear();\noptions.Scope.Add(\"openid\");\noptions.Scope.Add(\"profile\");\noptions.Scope.Add(\"api1\");\n```\n\n### 4. Cookie Too Large (>4KB)\n\nWhen `SaveTokens = true` and many claims are included, the cookie can exceed browser limits:\n\n```csharp\n// ✅ Solution 1: Use a server-side ITicketStore to move auth ticket out of the cookie.\n// Implement ITicketStore backed by IDistributedCache (e.g., Redis), then register it:\nbuilder.Services.AddStackExchangeRedisCache(options =>\n{\n options.Configuration = \"localhost:6379\";\n});\nbuilder.Services.AddSingleton<ITicketStore, RedisTicketStore>(); // your ITicketStore impl\n\n.AddCookie(\"Cookies\", options =>\n{\n // Wire the ITicketStore so the cookie only holds a session key, not the full ticket\n options.SessionStore = app.Services.GetRequiredService<ITicketStore>();\n});\n// Note: ITicketStore is in Microsoft.AspNetCore.Authentication.Cookies namespace.\n// There is no built-in DistributedSessionStore class — you must implement ITicketStore.\n\n// ✅ Solution 2: Filter claims stored in the cookie\n.AddOpenIdConnect(\"oidc\", options =>\n{\n options.ClaimActions.DeleteClaims(\"sid\", \"idp\", \"auth_time\", \"amr\");\n});\n\n// ✅ Solution 3: Use Duende IdentityServer server-side sessions\n```\n\n### 5. Redirect Loop After Login\n\nUsually caused by the cookie not being set due to SameSite restrictions:\n\n```csharp\n// ✅ Check SameSite settings\n.AddCookie(\"Cookies\", options =>\n{\n options.Cookie.SameSite = SameSiteMode.Lax; // Not Strict for OIDC callbacks\n options.Cookie.SecurePolicy = CookieSecurePolicy.Always;\n});\n```\n\n---\n\n## Resources\n\n- [ASP.NET Core Authentication — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authentication/)\n- [OpenID Connect Handler — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authentication/social/)\n- [JWT Bearer Handler — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authentication/configure-jwt-bearer-authentication)\n- [Duende IdentityServer Quickstarts](https://docs.duendesoftware.com/identityserver/quickstarts/)\n- [OIDC Handler Events — Duende Docs](https://docs.duendesoftware.com/identityserver/fundamentals/openid-connect-events/)\n"
}SHA-256: 284fa31db8cd35f1cd70bdb36102e395dd97e0667ff52dcaf667c0dd7b6deb65