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 role — ancient, 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
https://ancientholdings.eu<from registration><one-time, from registration>https://your-app.example/admin/callbackhttps://ancientholdings.eu/api/oidc/jwksThe 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):
https://ancientholdings.eu/.well-known/openid-configurationhttps://ancientholdings.eu/api/oidc/jwkshttps://ancientholdings.eu/api/oidc/authorizehttps://ancientholdings.eu/api/oidc/tokenhttps://ancientholdings.eu/api/oidc/userinfohttps://ancientholdings.eu/api/oidc/logoutNo 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
- Redirect to
/authorize. Sendresponse_type=code, yourclient_id, your exactredirect_uri,scope=openid roles(plusprofile/emailas needed), a randomstate, a randomnonce, and a PKCEcode_challengewithcode_challenge_method=S256(PKCE S256 is mandatory — aplainor absent challenge is rejected). If the user has no hub session they are bounced to the hub login and returned here. - Receive the code at your
redirect_uri. The hub 302s back with?code=…&state=…. Checkstateequals what you sent. - Exchange the code at
/token. POSTgrant_type=authorization_code, thecode, the matchingcode_verifier, the sameredirect_uri, and your confidential client credentials. You receive anid_token(RS256) plus a short-livedaccess_tokenwith a distincttoken_useandaud— do not confuse the two. Codes are single-use and short-lived; a replay is rejected generically. - Verify the
id_token. Validate it against JWKS as in §5, then key your local user onsuband gate on therolesclaim.
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']+ rejectalg:none/HMAC. Never trust the token’s ownalgheader. The hub’s RSA public key is published in JWKS, so analg:nonetoken has no signature to check and anHS256/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 whoseissis not exactly the configured issuer. - Pinned
audience=== your ownclient_id. Reject an id_token whoseaudis 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 thenonceyou sent to/authorizeand reject any id_token whosenoncedoes not match — this defeats token replay/injection even when the signature is valid. - Key the local user on
sub, never onemail/preferred_username.subis an opaque, stable per-user id.email,preferred_username, anddisplay_nameare mutable, display-onlyclaims — keying on them breaks the moment a user’s canonical email changes. - Treat
rolesas a set; ignore unknown roles.rolesis an array (0-or-1 element today, may grow). Do notswitch-exhaustive on it and ignore any unrecognised role string. The tiers form a strict hierarchyancient > modern > baron > operator;baron/modern/ancientcarry operator capability. The ancient-admin gate is exactlyroles.includes("ancient"). - Small
exp/iatleeway (~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).