OpenID Connect, off unless four settings are present. It is a second way to the same token the password login mints — every route and the WebSocket still know only a signed JWT — which is why it fits in one file and needs no new dependency: Node imports a JWK itself, so checking an ID token is a signature and five claims. Authorization Code with PKCE, exchanged on the server so the client secret stays there. The browser comes home with a one-time ticket rather than the token, because a token in a redirect URL sits in the history, in the proxy's log and in the referrer of the next request. Somebody the app has not seen gets an account; an account that already exists under that name is linked to the identity rather than duplicated, by subject from then on. Never an administrator. An account made this way has no password here, and the interface stops offering one — but an admin can still set one, which is the way back in if the provider is ever gone. The suite signs in at a provider of its own that signs real RS256 tokens, and holds down the four ways the journey must fail as well as the way it must work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
331 lines
13 KiB
JavaScript
331 lines
13 KiB
JavaScript
/*
|
|
* 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,
|
|
};
|