mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
The OAuth support that was here could not work and would not have been safe if it had. It could not work: the login page called google.accounts.oauth2 and new msal.PublicClientApplication, and NEITHER SDK WAS EVER LOADED by any page in this app — no script tag, no dynamic import, nothing. Both buttons threw ReferenceError on click. Even had they loaded, the CSP allows scripts only from 'self' and cloudflareinsights, and frames only from self and YouTube, so the libraries and their popups were blocked too. It would not have been safe: both endpoints authenticated with an ACCESS token and neither checked who it was issued for. POST /auth/google fell back to tokeninfo?access_token= and read the email out of the reply; POST /auth/microsoft handed the bearer token to Graph /me and trusted that. Graph and tokeninfo will both describe the user behind a token minted for SOMEBODY ELSE'S application, so any site a user signed into that requested `email` or `User.Read` could have replayed their token here and been issued a session as them. Both endpoints are deleted; nothing is lost, because nothing could reach them. Replaced by ONE generic flow — Authorization Code + PKCE (S256), run server-side, with the provider list resolved through a single function so per-organization SSO can extend it later without a second login path. Google and Microsoft become ordinary configured providers; Okta, Keycloak, Authentik, Auth0 and anything else that speaks OIDC now work with three env vars. Because the exchange happens server-side the browser never talks to the provider, so there is no SDK to load, no client id in the page, and no third-party origin needed in the CSP. Identity comes from an ID token that must survive: signature against the provider's JWKS (asymmetric algorithms only — alg:none and HMAC are refused outright, the latter because the only key we hold is public), `iss` exactly as discovered, `aud` and `azp` matching our client, `exp`, and a `nonce` this server minted for that login. State is compared in constant time against a value in an httpOnly SameSite=Lax cookie, so the callback is CSRF-protected and survives a restart mid-login. Account rules are the ones already in place: a verified email is required, an SSO login never takes over an account that has a password, and a changed `sub` for a known address is refused rather than handing the account to a recycled mailbox. 18 new tests, every one describing something the old code would have accepted: cross-audience tokens, azp mismatch, replayed nonces, alg:none, HMAC forgery, wrong signing key, expired tokens, a discovery document lying about its issuer, and a registry that never leaks a client id or secret to the browser. Verified end to end against Google's real discovery document: the redirect carries response_type=code, PKCE S256, state and nonce, and every callback guard rejects as intended (no cookie, wrong state, no code, provider refusal, unknown provider). ⚠️ TOTP is still not prompted on an SSO login, matching the documented behaviour of the previous SSO and API-token paths. That is a product decision and is left unchanged here rather than altered silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
225 lines
9.3 KiB
JavaScript
225 lines
9.3 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 }
|
|
|
|
/** fetch with a timeout, because a hanging IdP must not hang a login forever. */
|
|
async function getJson(url) {
|
|
const ctl = new AbortController();
|
|
const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
|
|
try {
|
|
const res = await fetch(url, { signal: ctl.signal, redirect: 'follow' });
|
|
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 {
|
|
const res = await fetch(doc.token_endpoint, { method: 'POST', headers, body, signal: ctl.signal });
|
|
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();
|
|
}
|
|
|
|
module.exports = {
|
|
discover,
|
|
verifyIdToken,
|
|
exchangeCode,
|
|
createPkce,
|
|
randomToken,
|
|
ALLOWED_ALGS,
|
|
_resetCaches,
|
|
};
|