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/logouthttps://ancientholdings.eu/api/oidc/revokehttps://ancientholdings.eu/api/oidc/earningshttps://ancientholdings.eu/api/oidc/earnings/streamearnings (REST) and earnings_stream (SSE) are hub extensions gated on the earnings scope — advertised in the discovery document as earnings_endpoint / earnings_stream_endpoint. See §3b for the payload and the live stream contract.
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.
3b · Earnings over the login API (the earnings scope)
A client granted the earningsscope can read the signed-in user’s earnings straight from the OIDC flow — two read-only surfaces, same bearer as everything else:
- Summary on
userinfo. Withearningsgranted, theuserinforesponse carries anearningsclaim: total Stoicism (splitstoicismBase/stoicismPythiaplusstoicismTotal), the un-flushed PythiastoicismPythiaHot(the “hot” bucket that resets at the daily 00:00 UTC flush;stoicismPythiais the lifetime figure) — plus both in B.UNA asstoicismPythiaBUna/stoicismPythiaHotBUna(= Stoicism × 1,000,000, exact) — totalpetitionsandpondus, topopusLevel, andhasVerifiedAccount— decimals as exact strings. One call, headline figures. - Full breakdown at
GET /api/oidc/earnings. The per-account detail: base node-scoring Stoicism (lifetime + today/week/month + pending/redeemed), and per Ouronet account the Pythia Petitions/Pondus, PythLevel / ErgonLevel / Opus Level with ladder progress, current UNA-per-pondus mint rate, minted Pythia Stoicism (lifetimepythiaStoicismDec+ the un-flushedpythiaHotDec, and both in B.UNA aspythiaLifetimeBUna/pythiaHotBUna), per-slot contributions, recent settles, and the held bucket. Same access token; a token without theearningsscope gets403 insufficient_scope. - Live stream at
GET /api/oidc/earnings/stream. A Server-Sent Events version of the resource: same bearer +earningsscope, same{ sub, earnings }body, held open and pushed on change. Emitsevent: earningsonce on connect then whenever earnings move, a distinctevent: flushat the daily 00:00 UTC hot→pool check-in,: keepalivecomments, andretry: 5000. Auth failures resolve before the stream opens (401 / 403 like the resource); a mid-stream token expiry closes withevent: error. Feature-detect it off the discovery document’searnings_stream_endpointand fall back to polling the resource when it’s absent. It’s built for a server-to-server consumer (bearer in the request header), so it fits a backend that holds the access token and fans out to browsers itself.
Earnings only ever settle to a verified Ouronet account — if hasVerifiedAccount is false, usage accrues into the held bucket and settles the moment the account is verified.
3c · Staying logged in (the offline_access scope + refresh tokens)
Access tokens are short-lived (5 minutes). A confidential client that requests the offline_access scope also receives a long-lived refresh token at the token exchange, which it can swap for a fresh access token without sending the user back through login — so a live resource (e.g. the earnings stream) survives access-token expiry.
- Request it: add
offline_accessto the authorizescope(feature-detect viascopes_supported). The token response then includes arefresh_tokennext to theaccess_token. - Renew:
POSTto the token endpoint withgrant_type=refresh_token+refresh_token=…and yourclient_secret_basicauth. The response carries a newaccess_tokenAND a rotatedrefresh_token— store the new one and discard the old. A401 invalid_tokenon a resource is your cue to refresh once and retry. - Rotation + reuse detection: every refresh rotates. If a rotated-out (or stolen) refresh token is ever presented again, the whole chain is revoked and the grant fails
invalid_grant— so your client must always persist the newest token from each refresh. - Lifetime + revocation: refresh tokens last 60 days (sliding on each rotation). Revoke one on your own logout via
POST {revoke}(RFC 7009:token=<refresh_token>+ client auth). They’re also revoked automatically when the operator logs out of the hub.
Refresh tokens are issued to confidential clients only (never public/PKCE-only clients). The 5-minute access-token lifetime is unchanged — rotation + reuse-detection + revocation are what bound a refresh token’s risk, not a short TTL.
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).