Ctrl K
ComunidadGuías…

src/server/auth.ts

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

Archivo original ↗Preguntar a KiBot
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { randomBytes, timingSafeEqual, createHmac, scryptSync } from 'node:crypto';
import { join } from 'node:path';
import type { Request, Response, NextFunction } from 'express';

// JwtHelper is not exported from the main edge.libx.js entry, import from build directly
import { JwtHelper } from 'edge.libx.js/build/helpers/jwt.js';

// Cache Google JWKS keys — avoids ~300ms fetch per token verification
const _jwksCache = { keys: new Map<string, any>(), expiresAt: 0 };
const _jwksInflight = new Map<string, Promise<any>>();
const JWKS_TTL = 3600_000;
// getGooglePublicKey is declared private on JwtHelper; we monkey-patch it (JS allows this
// at runtime). Bracket access asserts the type without altering behavior.
const _origGetKey = (JwtHelper as any)['getGooglePublicKey'].bind(JwtHelper);
(JwtHelper as any)['getGooglePublicKey'] = async function (kid: string) {
  if (_jwksCache.keys.has(kid) && Date.now() < _jwksCache.expiresAt) return _jwksCache.keys.get(kid);
  if (_jwksInflight.has(kid)) return _jwksInflight.get(kid);
  const p = _origGetKey(kid).then((key: any) => {
    if (key) { _jwksCache.keys.set(kid, key); _jwksCache.expiresAt = Date.now() + JWKS_TTL; }
    _jwksInflight.delete(kid);
    return key;
  }, (err: any) => {
    _jwksInflight.delete(kid);
    throw err;
  });
  _jwksInflight.set(kid, p);
  return p;
};
import { dataPath } from './paths.ts';
import { apiKeyPrincipal, validateApiKey } from './api-keys.ts';
import { isOwnerEmail } from './owners.ts';
import { fromAuthUser, fromInternal, type Principal } from './security/principal.ts';
import { loginAllowed, security } from './security/runtime.ts';
import { firebaseIssuedAt, tokenRevoked } from './security/revocation.ts';

// Issued-at (`iat`) + revocation: every token format below carries `iat` for NEW tokens, in a shape the pre-iat
// verifier REJECTS (never mis-parses into a different identity) — so for these tokens a rollback only forces re-auth.
// API keys are NOT covered: pre-hashing code breaks on hashed entries, so a rollback invalidates every key (see the
// Rollback note in api-keys.ts). Old tokens
// without iat still verify here, with an implied iat (see security/revocation.ts). Checked against
// policy.tokensValidAfter for the token's principal: an in-memory lookup, no I/O.
const nowSec = () => Math.floor(Date.now() / 1000);
const hmacEq = (a: string, b: string) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
const userPrincipalId = (uid: string, email: string) => fromAuthUser({ uid, email }).id;

/** Fixed TTLs (seconds). Legacy tokens without iat are treated as issued at `exp - TTL`. */
export const MCP_TOKEN_TTL = { access: 3600, refresh: 60 * 60 * 24 * 30 } as const;
export const LOCAL_TOKEN_TTL = 30 * 24 * 3600;

/** Server secret for signing scoped internal tokens — stable per startup. */
const INTERNAL_SECRET = process.env.INTERNAL_API_TOKEN || randomBytes(32).toString('hex');
process.env.INTERNAL_API_TOKEN = INTERNAL_SECRET;
/** Publish this process's secret to `<app>/.tmp/.internal-token`. SERVER ONLY — called from boot.
 *  It used to run at import time, so any process importing auth.ts (e.g. the `shraga para post` CLI) overwrote
 *  the live server's token with its own throwaway secret, and every token signed from the file then 401'd
 *  (feedox 2026-09-23). */
export function publishInternalToken(): void {
  const tmpDir = join(dataPath('..'), '.tmp');
  try { mkdirSync(tmpDir, { recursive: true }); writeFileSync(join(tmpDir, '.internal-token'), INTERNAL_SECRET); } catch (e: any) { console.error('[auth] failed to write .internal-token:', e.message); }
}

/** Sign a scoped internal token embedding user identity. Agent subprocess uses this as INTERNAL_API_TOKEN — single env var carries both auth + identity.
 *  Format `<sig64>.<iat>:<uid>:<email>`, sig = HMAC("v2:<iat>:<uid>:<email>"). (Legacy: `<sig64>:<uid>:<email>`, no iat.) */
export function signInternalToken(uid: string, email: string): string {
  const rest = `${nowSec()}:${uid}:${email}`;
  const sig = createHmac('sha256', INTERNAL_SECRET).update(`v2:${rest}`).digest('hex');
  return `${sig}.${rest}`;
}

/** Verify and extract user identity from a scoped internal token. Falls back to legacy global token check.
 *  Revocation: rejected if issued before tokensValidAfter of `internal:<uid>` or of the user it acts for. */
function verifyInternalToken(token: string): { uid: string; email: string } | null {
  const scoped = (uid: string, email: string, iat: number) =>
    tokenRevoked([fromInternal({ uid }).id, userPrincipalId(uid, email)], iat) ? null : { uid, email };
  if (token[64] === '.') {
    const rest = token.slice(65);
    if (!hmacEq(token.slice(0, 64), createHmac('sha256', INTERNAL_SECRET).update(`v2:${rest}`).digest('hex'))) return null;
    const [iatStr, uid, ...emailParts] = rest.split(':');
    const iat = Number(iatStr);
    if (!Number.isInteger(iat) || !uid || !emailParts.length) return null;
    return scoped(uid, emailParts.join(':'), iat);
  }
  const firstColon = token.indexOf(':');
  if (firstColon === 64) {
    const sig = token.slice(0, 64);
    const payload = token.slice(65);
    const expected = createHmac('sha256', INTERNAL_SECRET).update(payload).digest('hex');
    if (hmacEq(sig, expected)) {
      const sepIdx = payload.indexOf(':');
      // Legacy scoped token: no exp, no iat ⇒ issued at 0, so any revocation of the principal kills it.
      if (sepIdx > 0) return scoped(payload.slice(0, sepIdx), payload.slice(sepIdx + 1), 0);
    }
    return null;
  }
  // Legacy: plain global token (backwards compat for old agents / direct curl)
  if (token.length === INTERNAL_SECRET.length && timingSafeEqual(Buffer.from(token), Buffer.from(INTERNAL_SECRET))) {
    return { uid: 'agent-internal', email: 'agent@internal' };
  }
  return null;
}

/**
 * Persisted secret for signing MCP OAuth tokens. Unlike INTERNAL_SECRET (random per boot
 * unless INTERNAL_API_TOKEN is set), this is stored in data/ so issued access/refresh tokens
 * survive restarts/deploys — otherwise every restart would force claude.ai users to re-auth.
 */
let mcpSecret: string | null = null;
function getMcpSecret(): string {
  if (mcpSecret) return mcpSecret;
  const p = dataPath('.mcp-oauth-secret');
  try {
    if (existsSync(p)) {
      const s = readFileSync(p, 'utf8').trim();
      if (s) return (mcpSecret = s);
    }
    mcpSecret = randomBytes(32).toString('hex');
    writeFileSync(p, mcpSecret, { mode: 0o600 });
  } catch (e: any) {
    console.error('[auth] mcp-oauth secret persist failed, falling back to INTERNAL_SECRET:', e.message);
    mcpSecret = INTERNAL_SECRET;
  }
  return mcpSecret;
}

/**
 * Stateless OAuth access/refresh tokens for the MCP endpoint, signed with a persisted secret.
 * Provider-agnostic: identity is established upstream by requireAuth (Firebase today,
 * email-password later) — this only carries the resulting {uid, email}. No storage needed.
 */
/** Payload `mcp2:kind:uid:email:iat:exp`. (Legacy `mcp:kind:uid:email:exp` — the pre-iat verifier rejects `mcp2`.) */
export function signMcpToken(uid: string, email: string, kind: 'access' | 'refresh' = 'access', ttlSec: number = MCP_TOKEN_TTL[kind]): string {
  const iat = nowSec();
  const payload = `mcp2:${kind}:${uid}:${email}:${iat}:${iat + ttlSec}`;
  const sig = createHmac('sha256', getMcpSecret()).update(payload).digest('hex');
  return `mcp_${sig}.${Buffer.from(payload).toString('base64url')}`;
}

export function verifyMcpToken(token: string): { uid: string; email: string; kind: 'access' | 'refresh' } | null {
  if (!token.startsWith('mcp_')) return null;
  const body = token.slice(4);
  const dot = body.indexOf('.');
  if (dot < 0) return null;
  const sig = body.slice(0, dot);
  let payload: string;
  try { payload = Buffer.from(body.slice(dot + 1), 'base64url').toString(); } catch { return null; }
  const expected = createHmac('sha256', getMcpSecret()).update(payload).digest('hex');
  if (!hmacEq(sig, expected)) return null;
  const parts = payload.split(':'); // tag:kind:uid:email[:iat]:exp  (uid never contains ':')
  const v2 = parts[0] === 'mcp2';
  if (!(v2 || parts[0] === 'mcp') || parts.length < (v2 ? 6 : 5)) return null;
  const kind = parts[1];
  if (kind !== 'access' && kind !== 'refresh') return null;
  const exp = Number(parts[parts.length - 1]);
  if (!exp || nowSec() > exp) return null;
  const iat = v2 ? Number(parts[parts.length - 2]) : exp - MCP_TOKEN_TTL[kind];
  if (!Number.isInteger(iat)) return null;
  const uid = parts[2], email = parts.slice(3, parts.length - (v2 ? 2 : 1)).join(':');
  if (tokenRevoked(userPrincipalId(uid, email), iat)) return null;
  return { kind, uid, email };
}

export interface AuthUser {
  uid: string;
  email: string;
  /** Owners can see all sessions/schedules across users (view-only bypass — mutations still restricted to owner of record). */
  isOwner: boolean;
  /** Who is calling, per auth branch (login → user, api key → apikey, internal token → internal). */
  principal: Principal;
}

// Owner comes from an interactive login, a server-minted scoped internal token acting for one, or an UNCAPPED API key
// (a delegated login credential — only an interactive login can mint a key, e.g. `shraga term`'s /cli-auth consent).
// A role-capped key is never owner. OWNERS is read per call, so removing the creator takes effect on the next request.
const ownerCapable = (p: Principal) => p.kind === 'user' || p.kind === 'internal' || (p.kind === 'apikey' && typeof p.attrs.role !== 'string');
const authUser = (uid: string, email: string, principal: Principal): AuthUser => ({
  uid, email, principal, isOwner: ownerCapable(principal) && isOwnerEmail(email),
});
const internalUser = (t: { uid: string; email: string }) => authUser(t.uid, t.email, fromInternal(t));
const apiKeyUser = (k: { id: string; uid: string; email: string; role?: string }) => authUser(k.uid, k.email, apiKeyPrincipal(k));

// ── Attributing a rejection ──────────────────────────────────────────────────
// A token can be CRYPTOGRAPHICALLY valid and still be refused (not in the policy bindings, revoked). The identity is
// known at that moment but was thrown away with the error, so `auth.deny` landed in the audit with no principal and
// Owner Console → Principals never showed the person who was turned away. Carry it on the error instead.
const DENIED = Symbol.for('shraga.deniedPrincipal');
/** Tag an auth error with the identity it rejected. */
export function deniedPrincipal<E extends Error>(err: E, principal: Principal): E {
  (err as any)[DENIED] = principal;
  return err;
}
/** The identity an auth error rejected, if it carries one. */
export function deniedPrincipalOf(err: unknown): Principal | undefined {
  const p = (err as any)?.[DENIED];
  return p && typeof p === 'object' && typeof p.id === 'string' ? (p as Principal) : undefined;
}

export async function verifyToken(token: string): Promise<AuthUser> {
  const projectId = JSON.parse(process.env.FIREBASE_CONFIG_PROD ?? process.env.VITE_FIREBASE_CONFIG_PROD ?? '{}').projectId;
  if (!projectId) throw new Error('FIREBASE_CONFIG_PROD not set or missing projectId');
  let payload: any;
  try {
    payload = await JwtHelper.verifyFirebaseToken(token, projectId);
  } catch (err: any) {
    const msg = typeof err === 'string' ? err : err?.message ?? String(err);
    if (msg.includes('aud')) throw new Error(`Token audience mismatch (expected ${projectId}). Please sign out and sign in again.`);
    throw new Error(msg);
  }
  const uid = payload.user_id || payload.sub;
  const principal = fromAuthUser({ uid, email: payload.email });
  // Login gate = policy bindings (member rank+) or OWNERS; fails closed. The message keeps the word 'whitelist':
  // the client (App.tsx, useAgentSocket, lib/ws) keys its permanent "not allowed" handling on it.
  // The rejected identity rides on the error so the caller can audit WHO was denied (auth.deny is otherwise anonymous).
  if (!loginAllowed(principal)) throw deniedPrincipal(new Error('User not in whitelist — ask an owner to add a binding (Owner Console → Bindings)'), principal);
  // auth_time = sign-in time; the hourly-refreshed iat would outlive a revocation.
  if (tokenRevoked(principal.id, firebaseIssuedAt(payload))) throw deniedPrincipal(new Error('Token revoked — sign in again'), principal);
  return authUser(uid, payload.email, principal);
}

// ── Pluggable auth provider ──────────────────────────────────────────────────
// Default = 'local' (username/password, zero external deps — a dev can install and run
// with no Firebase). Set AUTH_PROVIDER=firebase (an optional add-on) to verify Firebase
// ID tokens instead. The provider only turns a bearer token into an AuthUser; api-key and
// internal-token auth are provider-agnostic.
export const AUTH_PROVIDER = (process.env.AUTH_PROVIDER || 'local').toLowerCase();

interface LocalUser { email: string; salt: string; hash: string }
const USERS_PATH = () => dataPath('users.json');
function loadLocalUsers(): LocalUser[] {
  try { return existsSync(USERS_PATH()) ? JSON.parse(readFileSync(USERS_PATH(), 'utf-8')) : []; }
  catch (e: any) { console.error('[auth] users.json unreadable:', e.message); return []; }
}
function hashPw(password: string, salt = randomBytes(16).toString('hex')) {
  return { salt, hash: scryptSync(password, salt, 64).toString('hex') };
}
/** Create a local user (used by `shraga user add` and first-run /api/auth/register). */
export function addLocalUser(email: string, password: string): void {
  const users = loadLocalUsers();
  if (users.some((u) => u.email === email)) throw new Error(`user ${email} already exists`);
  users.push({ email, ...hashPw(password) });
  writeFileSync(USERS_PATH(), JSON.stringify(users, null, 2));
}
export function localUserCount(): number { return loadLocalUsers().length; }

// Persisted secret so local session tokens survive restarts.
let _localSecret: string | null = null;
function localSecret(): string {
  if (_localSecret) return _localSecret;
  const p = dataPath('.local-auth-secret');
  try {
    if (existsSync(p)) return (_localSecret = readFileSync(p, 'utf8').trim());
    _localSecret = randomBytes(32).toString('hex');
    writeFileSync(p, _localSecret, { mode: 0o600 });
  } catch { _localSecret = INTERNAL_SECRET; }
  return _localSecret;
}
/** Issue a signed local session token: sha_<sig>.<base64url(email:iat:exp)>, sig = HMAC("v2:" + payload).
 *  (Legacy: payload `email:exp`, sig over the payload alone — the "v2:" domain separation makes the pre-iat verifier
 *  reject new tokens instead of reading `email:iat` as the email.) */
export function localLogin(email: string, password: string): string | null {
  const rec = loadLocalUsers().find((u) => u.email === email);
  if (!rec) return null;
  const { hash } = hashPw(password, rec.salt);
  if (hash.length !== rec.hash.length || !timingSafeEqual(Buffer.from(hash), Buffer.from(rec.hash))) return null;
  const iat = nowSec();
  const payload = `${email}:${iat}:${iat + LOCAL_TOKEN_TTL}`;
  const sig = createHmac('sha256', localSecret()).update(`v2:${payload}`).digest('hex');
  return `sha_${sig}.${Buffer.from(payload).toString('base64url')}`;
}
function verifyLocalToken(token: string): AuthUser {
  if (!token.startsWith('sha_')) throw new Error('Invalid session token');
  const body = token.slice(4);
  const dot = body.indexOf('.');
  if (dot < 0) throw new Error('Malformed token');
  const sig = body.slice(0, dot);
  const payload = Buffer.from(body.slice(dot + 1), 'base64url').toString();
  const v2 = hmacEq(sig, createHmac('sha256', localSecret()).update(`v2:${payload}`).digest('hex'));
  if (!v2 && !hmacEq(sig, createHmac('sha256', localSecret()).update(payload).digest('hex'))) throw new Error('Bad token signature');
  const sep = payload.lastIndexOf(':');
  const exp = Number(payload.slice(sep + 1));
  let head = payload.slice(0, sep), iat = exp - LOCAL_TOKEN_TTL;
  if (v2) { const s = head.lastIndexOf(':'); iat = Number(head.slice(s + 1)); head = head.slice(0, s); }
  const email = head;
  if (!Number.isFinite(exp) || nowSec() > exp) throw new Error('Token expired — sign in again');
  const principal = fromAuthUser({ uid: email, email });
  if (!Number.isInteger(iat)) throw new Error('Malformed token');
  if (tokenRevoked(principal.id, iat)) throw deniedPrincipal(new Error('Token revoked — sign in again'), principal);
  return authUser(email, email, principal);
}

/** Dispatch bearer verification to the active provider. */
export async function verifyBearer(token: string): Promise<AuthUser> {
  return AUTH_PROVIDER === 'firebase' ? verifyToken(token) : verifyLocalToken(token);
}

/** Verify a raw token (api-key `uck_…`, scoped internal token, or provider bearer) → AuthUser, or null.
 * The token-only half of `requireAuth`, for callers with no Express req/res — notably the WebSocket
 * upgrade path, where the token arrives in the handshake instead of an Authorization header. */
export async function authenticateToken(token: string | undefined | null): Promise<AuthUser | null> {
  if (!token) return null;
  if (token.startsWith('uck_')) {
    const identity = validateApiKey(token);
    return identity ? apiKeyUser(identity) : null;
  }
  const internal = verifyInternalToken(token);
  if (internal) return internalUser(internal);
  try { return await verifyBearer(token); } catch { return null; }
}

export async function requireAuth(req: Request, res: Response, next: NextFunction) {
  const internalToken = req.headers['x-internal-token'] as string | undefined;
  if (internalToken) {
    const identity = verifyInternalToken(internalToken);
    if (identity) {
      const user = internalUser(identity);
      (req as any).user = user;
      security()?.authAllow(user.principal, 'http:internal');
      return next();
    }
  }

  const token = req.headers.authorization?.replace('Bearer ', '') || (req.query.token as string);
  if (!token) {
    security()?.authDeny('http', 'missing-token', req.ip);
    return void res.status(401).json({ error: 'Missing token' });
  }

  // API key auth (uck_…)
  if (token.startsWith('uck_')) {
    const identity = validateApiKey(token);
    if (!identity) {
      security()?.authDeny('http:apikey', 'invalid-api-key', req.ip);
      return void res.status(401).json({ error: 'Invalid API key' });
    }
    const user = apiKeyUser(identity);
    (req as any).user = user;
    security()?.authAllow(user.principal, 'http:apikey');
    return next();
  }

  let user: AuthUser;
  try {
    user = await verifyBearer(token);
  } catch (err: any) {
    const whitelisted = err?.message?.includes('whitelist');
    const reason = whitelisted ? 'not-whitelisted' : err?.message?.includes('revoked') ? 'token-revoked' : 'invalid-bearer';
    security()?.authDeny('http:bearer', reason, req.ip, deniedPrincipalOf(err));
    return void res.status(whitelisted ? 403 : 401).json({ error: err.message });
  }
  (req as any).user = user;
  security()?.authAllow(user.principal, 'http:bearer');
  next();
}