Documentation / 19. SSO integration (OIDC)

Chapter 19

SSO integration (OIDC)

Integrate an app with AncientHoldings single sign-on. The hub is a central OpenID Provider: users log in with their hub account and are recognised by role (ancient / modern / baron / operator). Register a client, run the authorize→callback→token flow against the exact endpoint URLs, and validate the RS256 id_token via JWKS — with a copy-paste Node/Hono verify snippet that pins issuer, audience, RS256, a random nonce, keys the local user on the opaque sub, and gates on roles.includes("ancient"). Token introspection is intentionally absent; consumers feature-detect via discovery.

The AncientHoldings hub is a central OpenID Provider (OIDC). An app you build lets a user log in with their AncientHoldings account (e.g. kjrkentolopon@ancientholdings.eu) and recognises them by their hub roleancient, modern, baron, or operator. The hub owns the user database; your app trusts the hub’s RS256-signed id_token and validates it statelessly against the published JWKS.

In one sentence

Register a confidential client, run the Authorization-Code + PKCE flow, verify the returned id_tokenagainst the hub’s JWKS with a pinned issuer + audience + RS256, then gate on roles.includes("ancient").

1 · Register a client

A hub ancient admin registers your app as a client from the SSO admin surface. Registration returns a client_id and a one-time client_secret (≥32 bytes from a CSPRNG, shown once, stored only as a salted hash — it is never re-derivable, so capture it at creation). If the secret is lost, the admin can Rotate secret on the client to mint a fresh one (the previous secret stops verifying immediately). You register your exact redirect_uri — matching is byte-for-byte, so .../callback and .../callback/ are different URIs. You may register several and edit them later on the client; they must be https:// except a http://localhost (or 127.0.0.1 / [::1]) callback, allowed for local-dev logins. For a confidential app, prefer a separate client per environment (a dev client with the localhost callback + its own secret) over sharing the production secret with dev machines. Confidential clients that run the flow server-to-server register no CORS origin; a confidential secret must never be exposed browser-side.

2 · Config a consumer sets

issuerhttps://ancientholdings.eu
client_id<from registration>
client_secret<one-time, from registration>
redirect_urihttps://your-app.example/admin/callback
jwks_urihttps://ancientholdings.eu/api/oidc/jwks

The issuer is the trust anchor — pin it byte-for-byte. Every advertised URL derives from it; the hub never reconstructs it from a request Host header.

3 · Endpoint URLs

All endpoints derive from the issuer above. Fetch the discovery document and read the endpoints from it at runtime rather than hardcoding — that is the feature-detect contract (see §6):

discoveryhttps://ancientholdings.eu/.well-known/openid-configuration
jwks_urihttps://ancientholdings.eu/api/oidc/jwks
authorizehttps://ancientholdings.eu/api/oidc/authorize
tokenhttps://ancientholdings.eu/api/oidc/token
userinfohttps://ancientholdings.eu/api/oidc/userinfo
logouthttps://ancientholdings.eu/api/oidc/logout

No trailing-slash redirect. The OIDC endpoints are served at exactly these URLs — a POST to /api/oidc/token(or any /api/oidc/*route) returns the endpoint’s real response directly and is never 308-redirected to a trailing-slash variant. An HTTP client that would otherwise drop the request body / Authorization header across a 307/308 therefore works with no workaround — use the URLs verbatim from discovery.

4 · The authorize → callback → token flow

  1. Redirect to /authorize. Send response_type=code, your client_id, your exact redirect_uri, scope=openid roles (plus profile/email as needed), a random state, a random nonce, and a PKCE code_challenge with code_challenge_method=S256 (PKCE S256 is mandatory — a plain or absent challenge is rejected). If the user has no hub session they are bounced to the hub login and returned here.
  2. Receive the code at your redirect_uri. The hub 302s back with ?code=…&state=…. Check state equals what you sent.
  3. Exchange the code at /token. POST grant_type=authorization_code, the code, the matching code_verifier, the same redirect_uri, and your confidential client credentials. You receive an id_token (RS256) plus a short-lived access_token with a distinct token_use and aud — do not confuse the two. Codes are single-use and short-lived; a replay is rejected generically.
  4. Verify the id_token. Validate it against JWKS as in §5, then key your local user on sub and gate on the roles claim.

5 · Verify the id_token (Node / Hono)

Save the snippet below and call verifyIdToken(id_token, { clientId, expectedNonce }) after the token exchange. It is the exact contract the hub’s own integration tests run — copy it faithfully:

// verify-id-token.mjs — Node 18+. Requires: npm i jose
import { createRemoteJWKSet, jwtVerify } from 'jose';

// From your client config (see §2). The issuer is the trust anchor —
// pin it byte-for-byte; never derive it from a response header.
const ISSUER   = 'https://ancientholdings.eu';
const CLIENT_ID = process.env.SSO_CLIENT_ID; // your own client_id
const JWKS_URI  = ISSUER + '/api/oidc/jwks';

// createRemoteJWKSet (NOT createLocalJWKSet): it refetches on an unknown
// kid, so key rotation just works. A static local set never refetches and
// would silently reject every token signed by a rotated-in key.
const JWKS = createRemoteJWKSet(new URL(JWKS_URI));

/**
 * Verify a hub id_token and return its verified claim set.
 * @param {string} token          the id_token from /token
 * @param {{clientId:string, expectedNonce:string}} opts
 * @returns the verified claims: { sub, roles, iss, aud, nonce, exp }
 */
export async function verifyIdToken(token, { clientId, expectedNonce }) {
  // Pin issuer + audience + RS256. This single call rejects:
  //  - alg:none / HMAC (HS256): the RSA public key is PUBLISHED, so an
  //    HS256 token keyed on it must fail — algorithms:['RS256'] excludes it,
  //    and we never trust the token's own alg header.
  //  - a token whose iss !== ISSUER, or whose aud is a DIFFERENT client_id
  //    (cross-client substitution).
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: ISSUER,
    audience: clientId,          // your own client_id — reject cross-client aud
    algorithms: ['RS256'],       // never trust the token's own alg header
    clockTolerance: 60,          // ~60s exp/iat leeway for clock skew
  });

  // Always send a random nonce to /authorize and verify it here. jwtVerify
  // alone would pass a replayed token with a stale nonce; this check stops it.
  if (payload.nonce !== expectedNonce) {
    throw new Error('nonce mismatch');
  }

  const roles = Array.isArray(payload.roles) ? payload.roles : [];

  return {
    // Key your local user record on sub — opaque + stable. NEVER on
    // email/preferred_username (mutable, display-only).
    sub: payload.sub,
    // roles is a SET: ignore unknown strings, honour the hierarchy
    // ancient > modern > baron > operator. The ancient-admin gate:
    isAncientAdmin: roles.includes("ancient"),
    roles,
    iss: payload.iss,
    aud: payload.aud,
    nonce: payload.nonce,
    exp: payload.exp,
  };
}

6 · Why each pin matters

  • algorithms: ['RS256'] + reject alg:none/HMAC. Never trust the token’s own algheader. The hub’s RSA public key is published in JWKS, so an alg:none token has no signature to check and an HS256/HMAC token forged with the public key bytes as the secret would otherwise verify. Pinning ['RS256'] excludes both — the published public key can only verify an asymmetric RS256 signature.
  • Pinned issuer === https://ancientholdings.eu. Reject any token whose iss is not exactly the configured issuer.
  • Pinned audience === your own client_id. Reject an id_token whose aud is a different client — this is the cross-client substitution guard. A token minted for another app must not authenticate a user into yours.
  • Always send + verify a random nonce. Bind the nonce you sent to /authorize and reject any id_token whose nonce does not match — this defeats token replay/injection even when the signature is valid.
  • Key the local user on sub, never on email/preferred_username. sub is an opaque, stable per-user id. email, preferred_username, and display_name are mutable, display-onlyclaims — keying on them breaks the moment a user’s canonical email changes.
  • Treat roles as a set; ignore unknown roles. roles is an array (0-or-1 element today, may grow). Do not switch-exhaustive on it and ignore any unrecognised role string. The tiers form a strict hierarchy ancient > modern > baron > operator; baron/modern/ancient carry operator capability. The ancient-admin gate is exactly roles.includes("ancient").
  • Small exp/iat leeway (~60s). Allow a little clock skew, no more.

No token introspection — this is intentional

There is deliberately no token-introspection endpoint. Stateless JWKS validation is the sole contract — you verify the RS256 signature and claims locally, you never call back to the hub to ask if a token is valid. Likewise there is no refresh token in v1; consumers re-run /authorize (cheap, since they hold their own session). Both are documented contracts, not omissions. Any future addition (refresh, introspection, new scopes) is an additive discovery change: always feature-detect via the discovery document — never hardcode the grant-type or scope set.

Related: Hub account tiers (what each role can do) and Audit integrity (verifying signed exports — the same publish-the-public-key trust model).