From duende-skills
Guides through OAuth 2.0 and OpenID Connect protocol fundamentals: grant types, PKCE, token types, discovery documents, JWKS, and token introspection. Useful for choosing flows, debugging token exchange, and reviewing security properties.
How this skill is triggered — by the user, by Claude, or both
Slash command
/duende-skills:oauth-oidc-protocolsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Use this skill when:
Use this skill when:
/.well-known/openid-configuration rather than hardcoding URLs.OneTimeOnly) and store them server-side.identityserver-configuration — Server-side configuration of clients, resources, and scopesaspnetcore-authentication — Implementing OIDC authentication in ASP.NET Core appstoken-management — Automated token lifecycle with Duende.AccessTokenManagementidentity-security-hardening — Security hardening including DPoP, PAR, and FAPIduende-bff — Backend-for-Frontend pattern for SPAsDocs: https://docs.duendesoftware.com/identityserver/fundamentals
| Role | OAuth 2.0 Term | OIDC Term | Example |
|---|---|---|---|
| User | Resource Owner | End-User | A person logging in |
| Browser/App | Client | Relying Party (RP) | ASP.NET Core web app |
| Token Server | Authorization Server | OpenID Provider (OP) | Duende IdentityServer |
| API | Resource Server | — | ASP.NET Core Web API |
| Token | Purpose | Who Consumes It | Format |
|---|---|---|---|
| ID Token | Proves user identity | Client application | Always JWT |
| Access Token | Authorizes API calls | Resource server (API) | JWT or reference |
| Refresh Token | Obtains new access tokens | Client application | Opaque handle |
Critical Rule: Clients authenticate users with the ID token. Clients authorize API calls with the access token. Never use an access token to determine who a user is. Never send an ID token to an API.
The authorization code flow with PKCE is the recommended flow for all clients that involve a user. PKCE prevents authorization code interception attacks.
How it works:
code_verifier and its SHA256 hash code_challengecode_challengecodecode + code_verifier at the token endpointoffline_access scope)┌──────┐ ┌──────────┐ ┌──────────────┐
│Client│ │ Browser │ │IdentityServer│
└──┬───┘ └────┬─────┘ └──────┬───────┘
│ 1. Generate PKCE │ │
│ code_verifier │ │
│ code_challenge │ │
│ │ │
│ 2. Redirect ───────────────────────────► │
│ /authorize?code_challenge=... │
│ │ 3. User logs in │
│ │ ◄──────────────────► │
│ │ │
│ 4. Redirect back ◄────────────────────── │
│ ?code=abc123 │ │
│ │ │
│ 5. POST /token ─────────────────────────►│
│ code=abc123&code_verifier=... │
│ │ │
│ 6. Tokens ◄──────────────────────────────│
│ { id_token, access_token, │
│ refresh_token } │
└───────────────────┴───────────────────────┘
When to use: Web applications, native apps, SPAs (via BFF pattern).
For machine-to-machine communication with no user involvement.
┌──────────┐ ┌──────────────┐
│ Service │ │IdentityServer│
└────┬─────┘ └──────┬───────┘
│ POST /token │
│ grant_type=client_credentials │
│ client_id=... │
│ client_secret=... │
│ scope=api1 │
│ ───────────────────────────► │
│ │
│ { access_token } │
│ ◄─────────────────────────── │
└─────────────────────────────────┘
When to use: Background services, daemons, server-to-server API calls.
Key difference: No user identity — the access token contains only client claims, not user claims.
┌──────┐ ┌──────────────┐
│Client│ │IdentityServer│
└──┬───┘ └──────┬───────┘
│ POST /token │
│ grant_type=refresh_token │
│ refresh_token=old_rt │
│ ─────────────────────────────► │
│ │
│ { access_token, │
│ refresh_token: new_rt } │
│ ◄───────────────────────────── │
└────────────────────────────────────┘
With
RefreshTokenUsage = OneTimeOnly, each refresh returns a new refresh token. The old one is invalidated. This enables refresh token rotation, a key security measure. Note: the default changed toReUsein IdentityServer v7.0 — setOneTimeOnlyexplicitly for rotation.
| Flow | Status | Why |
|---|---|---|
Implicit (token / id_token) | Deprecated | Tokens in URL fragments; no PKCE protection |
| Resource Owner Password (ROPC) | Discouraged | Client handles credentials directly; no MFA support |
| Hybrid | Replaced | Use Authorization Code + PKCE instead |
Every OpenID Connect provider publishes a discovery document at /.well-known/openid-configuration. This JSON document advertises:
// ✅ Use IdentityModel to fetch discovery programmatically
using var httpClient = new HttpClient();
var disco = await httpClient.GetDiscoveryDocumentAsync("https://identity.example.com");
if (disco.IsError) throw new Exception(disco.Error);
var tokenEndpoint = disco.TokenEndpoint;
var jwksUri = disco.JwksUri;
Best Practice: Never hardcode endpoint URLs. Always resolve them from the discovery document. This ensures your application adapts to URL changes and load balancer configurations.
The JWKS endpoint (advertised via the jwks_uri field in the discovery document; in Duende IdentityServer this is /.well-known/openid-configuration/jwks) publishes the public keys used to verify token signatures. APIs and clients fetch this to validate JWTs.
Key rotation: When IdentityServer rotates signing keys, the new key appears in JWKS during the propagation period before it becomes the active signing key. Client libraries cache JWKS for 24 hours by default.
{
"iss": "https://identity.example.com",
"sub": "818727",
"aud": "web.app",
"exp": 1311281970,
"iat": 1311280970,
"nonce": "n-0S6_WzA2Mj",
"auth_time": 1311280969,
"at_hash": "77QmUPtjPfzWtF2AnpK9RQ",
"amr": ["pwd", "mfa"],
"name": "Alice Smith",
"email": "[email protected]"
}
Key claims:
iss — Issuer (must match your IdentityServer URL)sub — Subject (unique user identifier)aud — Audience (must match the client ID)nonce — Replay protection (sent in authorize request, echoed in token)at_hash — Hash of the access token (binds the ID token to the access token)amr — Authentication methods used{
"iss": "https://identity.example.com",
"aud": "https://api.example.com",
"client_id": "web.app",
"sub": "818727",
"scope": "openid profile api1",
"exp": 1311284570,
"iat": 1311280970,
"jti": "unique-token-id"
}
Key claims:
aud — The API resource(s) this token is valid forclient_id — Which client requested this tokenscope — Granted permissionsjti — Unique token identifier (for revocation tracking)Reference tokens are not self-contained JWTs. Instead, the access token is an opaque identifier. The API must call the introspection endpoint to validate it:
POST /connect/introspect
Content-Type: application/x-www-form-urlencoded
token=<reference_token>&token_type_hint=access_token
When to use reference tokens:
APIs validate reference tokens by calling the introspection endpoint. The API authenticates itself with its own secret:
// Using IdentityModel
var introspectionResponse = await httpClient.IntrospectTokenAsync(
new TokenIntrospectionRequest
{
Address = disco.IntrospectionEndpoint,
ClientId = "api1",
ClientSecret = "api1-secret",
Token = accessToken
});
if (!introspectionResponse.IsActive)
{
// Token is invalid, expired, or revoked
}
Clients can revoke access tokens and refresh tokens:
var revocationResponse = await httpClient.RevokeTokenAsync(
new TokenRevocationRequest
{
Address = disco.RevocationEndpoint,
ClientId = "web.app",
ClientSecret = "secret",
Token = refreshToken,
TokenTypeHint = "refresh_token"
});
| Scope requested | What it controls | Token affected |
|---|---|---|
openid | Returns sub claim | ID token |
profile | Returns name, family_name, etc. | ID token / userinfo |
email | Returns email, email_verified | ID token / userinfo |
api1 | Grants access to API | Access token |
offline_access | Returns refresh token | Refresh token issued |
By default, IdentityServer emits identity claims to the ID token and the userinfo endpoint. Claims associated with API scopes go into the access token. The IProfileService controls claim emission:
public class ProfileService : IProfileService
{
public Task GetProfileDataAsync(ProfileDataRequestContext context)
{
// Add claims based on the requested resources
var claims = GetClaimsForUser(context.Subject);
context.IssuedClaims.AddRange(
claims.Where(c => context.RequestedClaimTypes.Contains(c.Type)));
return Task.CompletedTask;
}
public Task IsActiveAsync(IsActiveContext context)
{
context.IsActive = true; // Check if user account is still active
return Task.CompletedTask;
}
}
PAR moves the authorization parameters from the query string to a backchannel POST, preventing parameter tampering and URL length issues:
1. Client POSTs parameters to /connect/par → gets a request_uri
2. Client redirects user to /authorize?request_uri=...&client_id=...
DPoP binds access tokens to a client's cryptographic key, preventing token theft and replay:
1. Client generates a key pair
2. Client creates a DPoP proof (signed JWT with the public key)
3. Client sends the DPoP proof in the DPoP header with the token request
4. IdentityServer binds the token to the key via a "cnf" claim
5. API verifies the DPoP proof matches the token's "cnf" claim
Financial-grade API profile requires PAR, DPoP or mTLS, and stricter validation. Duende IdentityServer supports FAPI 2.0 compliance from v7.3+.
// ❌ WRONG — Access tokens are for authorization, not authentication
var userId = accessToken.Claims.First(c => c.Type == "sub").Value;
// The access token's audience is the API, not your app
// ✅ CORRECT — Use the ID token (via the authentication middleware)
var userId = User.FindFirst("sub")?.Value;
// ❌ WRONG — Clients should treat access tokens as opaque
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(accessToken);
// This breaks when the server switches to reference tokens
// ✅ CORRECT — Only the resource server (API) validates access tokens
// The client just forwards the token in the Authorization header
httpClient.SetBearerToken(accessToken);
// ❌ WRONG — No PKCE leaves authorization code flow vulnerable
// Duende IdentityServer requires PKCE by default (RequirePkce = true)
// ✅ The ASP.NET Core OIDC handler sends PKCE automatically since .NET 7
// No extra configuration needed on the client side
// ❌ WRONG — Using a token without checking expiration
httpClient.SetBearerToken(cachedAccessToken); // Might be expired
// ✅ CORRECT — Use Duende.AccessTokenManagement for automatic refresh
// See the `token-management` skill
builder.Services.AddOpenIdConnectAccessTokenManagement();
// ❌ WRONG — Breaks when server URL changes
var tokenEndpoint = "https://identity.example.com/connect/token";
// ✅ CORRECT — Resolve from discovery
var disco = await httpClient.GetDiscoveryDocumentAsync(authority);
var tokenEndpoint = disco.TokenEndpoint;
When a token exchange fails, check these in order:
/.well-known/openid-configuration reachable? Does it return valid JSON?AllowedScopes on the client?AllowedGrantTypes?code_challenge and code_verifier? Duende IS requires PKCE by default.AllowedCorsOrigins?Provides CDSS development patterns for drug interaction checking, dose validation, clinical scoring (NEWS2, qSOFA), and alert classification integrated into EMR workflows.
npx claudepluginhub duendesoftware/duende-skills --plugin duende-skills