OIDC Integration
BluAuth is a standards-compliant OpenID Connect provider. Downstream Blu apps sign users in by redirecting them to BluAuth, exchanging a one-time authorization code for signed tokens, and validating the ID token. This page walks through the full flow and documents the exact parameters, claims, and error conditions a production client must handle.
Any greenfield Blu app must integrate via this path. The legacy /oauth2/* broker exists only to support pre-existing apps during migration.
Discovery
BluAuth publishes a spec-compliant discovery document:
GET https://auth.example.com/.well-known/openid-configuration
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/api/oidc/authorize",
"token_endpoint": "https://auth.example.com/api/oidc/token",
"userinfo_endpoint": "https://auth.example.com/api/oidc/userinfo",
"jwks_uri": "https://auth.example.com/api/oidc/jwks",
"introspection_endpoint": "https://auth.example.com/api/oidc/token/introspect",
"revocation_endpoint": "https://auth.example.com/api/oidc/token/revoke",
"end_session_endpoint": "https://auth.example.com/api/oidc/end-session",
"scopes_supported": ["openid", "profile", "email", "admin"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "client_credentials"],
"subject_types_supported": ["pairwise"],
"id_token_signing_alg_values_supported": ["ES256"],
"token_endpoint_auth_methods_supported": ["private_key_jwt"],
"code_challenge_methods_supported": ["S256"]
}
Your OIDC client library should consume the discovery document and reuse its URLs. Do not hardcode endpoints — they are versioned with the service.
Source: server/routes/.well-known/openid-configuration.get.ts.
Register a client
Before integrating, ask a BluAuth admin to create an OAuth client for your app (see /admin/clients). You will receive:
- Client ID — a UUID. Used as
client_idand as theaudclaim in ID tokens. - Client authentication — either
private_key_jwt(recommended confidential clients) or PKCE-only (public clients / SPAs). - Allowed scopes — typically
openid profile email.adminis reserved for service clients. - Redirect URIs — BluAuth rejects any
redirect_urithat does not exactly match one of the registered values. - Post-logout redirect URIs — allow-listed separately from redirect URIs.
The authorization code flow with PKCE
PKCE is required for every client, public or confidential. BluAuth rejects any code_challenge_method other than S256, and rejects authorization requests without a nonce.
1. Generate PKCE values and anti-forgery state
import { randomBytes, createHash } from 'node:crypto';
const codeVerifier = randomBytes(64).toString('base64url'); // 43–128 chars
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
const state = randomBytes(32).toString('base64url');
const nonce = randomBytes(32).toString('base64url');
Persist codeVerifier, state, and nonce in a server-side session keyed to the browser (or a signed, HttpOnly cookie). The values must survive the round-trip to BluAuth.
2. Redirect the user to the authorization endpoint
GET https://auth.example.com/api/oidc/authorize
?client_id=7f9b0e7e-...-a11c
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback
&response_type=code
&scope=openid%20profile%20email
&state=<opaque>
&nonce=<opaque>
&code_challenge=<pkce-challenge>
&code_challenge_method=S256
Required parameters:
| Parameter | Value |
|---|---|
client_id | Registered client UUID. |
redirect_uri | Must exactly match one of the client's registered URIs. |
response_type | code. Any other value returns unsupported_response_type. |
scope | Space-separated. Must be a subset of the client's allowedScopes. |
state | Random opaque value; echoed back on callback. |
nonce | Random opaque value; mirrored into the ID token's nonce claim. |
code_challenge | Base64url-encoded SHA-256 of the code verifier. |
code_challenge_method | S256. |
Source: server/api/oidc/authorize.get.ts.
3. BluAuth handles the user
- If the user has an active BluAuth session, BluAuth issues an authorization code and redirects back immediately (silent re-auth).
- Otherwise, BluAuth renders the hosted login page, completes sign-in (local password or federated), and then issues the code.
- If the user requires a forced password reset, BluAuth sends them through
/reset-passwordfirst, returning to the authorize flow on completion. - If the user denies consent or cancels, BluAuth redirects to
redirect_uriwith?error=access_denied&state=<echoed>.
4. Exchange the code at the token endpoint
POST https://auth.example.com/api/oidc/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<code>
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback
&client_id=7f9b0e7e-...-a11c
&code_verifier=<pkce-verifier>
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=<signed-jwt>
Confidential clients authenticate with private_key_jwt (the canonical BluAuth method — see token_endpoint_auth_methods_supported). The client_assertion is a JWT signed with the client's registered key, with iss and sub set to the client ID and aud set to the token endpoint URL.
Public clients omit client_assertion / client_secret and rely on PKCE alone.
Successful response (tokens trimmed for readability):
{
"access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImJsdWF1dGgtMTcx...",
"id_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImJsdWF1dGgtMTcx...",
"token_type": "Bearer",
"expires_in": 3600
}
Both tokens are ES256-signed JWTs (EC P-256, alg: "ES256"). expires_in is fixed at 3600 seconds (1 hour). Response headers include Cache-Control: no-store and Pragma: no-cache per RFC 6749 §5.1.
Source: server/api/oidc/token.post.ts, server/utils/oidc/tokens.ts.
5. Validate the ID token
Validate, in order:
- Signature. Fetch the JWKS from
/api/oidc/jwks, select the key matching the JWT'skid, and verify with ES256. issequals the issuer returned by discovery (e.g.https://auth.example.com).audequals your client ID.expis in the future;iatis not in the future (allow 2 minutes of skew).nonceequals the value you sent in step 2.at_hashmatches the left-most 128 bits of SHA-256 of the access token, base64url-encoded.
Signing keys rotate on a schedule; consumers that hardcode JWKS responses will break. Any mature OIDC client library performs these checks correctly — use one.
6. Call userinfo (optional)
GET https://auth.example.com/api/oidc/userinfo
Authorization: Bearer <access_token>
{
"sub": "6f2a8e7e-...-pairwise",
"name": "Jane Doe",
"given_name": "Jane",
"family_name": "Doe",
"picture": "https://cdn.example.com/u/jane.png",
"email": "jane@example.com",
"email_verified": true,
"emails": ["jane@example.com", "jane.doe@example.com"],
"auth_method": "password",
"linked_providers": ["google"],
"current_provider": "credential",
"mfa_satisfied": true,
"auth_assurance_level": "aal2",
"assurance_source": "local_2fa"
}
Returned claims are scoped — you only get profile claims if the profile scope was granted, and only email claims if email was granted. sub is pairwise — the same user has a different sub for each client, derived deterministically. emails lists all verified aliases so downstream apps can match on any of them.
The current_provider, mfa_satisfied, auth_assurance_level, and assurance_source claims describe the current sign-in event, not the user's long-term account shape. Downstream apps can use them for admission control such as provider-constrained access rules or current-session MFA requirements.
Source: server/utils/oidc/userinfo.ts.
ID token claims
| Claim | Notes |
|---|---|
iss | Issuer, matches discovery. |
sub | Pairwise per (user, client). Stable across sessions. |
aud | Your client ID. |
exp, iat | Expiry and issue time (Unix seconds). TTL is 1 hour. |
nonce | Echoed from authorize request. |
at_hash | Left half of SHA-256 of the access token. |
auth_method | password, <provider-slug>, or service. |
linked_providers | Array of provider slugs the user has linked. |
current_provider | Provider that authenticated the current session. |
mfa_satisfied | true, false, or null for the current session. |
auth_assurance_level | Assurance summary such as aal1, aal2, or null. |
assurance_source | How BluAuth derived assurance for the session. |
email, email_verified, emails | Present when email scope granted. |
name, given_name, family_name, picture | Present when profile scope granted. |
Refreshing tokens
BluAuth does not issue OAuth refresh tokens to downstream apps today. When an access token expires, your client should re-enter the authorize flow. If the user's BluAuth session is still active, BluAuth issues a new code without user interaction (silent re-auth via prompt=none is supported).
Plan for this in UI code: when a token expires mid-session, redirect to /api/oidc/authorize in a hidden iframe (or via full navigation), complete the code exchange, and resume.
Logout
RP-initiated logout follows OIDC RP-Initiated Logout 1.0:
GET https://auth.example.com/api/oidc/end-session
?id_token_hint=<id_token>
&post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Fsigned-out
&state=<opaque>
BluAuth clears the Better Auth session cookie, deletes the session row from Postgres, and redirects to post_logout_redirect_uri (which must be pre-registered on the client). See Session Contract for full logout semantics.
Source: server/api/oidc/end-session.get.ts.
Revocation and introspection
BluAuth implements RFC 7009 (Token Revocation) and RFC 7662 (Token Introspection). Both require client authentication.
POST https://auth.example.com/api/oidc/token/revoke
Content-Type: application/x-www-form-urlencoded
token=<access_token>&token_type_hint=access_token
POST https://auth.example.com/api/oidc/token/introspect
Content-Type: application/x-www-form-urlencoded
token=<access_token>
{
"active": true,
"sub": "6f2a8e7e-...-pairwise",
"client_id": "7f9b0e7e-...-a11c",
"scope": "openid profile email",
"token_type": "Bearer",
"exp": 1744823025,
"iat": 1744819425
}
Edge cases
- State mismatch on callback. Reject the response and restart the flow. Never trust an unvalidated
state. - Expired code. Codes are valid for 10 minutes. Attempting to exchange an expired code returns
invalid_grant; the code is deleted from the store on detection. - Reused code. Codes are single-use. The first successful exchange deletes the row; the second attempt returns
invalid_grant. - PKCE verifier mismatch. Returns
invalid_grant. Ensure you're sending the exact verifier you generated — not the challenge. - Scope escalation. Requesting a scope outside
allowedScopesaborts the flow at/authorizewithinvalid_scope. - Changing
redirect_urimid-flow. The value in step 4 must exactly match step 2. Case-sensitive, query-string-sensitive.
Example client (Node + openid-client)
import { Issuer, generators } from 'openid-client';
const issuer = await Issuer.discover('https://auth.example.com');
const client = new issuer.Client(
{
client_id: '7f9b0e7e-...-a11c',
redirect_uris: ['https://app.example.com/auth/callback'],
response_types: ['code'],
token_endpoint_auth_method: 'private_key_jwt',
id_token_signed_response_alg: 'ES256'
},
{
keys: [
/* your JWK */
]
}
);
// Redirect
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
const state = generators.state();
const nonce = generators.nonce();
const authUrl = client.authorizationUrl({
scope: 'openid profile email',
code_challenge,
code_challenge_method: 'S256',
state,
nonce
});
// Callback
const params = client.callbackParams(req);
const tokenSet = await client.callback('https://app.example.com/auth/callback', params, { code_verifier, state, nonce });
const claims = tokenSet.claims();
Errors
BluAuth returns spec-compliant OAuth 2.0 error responses on the authorize endpoint (query-string redirect) and on the token endpoint (JSON body).
| Error | Meaning |
|---|---|
invalid_request | Missing or malformed parameter. |
unauthorized_client | Client exists but isn't allowed for this grant or scope. |
access_denied | User cancelled, denied consent, or was blocked by policy. |
invalid_grant | Code expired, already used, or PKCE verifier mismatched. |
invalid_scope | Requested scope isn't in allowedScopes. |
unsupported_response_type | response_type was not code. |
server_error | Internal — report with request ID. |
For full server-side error codes and JSON response shapes see API errors and the API reference.
Picking a library
Any conformant OIDC client library works. Verified-compatible picks:
- Node / TypeScript (server):
openid-client. - Browser / SPA:
oidc-client-ts. - Go:
go-oidc. - Python:
authlib. - Rust:
openidconnect.
Always configure via discovery URL. Always verify ID token signatures against the JWKS. Never skip nonce validation.