import { createHash, randomBytes, webcrypto } from 'node:crypto'; import { WeaveError } from './store.js'; const { subtle } = webcrypto; const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]']); const SKEW_S = 60; const DISCOVERY_TTL_MS = 60 * 60 * 1000; const TOKEN_TTL_MS = 60 * 1000; const TOKEN_CACHE_MAX = 1000; export const CLIENT_FAULTS = new Set(['invalid_client', 'invalid_scope', 'unauthorized_client']); const ALGS = { RS256: { import: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, verify: { name: 'RSASSA-PKCS1-v1_5' } }, ES256: { import: { name: 'ECDSA', namedCurve: 'P-256' }, verify: { name: 'ECDSA', hash: 'SHA-256' } }, }; export function oidcFromEnv(env = process.env) { const raw = env.WEAVE_OIDC_ISSUER?.trim(); const clientId = env.WEAVE_OIDC_CLIENT_ID?.trim(); if (!raw && !clientId) return null; if (!raw) throw new WeaveError('WEAVE_OIDC_ISSUER is required when WEAVE_OIDC_CLIENT_ID is set: the provider\'s issuer URL, e.g. https://clerk.example.com', 'invalid'); if (!clientId) throw new WeaveError('WEAVE_OIDC_CLIENT_ID is required when WEAVE_OIDC_ISSUER is set', 'invalid'); let url; try { url = new URL(raw); } catch { throw new WeaveError(`WEAVE_OIDC_ISSUER must be an absolute URL like https://clerk.example.com (got '${raw}')`, 'invalid'); } if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOOPBACK.has(url.hostname))) { throw new WeaveError(`WEAVE_OIDC_ISSUER must be https (got '${raw}')`, 'invalid'); } return { issuer: raw.replace(/\/+$/, ''), clientId, clientSecret: env.WEAVE_OIDC_CLIENT_SECRET?.trim() || null, name: env.WEAVE_OIDC_NAME?.trim() || url.hostname }; } const refuse = (message) => new WeaveError(message, 'unauthorized'); const decodePart = (part) => JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); export function createOidc({ issuer, clientId, clientSecret = null, name = null, fetch = globalThis.fetch } = {}) { if (!issuer || !clientId) throw new WeaveError('An OIDC provider needs an issuer and a client id', 'invalid'); let discovered = null; let keys = null; const vouched = new Map(); const getJson = async (url, init) => { const res = await fetch(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(10_000) }); const body = await res.json().catch(() => null); if (!res.ok || !body) throw Object.assign(refuse(`The identity provider answered ${res.status} at ${new URL(url).pathname}${body?.error ? `: ${body.error}` : ''}`), typeof body?.error === 'string' ? { provider: body.error } : {}); return body; }; const discover = async () => { if (discovered && Date.now() - discovered.at < DISCOVERY_TTL_MS) return discovered.doc; const doc = await getJson(`${issuer}/.well-known/openid-configuration`); if (doc.issuer !== issuer) throw refuse(`The identity provider names itself ${doc.issuer}, not ${issuer} (check WEAVE_OIDC_ISSUER)`); for (const k of ['authorization_endpoint', 'token_endpoint', 'jwks_uri']) if (!doc[k]) throw refuse(`The identity provider's discovery document has no ${k}`); discovered = { at: Date.now(), doc }; return doc; }; const keyFor = async (kid, doc) => { const find = () => (keys ?? []).find((k) => (kid ? k.kid === kid : true) && (k.use ?? 'sig') === 'sig'); if (!find()) keys = (await getJson(doc.jwks_uri)).keys ?? []; return find() ?? null; }; async function verifyIdToken(token, { nonce, doc }) { const parts = String(token ?? '').split('.'); if (parts.length !== 3) throw refuse('The identity provider sent no id token'); let head, claims; try { head = decodePart(parts[0]); claims = decodePart(parts[1]); } catch { throw refuse('The id token is not a JWT'); } const alg = ALGS[head.alg]; if (!alg) throw refuse(`The id token is signed with ${head.alg}; RS256 and ES256 are accepted`); const jwk = await keyFor(head.kid, doc); if (!jwk) throw refuse('The id token is signed with a key the identity provider does not publish'); const { kid, use, alg: _a, key_ops, ...material } = jwk; const key = await subtle.importKey('jwk', material, alg.import, false, ['verify']); const ok = await subtle.verify(alg.verify, key, Buffer.from(parts[2], 'base64url'), Buffer.from(`${parts[0]}.${parts[1]}`)); if (!ok) throw refuse('The id token\'s signature does not verify'); const now = Math.floor(Date.now() / 1000); if (claims.iss !== issuer) throw refuse('The id token was issued by someone else'); if (![].concat(claims.aud).includes(clientId)) throw refuse('The id token was issued to another client'); if (Array.isArray(claims.aud) && claims.aud.length > 1 && claims.azp !== clientId) throw refuse('The id token was issued to another client'); if (!(Number(claims.exp) + SKEW_S > now)) throw refuse('The id token has expired'); if (Number(claims.iat) - SKEW_S > now) throw refuse('The id token is dated in the future'); if (!claims.nonce || claims.nonce !== nonce) throw refuse('The id token does not answer this sign-in'); if (!claims.sub) throw refuse('The id token names nobody'); return claims; } const tokenRequest = (doc, form) => { const headers = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }; const methods = doc.token_endpoint_auth_methods_supported ?? ['client_secret_basic']; if (!clientSecret) form.set('client_id', clientId); else if (methods.includes('client_secret_basic')) headers.Authorization = `Basic ${Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`).toString('base64')}`; else { form.set('client_id', clientId); form.set('client_secret', clientSecret); } return { headers, body: form.toString() }; }; return { issuer, name: name || new URL(issuer).hostname, async probe({ redirectUri = null } = {}) { const checkedAt = new Date().toISOString(); let res, body; try { const doc = await discover(); const form = new URLSearchParams({ grant_type: 'authorization_code', code: `weave-probe-${randomBytes(18).toString('base64url')}`, code_verifier: randomBytes(48).toString('base64url') }); if (redirectUri) form.set('redirect_uri', redirectUri); res = await fetch(doc.token_endpoint, { method: 'POST', ...tokenRequest(doc, form), redirect: 'error', signal: AbortSignal.timeout(10_000) }); body = await res.json().catch(() => null); } catch (err) { return { ok: false, checkedAt, code: err instanceof WeaveError ? 'discovery_failed' : 'unreachable', detail: err.message }; } const code = typeof body?.error === 'string' ? body.error.slice(0, 64) : null; const status = res.status; if (CLIENT_FAULTS.has(code) || status === 401) return { ok: false, checkedAt, status, code: code ?? 'invalid_client' }; if (status >= 500) return { ok: false, checkedAt, status, code: code ?? 'server_error' }; return { ok: true, checkedAt, status, ...(code ? { code } : {}) }; }, async begin({ redirectUri, fresh = false }) { const doc = await discover(); const state = randomBytes(32).toString('base64url'); const nonce = randomBytes(32).toString('base64url'); const verifier = randomBytes(48).toString('base64url'); const url = new URL(doc.authorization_endpoint); for (const [k, v] of Object.entries({ response_type: 'code', client_id: clientId, redirect_uri: redirectUri, scope: 'openid email profile', state, nonce, code_challenge: createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256', })) url.searchParams.set(k, v); if (fresh) url.searchParams.set('prompt', doc.prompt_values_supported?.includes('select_account') ? 'login select_account' : 'login'); return { url: url.href, state, nonce, verifier }; }, async endSessionUrl({ postLogoutRedirectUri }) { const doc = await discover(); if (!doc.end_session_endpoint) return null; const url = new URL(doc.end_session_endpoint); url.searchParams.set('client_id', clientId); url.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri); return url.href; }, async redeem({ code, redirectUri, verifier, nonce }) { const doc = await discover(); const form = new URLSearchParams({ grant_type: 'authorization_code', code: String(code ?? ''), redirect_uri: redirectUri, code_verifier: verifier }); const tokens = await getJson(doc.token_endpoint, { method: 'POST', ...tokenRequest(doc, form) }); const claims = await verifyIdToken(tokens.id_token, { nonce, doc }); const email = claims.email_verified === true && typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : ''; const name = typeof claims.name === 'string' ? claims.name.trim().replace(/\s+/g, ' ').slice(0, 80) : ''; return { issuer, subject: String(claims.sub), ...(email ? { email } : {}), ...(name ? { name } : {}) }; }, async identify(accessToken) { const key = createHash('sha256').update(String(accessToken)).digest('hex'); const hit = vouched.get(key); if (hit && Date.now() - hit.at < TOKEN_TTL_MS) { if (hit.refused) throw refuse(hit.refused); return hit.who; } if (vouched.size >= TOKEN_CACHE_MAX) vouched.clear(); const doc = await discover().catch((err) => { throw new Error(err.message); }); if (!doc.userinfo_endpoint) throw new Error('The identity provider\'s discovery document has no userinfo_endpoint'); const res = await fetch(doc.userinfo_endpoint, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(10_000) }); if (res.status === 401 || res.status === 403) { const refused = 'The identity provider does not accept this token (expired, revoked or not its own)'; vouched.set(key, { at: Date.now(), refused }); throw refuse(refused); } const info = await res.json().catch(() => null); if (!res.ok || !info?.sub) throw new Error(`The identity provider answered ${res.status} at ${new URL(doc.userinfo_endpoint).pathname}`); let client = null; try { const c = decodePart(String(accessToken).split('.')[1]); client = c.client_id ?? c.azp ?? null; } catch {} const who = { issuer, subject: String(info.sub), client: /^[\w.:@-]{1,64}$/.test(String(client ?? '')) ? String(client) : null }; vouched.set(key, { at: Date.now(), who }); return who; }, }; }