/* * Signing in through an identity provider — OpenID Connect. * ──────────────────────────────────────────────────────── * * This is a second way to obtain the *same* token the password login hands out. * Nothing downstream changes: every route, and the WebSocket, still knows only * a signed JWT. That is the whole reason this fits in one file. * * Authorization Code with PKCE, and the code is exchanged **here**, never in * the browser: the client secret stays on the server, and the browser is only * ever handed a one-time ticket to swap for its token — a token in a redirect * URL ends up in the history, in the proxy's log, and in the referrer of the * next request. * * No library. Discovery is one document, the exchange is one form post, and * checking the ID token is a signature and five claims — Node imports a JWK * directly (`createPublicKey({ format: "jwk" })`), so this needs nothing that * is not already installed. A dependency here would be one more thing to keep * current in the part of the app that must not rot. * * What this file deliberately does *not* do: * • refresh tokens — the provider is asked once, at sign-in, and InkNote's own * token carries the session from then on. Nothing here needs the provider to * be reachable afterwards, which is what keeps the app working offline. * • decide who may in. That is an account question, and it is answered in * server.js where the users are. */ const crypto = require("crypto"); /* ─── Configuration ─── */ const trimSlash = (s) => String(s || "").replace(/\/+$/, ""); const ISSUER = trimSlash(process.env.INKNOTE_OIDC_ISSUER); const CLIENT_ID = process.env.INKNOTE_OIDC_CLIENT_ID || ""; const CLIENT_SECRET = process.env.INKNOTE_OIDC_CLIENT_SECRET || ""; const PUBLIC_URL = trimSlash(process.env.INKNOTE_PUBLIC_URL); const REDIRECT_URI = process.env.INKNOTE_OIDC_REDIRECT_URI || (PUBLIC_URL ? `${PUBLIC_URL}/api/auth/oidc/callback` : ""); const SCOPES = process.env.INKNOTE_OIDC_SCOPES || "openid profile email"; const LABEL = process.env.INKNOTE_OIDC_LABEL || "single sign-on"; /* Where the browser is sent back to with its ticket. The redirect URI names the * API; the app itself sits at the root of the same site. Never anything read * off the request: a redirect built from a parameter is an open redirect, and * this one is reached without being signed in. */ const APP_URL = PUBLIC_URL || trimSlash(REDIRECT_URI).replace(/\/api\/auth\/oidc\/callback$/, "") || ""; const enabled = Boolean(ISSUER && CLIENT_ID && CLIENT_SECRET && REDIRECT_URI); /** Says what is missing, once, at boot — half a configuration is a typo, not a choice. */ function describeConfiguration() { if (enabled) { // The client secret and the code both travel to the issuer. Over plain HTTP // they travel readably, which is worth saying out loud rather than assuming // somebody meant it. const insecure = ISSUER.startsWith("http://") ? "\n ⚠️ …over plain HTTP: the client secret and the code are readable in transit" : ""; return ` ✅ OpenID Connect enabled — ${ISSUER} (redirect ${REDIRECT_URI})${insecure}`; } const named = [ ["INKNOTE_OIDC_ISSUER", ISSUER], ["INKNOTE_OIDC_CLIENT_ID", CLIENT_ID], ["INKNOTE_OIDC_CLIENT_SECRET", CLIENT_SECRET], ["INKNOTE_PUBLIC_URL or INKNOTE_OIDC_REDIRECT_URI", REDIRECT_URI], ]; if (named.every(([, v]) => !v)) return null; // not configured at all: nothing to say const missing = named.filter(([, v]) => !v).map(([n]) => n); return ` ⚠️ OpenID Connect is half configured and stays off — missing: ${missing.join(", ")}`; } /* ─── Small helpers ─── */ const b64url = (buf) => Buffer.from(buf).toString("base64url"); const randomId = () => crypto.randomBytes(32).toString("base64url"); /** Fails loudly with a short slug: the browser is told the slug, the log the rest. */ function fail(code, detail) { const err = new Error(detail ? `${code}: ${detail}` : code); err.code = code; return err; } async function getJson(url, options = {}) { const res = await fetch(url, { ...options, signal: AbortSignal.timeout(8000) }); const text = await res.text(); let body = null; try { body = JSON.parse(text); } catch { /* left null — reported below with the status, which is the useful half */ } if (!res.ok || !body) throw fail("provider_unreachable", `${url} → ${res.status} ${text.slice(0, 200)}`); return body; } /* ─── Discovery and keys, both cached ─── * * Cached because they are asked for on every sign-in and change about once a * year. The key cache is also refreshed on demand: a provider may rotate its * keys at any moment, and the first token signed by the new one would otherwise * fail until the hour was up. That refresh is rate-limited, or an unknown `kid` * — which is also what a forged token looks like — would be a way to make this * server hammer the provider. */ const HOUR = 60 * 60 * 1000; let discovery = { at: 0, doc: null }; let jwks = { at: 0, keys: [], lastForcedRefresh: 0 }; async function metadata() { if (discovery.doc && Date.now() - discovery.at < HOUR) return discovery.doc; const doc = await getJson(`${ISSUER}/.well-known/openid-configuration`); for (const field of ["authorization_endpoint", "token_endpoint", "jwks_uri"]) { if (!doc[field]) throw fail("provider_incomplete", `discovery document has no ${field}`); } // The issuer identifies the provider and is checked against every token; a // document that names someone else is either misconfigured or not theirs. if (trimSlash(doc.issuer) !== ISSUER) { throw fail("provider_incomplete", `discovery says issuer ${doc.issuer}, configured ${ISSUER}`); } discovery = { at: Date.now(), doc }; return doc; } async function signingKeys({ force = false } = {}) { const fresh = Date.now() - jwks.at < HOUR; if (jwks.keys.length && fresh && !force) return jwks.keys; if (force && Date.now() - jwks.lastForcedRefresh < 60_000) return jwks.keys; if (force) jwks.lastForcedRefresh = Date.now(); const doc = await metadata(); const body = await getJson(doc.jwks_uri); const keys = Array.isArray(body.keys) ? body.keys : []; jwks = { ...jwks, at: Date.now(), keys }; return keys; } /* ─── Pending sign-ins and one-time tickets ─── * * Both live in memory on purpose. They exist for seconds, they are worthless * afterwards, and writing them to the database would mean a table to clean up * for something a restart is allowed to lose: a sign-in interrupted by a * restart simply starts again. */ const PENDING_TTL = 10 * 60 * 1000; const TICKET_TTL = 60 * 1000; const pending = new Map(); // state -> { verifier, nonce, at } const tickets = new Map(); // ticket -> { payload, at } setInterval(() => { const now = Date.now(); for (const [k, v] of pending) if (now - v.at > PENDING_TTL) pending.delete(k); for (const [k, v] of tickets) if (now - v.at > TICKET_TTL) tickets.delete(k); }, 60_000).unref(); /** * Where to send the browser, and what to remember while it is away. * * PKCE as well as the secret: the code comes back through the browser, and a * code that can be redeemed by whoever grabs it is worth as much as the token * it buys. `state` ties the return to this attempt, `nonce` ties the ID token to * it — different attacks, both cheap to prevent. */ async function beginLogin() { const doc = await metadata(); const state = randomId(); const nonce = randomId(); const verifier = randomId(); const challenge = b64url(crypto.createHash("sha256").update(verifier).digest()); pending.set(state, { verifier, nonce, at: Date.now() }); const url = new URL(doc.authorization_endpoint); for (const [k, v] of Object.entries({ response_type: "code", client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, scope: SCOPES, state, nonce, code_challenge: challenge, code_challenge_method: "S256", })) { url.searchParams.set(k, v); } return url.toString(); } /** The signature, then the claims. Everything here is a reason to refuse. */ async function verifyIdToken(idToken, nonce) { const parts = String(idToken).split("."); if (parts.length !== 3) throw fail("id_token_invalid", "not three parts"); const [headerB64, payloadB64, signatureB64] = parts; let header, claims; try { header = JSON.parse(Buffer.from(headerB64, "base64url").toString("utf8")); claims = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8")); } catch { throw fail("id_token_invalid", "header or payload is not JSON"); } const ALGORITHMS = { RS256: { hash: "sha256" }, RS384: { hash: "sha384" }, RS512: { hash: "sha512" }, PS256: { hash: "sha256", padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }, ES256: { hash: "sha256", dsaEncoding: "ieee-p1363" }, ES384: { hash: "sha384", dsaEncoding: "ieee-p1363" }, }; const alg = ALGORITHMS[header.alg]; // `none` is a valid JWT algorithm and an unforgeable way in if it is honoured. if (!alg) throw fail("id_token_invalid", `unsupported algorithm ${header.alg}`); const pick = (keys) => keys.find((k) => (header.kid ? k.kid === header.kid : true) && (!k.use || k.use === "sig")); const attempt = async (force) => { const jwk = pick(await signingKeys({ force })); if (!jwk) return `no signing key for kid ${header.kid}`; try { const key = crypto.createPublicKey({ key: jwk, format: "jwk" }); const ok = crypto.verify( alg.hash, Buffer.from(`${headerB64}.${payloadB64}`), { key, ...(alg.padding ? { padding: alg.padding, saltLength: alg.saltLength } : {}), ...(alg.dsaEncoding ? { dsaEncoding: alg.dsaEncoding } : {}), }, Buffer.from(signatureB64, "base64url") ); return ok ? null : "signature does not match"; } catch (e) { return `signature check failed: ${e.message}`; } }; /* Once with what we have, and if that fails once more with the key set read * fresh. A provider may add a key, or replace one without renaming it, and * either way the first token signed with it would otherwise be refused until * the cached hour was up. The re-read is rate-limited inside `signingKeys`, so * a stream of forged tokens cannot turn this into a way to hammer them. */ const problem = (await attempt(false)) && (await attempt(true)); if (problem) throw fail("id_token_invalid", problem); const now = Math.floor(Date.now() / 1000); const SKEW = 120; // clocks on two machines are never quite the same const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; if (trimSlash(claims.iss) !== ISSUER) throw fail("id_token_invalid", `issued by ${claims.iss}`); if (!audiences.includes(CLIENT_ID)) throw fail("id_token_invalid", "not addressed to this client"); if (audiences.length > 1 && claims.azp !== CLIENT_ID) throw fail("id_token_invalid", "azp is someone else"); if (!claims.exp || claims.exp + SKEW < now) throw fail("id_token_invalid", "expired"); if (claims.iat && claims.iat - SKEW > now) throw fail("id_token_invalid", "issued in the future"); // The nonce is what makes this token *this* sign-in rather than one replayed // from somewhere else. if (claims.nonce !== nonce) throw fail("id_token_invalid", "nonce does not match"); if (!claims.sub) throw fail("id_token_invalid", "no subject"); return claims; } /** * The return leg: state back to a pending attempt, code to tokens, token to * claims. Gives back only what an account is made of. */ async function completeLogin({ code, state }) { if (!code || !state) throw fail("callback_incomplete"); const attempt = pending.get(state); // One attempt, one use — a code replayed with the same state gets nowhere. pending.delete(state); if (!attempt) throw fail("state_unknown", "no pending sign-in for this state, or it expired"); const doc = await metadata(); const body = new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code_verifier: attempt.verifier, }); let tokens; try { tokens = await getJson(doc.token_endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, body: body.toString(), }); } catch (e) { throw fail("token_exchange_failed", e.message); } if (!tokens.id_token) throw fail("token_exchange_failed", "no id_token in the response"); const claims = await verifyIdToken(tokens.id_token, attempt.nonce); return { sub: String(claims.sub), username: claims.preferred_username || null, email: claims.email || null, displayName: claims.name || claims.given_name || null, }; } /* ─── The ticket the browser carries home ─── */ function issueTicket(payload) { const ticket = randomId(); tickets.set(ticket, { payload, at: Date.now() }); return ticket; } /** Single use: redeemed or gone, so a ticket left in a URL is worth nothing twice. */ function redeemTicket(ticket) { const entry = tickets.get(String(ticket ?? "")); if (!entry) return null; tickets.delete(String(ticket)); if (Date.now() - entry.at > TICKET_TTL) return null; return entry.payload; } module.exports = { enabled, label: LABEL, issuer: ISSUER, redirectUri: REDIRECT_URI, appUrl: APP_URL, describeConfiguration, beginLogin, completeLogin, issueTicket, redeemTicket, };