Files
InkNote/backend/oidc.js
T
marcandClaude Opus 5 a2b3f91e00 The face from the provider, and whose business a password is
The password tab was hidden for accounts with no password, which is not the
same as accounts that sign in through the provider — an account linked to one
kept its password and therefore kept the tab. That is precisely the account
the person setting this up is using. It goes by "signs in over there" now; the
password it kept is the emergency door, set by an administrator.

And the picture. The provider sends one, the server fetches it at each
sign-in and stores it encrypted in a table beside the users rather than in
them, and serves it back through the API — so the page never talks to the
provider, no third-party cookie is needed for a face to appear, and it
survives the provider being down like everything else here. Fetched once per
account into an object URL, since an img tag cannot carry a bearer token.

The suite's provider now serves a picture too, and the check asks the browser
whether it actually decoded rather than whether the element exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:30:58 +02:00

364 lines
15 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,
picture: typeof claims.picture === "string" ? claims.picture : null,
};
}
/**
* The profile picture, fetched by the server rather than by the browser.
*
* Three reasons it happens here. The address is often behind the provider's own
* session, which this app's visitors may not have in a third-party context; the
* browser would have to talk to the provider on every page, which is a request
* to somewhere else on every load; and an image that lives here keeps working
* when the provider does not, like everything else in this app.
*
* Best effort in the strict sense: a slow, missing, oversized or non-image
* answer gives null and the sign-in carries on. A picture is never a reason to
* fail to let somebody in.
*/
async function fetchPicture(url, { maxBytes = 512 * 1024 } = {}) {
if (!url || !/^https?:\/\//i.test(url)) return null;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(4000), redirect: "follow" });
if (!res.ok) return null;
const type = String(res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
if (!/^image\/(png|jpeg|webp|gif)$/.test(type)) return null;
const declared = Number(res.headers.get("content-length"));
if (Number.isFinite(declared) && declared > maxBytes) return null;
const bytes = Buffer.from(await res.arrayBuffer());
// The header may have lied or been absent; the bytes are the truth.
if (!bytes.length || bytes.length > maxBytes) return null;
return { bytes, type };
} catch {
return 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,
fetchPicture,
issueTicket,
redeemTicket,
};