src/oidc.js
Rama main · réplica de lectura del repositorio original.
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;
},
};
}