screentinker/server/lib/oidc.js
ScreenTinker d26aaebef6 SSO: fix an account takeover, a remote crash, and login CSRF found in review
Five reviewers went at the two SSO commits. Three of them independently
demonstrated a full account takeover, and it was the same defect each time.

TAKEOVER. An org admin supplies the issuer and client_id, so they control that
identity provider completely and can mint an id_token asserting ANY email with
email_verified:true — including a platform_admin's. Every cryptographic check
passed honestly, because the attacker IS the issuer. upsertFederatedUser then
re-pointed the existing account at whichever provider spoke last, because the
only guard was `password_hash IS NULL` — and every SSO-created account has a
null password. Sessions were issued as the victim, and the victim's own login
then failed forever with subject_mismatch.

The rule came from the old Google handler, where it was safe: only the operator
could add a provider. Making providers customer-configurable turned it into a
takeover primitive and the assumption was not re-examined. Now an org provider
may only assert emails inside the domains it registered, and may never adopt an
account another provider established.

REMOTE CRASH, unauthenticated. The state comparison guarded on UTF-16 character
length while Buffer.from produces UTF-8 bytes, so a state of 43 characters
containing one multi-byte character reached timingSafeEqual with mismatched
buffers and threw — inside an async handler, which Express does not catch, which
server.js turns into process.exit. One request per restart killed any instance
with SSO enabled. Compared as bytes now, and /api/auth/oidc gained a rate limit.

LOGIN CSRF. The callback returned the session token in the URL fragment, so a
crafted link installed an ATTACKER'S token and silently signed the victim into
their account. The token now goes in a one-shot httpOnly cookie exchanged at
POST /sso/claim, which a link cannot forge.

FRONTEND, dead on arrival twice over. login.js used `await` in a non-async
function — a SyntaxError that takes the WHOLE app down, since app.js imports it
statically and there is no bundler. And `esc` was never imported, so the org-SSO
button could never render; the ReferenceError was swallowed by the catch written
for network failures. Both slipped through because `node --check` parses these
files as CommonJS and exits 0 on a broken module. The correct check is
`node --input-type=module --check`, and all four frontend files now pass it.

PUBLIC EMAIL DOMAINS cannot be claimed. A tenant had claimed gmail.com in
review, after which every Gmail user typing their address was offered "sign in
with your organization" pointing at that tenant's infrastructure — phishing from
this product's own login page. server/lib/public-email-domains.js.

MICROSOFT multi-tenant is refused rather than silently broken. `common` metadata
advertises the literal template {tenantid}, so the issuer never matches and
every login already failed; and loosening that check is nOAuth. A tenant GUID is
now required, with a loud warning at boot.

SSRF: https only, loopback/RFC1918/link-local refused, redirects not followed,
and the test endpoint no longer echoes upstream status for a caller-supplied
jwks_uri (it was a readable internal port scanner).

Also: an omitted email_verified was accepted (the comment already said it should
not be); the domain-uniqueness check raced an 8s network call before its insert
and is now inside the transaction; same-org duplicate domains were allowed and
made routing depend on table-scan order; routing is now ordered; a client secret
that cannot be decrypted fails closed instead of silently downgrading to a public
client; SSO audit rows were writing the org id into the deviceId column; and
/sso/start was capped at 10/min per IP, which would 429 the 11th employee behind
a corporate NAT.

Adds per-provider editing in the org admin UI (replace-only secrets — never
returned, blank means keep, explicit clear) and a Test button that checks
discovery, endpoints and signing keys while stating plainly that it cannot
verify the client ID, the secret, or the redirect URI registration.

⚠️ STILL MISSING: domain-ownership verification. A claimed domain means "nobody
else had claimed it", not "they own it". DNS TXT proof is the remaining control.

1582 tests pass. New regression tests cover the takeover confinement, ordering,
fail-closed secrets, the Microsoft refusal and the public-domain blocklist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 18:12:07 -05:00

268 lines
12 KiB
JavaScript

'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 }
/*
* 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, link-local (169.254.169.254 is cloud metadata) and the
* IPv6 equivalents are refused outright.
*
* ⚠️ 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",
* and the README says so rather than implying more.
*/
const BLOCKED_HOST = /^(localhost|.*\.localhost|127\.|0\.0\.0\.0|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|\[?::1\]?|\[?f[cd])/i;
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');
if (BLOCKED_HOST.test(u.hostname)) 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,
fetchJwks,
verifyIdToken,
exchangeCode,
createPkce,
randomToken,
ALLOWED_ALGS,
_resetCaches,
};