Ctrl K
ComunidadGuías…

src/oidc.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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;
    },
  };
}