← 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
{
  "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