'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 net = require('net'); 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 } /* * Every URL this module fetches is ultimately chosen by whoever configured the provider — and since * per-org SSO, that is a CUSTOMER, not the operator. Discovery, JWKS and the token endpoint are * therefore server-side request forgery primitives unless they are constrained. * * Two rules, both cheap: * https only — an http:// target is a plaintext credential leak as well as a way to reach * services that never expected a request from inside the network. * public hosts only — loopback, RFC1918, CGNAT, link-local (169.254.169.254 is cloud metadata), * multicast and reserved ranges, in BOTH address families, including the * IPv4-mapped IPv6 forms that a prefix match misses. * * ⚠️ This is a literal-address check, not full SSRF protection: a hostname that RESOLVES to a * private address still passes, because refusing that needs resolve-then-pin plumbing that Node's * fetch does not expose. It raises the bar from "type an internal URL" to "control public DNS". * README.md documents this limitation under per-organization SSO. */ /* * Addresses are parsed as ADDRESSES and compared by range. This started life as a prefix regex, * which was wrong in both directions: it missed `[::ffff:127.0.0.1]` — the entire IPv4 space * re-encoded, which WHATWG URL normalises to `[::ffff:7f00:1]` so no dotted-quad prefix can match, * and a review reached a loopback service straight through it — while also matching plain TEXT, so * every hostname beginning "fc" or "fd" was refused (fcm.googleapis.com, fcps.edu). */ const BLOCKED_V4 = [ ['0.0.0.0', 8], // "this network" ['10.0.0.0', 8], // RFC1918 ['100.64.0.0', 10], // CGNAT / Tailscale ['127.0.0.0', 8], // loopback ['169.254.0.0', 16], // link-local — 169.254.169.254 is cloud metadata ['172.16.0.0', 12], // RFC1918 ['192.0.0.0', 24], // IETF protocol assignments ['192.168.0.0', 16], // RFC1918 ['198.18.0.0', 15], // benchmarking ['224.0.0.0', 4], // multicast ['240.0.0.0', 4], // reserved ]; const v4ToInt = (ip) => ip.split('.').reduce((acc, o) => (acc * 256) + Number(o), 0); function isBlockedV4(ip) { const addr = v4ToInt(ip); return BLOCKED_V4.some(([base, bits]) => { const mask = bits === 0 ? 0 : (-1 << (32 - bits)) >>> 0; return (addr & mask) >>> 0 === (v4ToInt(base) & mask) >>> 0; }); } function isBlockedV6(ip) { const low = ip.toLowerCase(); // An IPv4-mapped or IPv4-compatible address is an IPv4 address wearing a hat — judge the IPv4. const mapped = low.match(/^::(ffff:)?(\d+\.\d+\.\d+\.\d+)$/) || low.match(/^::(ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); if (mapped) { if (mapped[2] && mapped[2].includes('.')) return isBlockedV4(mapped[2]); const hi = parseInt(mapped[2], 16), lo = parseInt(mapped[3], 16); return isBlockedV4([hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join('.')); } if (low === '::' || low === '::1') return true; // unspecified (= loopback on Linux), loopback if (/^f[cd]/.test(low)) return true; // fc00::/7 unique-local if (/^fe[89ab]/.test(low)) return true; // fe80::/10 link-local if (/^ff/.test(low)) return true; // multicast return false; } function assertFetchable(url) { let u; try { u = new URL(url); } catch { throw new Error(`not a URL: ${url}`); } if (u.protocol !== 'https:') throw new Error('provider URLs must use https'); const host = u.hostname; // URL keeps IPv6 literals in brackets; net.isIP does not want them. const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; const family = net.isIP(bare); const blocked = family === 4 ? isBlockedV4(bare) : family === 6 ? isBlockedV6(bare) : /^(localhost|.*\.localhost)$/i.test(host); if (blocked) throw new Error('provider host is not publicly routable'); return u; } /** fetch with a timeout, because a hanging IdP must not hang a login forever. */ async function getJson(url) { assertFetchable(url); const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS); try { /* * redirect: 'manual' — following redirects would let an allowlisted host bounce us to a blocked * one, which defeats the check above entirely. A provider that redirects its own well-known * document is misconfigured, and saying so is more useful than quietly following it. */ const res = await fetch(url, { signal: ctl.signal, redirect: 'manual' }); if (res.status >= 300 && res.status < 400) throw new Error(`${url} redirected; provider URLs must be final`); 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 { assertFetchable(doc.token_endpoint); const res = await fetch(doc.token_endpoint, { method: 'POST', headers, body, signal: ctl.signal, redirect: 'manual' }); 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(); } /** * The provider's published keys, straight from the document. Used by the configuration test so an * admin learns at setup time that a provider publishes no signing keys, rather than at first login. */ async function fetchJwks(jwksUri) { return getJson(jwksUri); } module.exports = { discover, assertFetchable, fetchJwks, verifyIdToken, exchangeCode, createPkce, randomToken, ALLOWED_ALGS, _resetCaches, };