'use strict'; /* * OpenID Connect — discovery, key handling and ID-token verification. * * This exists because the previous "OAuth" support verified nothing that mattered. The Google path * asked Google's tokeninfo endpoint whether an ACCESS token was valid and then trusted the email in * the reply; the Microsoft path handed a bearer token to Graph /me and trusted that. Neither ever * checked WHO THE TOKEN WAS ISSUED FOR, and an access token is not a proof of identity — it is a * bearer credential for some resource, minted for some application, and Graph will happily describe * the user behind a token issued to somebody else's app. Any site a user signs into that asks for * `email` or `User.Read` could replay that token here and be issued a session as that user. * * So identity now comes from an ID TOKEN and nothing else, and the token has to survive: * * signature against the provider's published JWKS, restricted to asymmetric algorithms * iss exactly the issuer discovery advertised * aud contains our client_id (and azp === client_id when the token carries one) * exp/nbf inside a small clock skew * nonce equal to the one WE generated for this login, which is what stops a token * obtained elsewhere — even a correctly-audienced one — being replayed here * * Deliberately dependency-free beyond `jsonwebtoken`: Node can import a JWK straight into a * KeyObject, so there is no need for jwks-rsa and no second opinion about what a key is. */ const crypto = require('crypto'); const jwt = require('jsonwebtoken'); /* * `alg: "none"` is the oldest JWT attack there is, and HMAC is nearly as bad here: an HS256 token is * verified with a SHARED SECRET, and the only "key" we have for a provider is its PUBLIC one — which * an attacker also has, and could sign with. Only asymmetric families are ever acceptable. */ const ALLOWED_ALGS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'PS256', 'PS384', 'PS512']; // Providers rotate keys and publish new ones ahead of use, so a short cache is safe and a miss is // cheap. Discovery changes far less often but is cached the same way for one reason: a provider // outage should not be able to stall every login for as long as it lasts. const DISCOVERY_TTL_MS = 60 * 60 * 1000; // 1 hour const JWKS_TTL_MS = 10 * 60 * 1000; // 10 minutes const FETCH_TIMEOUT_MS = 8000; const discoveryCache = new Map(); // issuer -> { at, doc } const jwksCache = new Map(); // jwks_uri -> { at, keys } /** fetch with a timeout, because a hanging IdP must not hang a login forever. */ async function getJson(url) { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS); try { const res = await fetch(url, { signal: ctl.signal, redirect: 'follow' }); if (!res.ok) throw new Error(`${url} responded ${res.status}`); return await res.json(); } finally { clearTimeout(timer); } } /** * The provider's own description of itself. * * ⚠️ The discovered `issuer` is checked against the configured one. Discovery is fetched over TLS * from a URL derived from the issuer, so this is belt-and-braces — but a provider whose document * claims a DIFFERENT issuer is either misconfigured or hostile, and either way its tokens must not * be accepted under a name it does not own. */ async function discover(issuer) { const key = String(issuer).replace(/\/+$/, ''); const hit = discoveryCache.get(key); if (hit && Date.now() - hit.at < DISCOVERY_TTL_MS) return hit.doc; const url = `${key}/.well-known/openid-configuration`; const doc = await getJson(url); const advertised = String(doc.issuer || '').replace(/\/+$/, ''); if (advertised !== key) { throw new Error(`discovery issuer mismatch: configured ${key}, document says ${doc.issuer}`); } for (const required of ['authorization_endpoint', 'token_endpoint', 'jwks_uri']) { if (!doc[required]) throw new Error(`discovery for ${key} is missing ${required}`); } discoveryCache.set(key, { at: Date.now(), doc }); return doc; } /** * The signing key for one token. * * An unknown `kid` forces ONE refresh: that is the normal shape of a key rotation, and refusing to * refetch would fail every login until the cache expired. It is bounded to one refresh per call so * a token quoting nonsense cannot be used to hammer the provider. */ async function keyForKid(jwksUri, kid) { let entry = jwksCache.get(jwksUri); const fresh = entry && Date.now() - entry.at < JWKS_TTL_MS; if (!fresh || !entry.keys.some((k) => k.kid === kid)) { const doc = await getJson(jwksUri); entry = { at: Date.now(), keys: Array.isArray(doc.keys) ? doc.keys : [] }; jwksCache.set(jwksUri, entry); } const jwk = entry.keys.find((k) => k.kid === kid) // A provider with exactly one key may omit kid entirely; anything ambiguous is refused rather // than guessed, because "try each key until one verifies" is how you accept a key you did not mean to. || (!kid && entry.keys.length === 1 ? entry.keys[0] : null); if (!jwk) throw new Error(`no signing key for kid ${kid || '(none)'}`); return crypto.createPublicKey({ key: jwk, format: 'jwk' }); } /** * Verify an ID token and return its claims. * * `nonce` is REQUIRED by this function even though the spec makes it conditional. Every flow here * is a browser login we initiated, so we always have one to compare — and it is the single check * that distinguishes "a token minted for us, now" from "a token minted for us at some point, * captured, and replayed". */ async function verifyIdToken(idToken, { issuer, clientId, nonce }) { if (!idToken || typeof idToken !== 'string') throw new Error('no id_token'); if (!nonce) throw new Error('no nonce to verify against'); const decoded = jwt.decode(idToken, { complete: true }); if (!decoded || !decoded.header) throw new Error('id_token is not a JWT'); if (!ALLOWED_ALGS.includes(decoded.header.alg)) { throw new Error(`refusing id_token algorithm ${decoded.header.alg}`); } const doc = await discover(issuer); const key = await keyForKid(doc.jwks_uri, decoded.header.kid); // jsonwebtoken checks signature, exp, nbf, iss and aud. The algorithm allowlist is passed // explicitly so the header cannot choose how it is verified. const claims = jwt.verify(idToken, key, { algorithms: ALLOWED_ALGS, issuer: doc.issuer, audience: clientId, clockTolerance: 60, }); if (claims.nonce !== nonce) throw new Error('id_token nonce does not match this login'); /* * azp names the party the token was issued TO when it differs from the audience. If it is present * it must be us: a token with our client_id merely in a multi-valued `aud`, issued to a different * application, is exactly the confused-deputy case this whole file exists to prevent. */ if (claims.azp && claims.azp !== clientId) { throw new Error('id_token was issued to a different application'); } if (!claims.sub) throw new Error('id_token has no subject'); return claims; } /** PKCE S256. The verifier never leaves us; only its hash goes to the provider. */ function createPkce() { const verifier = crypto.randomBytes(32).toString('base64url'); const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); return { verifier, challenge, method: 'S256' }; } const randomToken = () => crypto.randomBytes(32).toString('base64url'); /** * Exchange the authorization code. * * PKCE means a public client needs no secret, which is what lets a self-hoster configure a provider * without one. A secret is still sent when configured, because some providers (and some admins) * require confidential clients. */ async function exchangeCode({ issuer, clientId, clientSecret, code, redirectUri, verifier }) { const doc = await discover(issuer); const body = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, code_verifier: verifier, }); const headers = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }; if (clientSecret) { // client_secret_basic is the form every provider accepts; client_secret_post is not universal. headers.Authorization = 'Basic ' + Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`).toString('base64'); } const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS); let payload; try { const res = await fetch(doc.token_endpoint, { method: 'POST', headers, body, signal: ctl.signal }); payload = await res.json().catch(() => ({})); if (!res.ok) { // The provider's own error is far more useful than "exchange failed" — a wrong redirect_uri // or an unregistered client is the overwhelmingly common setup mistake and it says so here. throw new Error(payload.error_description || payload.error || `token endpoint responded ${res.status}`); } } finally { clearTimeout(timer); } if (!payload.id_token) throw new Error('provider returned no id_token — is the openid scope requested?'); return payload; } /** Test seam: drop cached discovery/JWKS so a test can change what a provider claims. */ function _resetCaches() { discoveryCache.clear(); jwksCache.clear(); } module.exports = { discover, verifyIdToken, exchangeCode, createPkce, randomToken, ALLOWED_ALGS, _resetCaches, };