← back to Inline Editor

src/auth.js

90 lines

/**
 * Auth gate for inline-editor save/upload/revert endpoints.
 *
 * Verifies the `dw_auth` cookie issued by auth.agentabrams.com.
 *
 * For v1 we support two modes:
 *   - PROD: JWT verified against AUTH_JWKS_URL (issuer auth.agentabrams.com).
 *   - DEV:  symmetric HS256 token signed with AUTH_DEV_SECRET (env).
 *           Lets you test locally without the production JWKS endpoint.
 *
 * Required claims: { email, role, exp, iss }.
 * Allowed only when role === 'admin' (and iss === 'https://auth.agentabrams.com' in prod).
 *
 * Fail-closed: any missing/expired/invalid token → 401.
 */
const jwt = require('jsonwebtoken');

const ISSUER = 'https://auth.agentabrams.com';
const COOKIE_NAME = 'dw_auth';

let jwksCache = null;
let jwksFetchedAt = 0;

async function getJwks(url) {
  if (jwksCache && Date.now() - jwksFetchedAt < 10 * 60_000) return jwksCache;
  const res = await fetch(url);
  if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);
  jwksCache = await res.json();
  jwksFetchedAt = Date.now();
  return jwksCache;
}

function pemFromJwk(jwk) {
  // Minimal RSA n/e → PEM. For HS256 dev mode this is unused.
  // Pulled lazily — for v1 we recommend symmetric DEV mode or
  // a thin RS256 verifier wired against auth.agentabrams.com's
  // public key (paste into AUTH_PUBLIC_PEM env). Keep complexity
  // out of this file; if AUTH_PUBLIC_PEM is set, use it.
  return null;
}

async function verifyJwt(token, opts) {
  const { authMode, devSecret, publicPem, jwksUrl } = opts;
  if (authMode === 'dev' || (!publicPem && !jwksUrl)) {
    if (!devSecret) throw new Error('AUTH_DEV_SECRET missing');
    return jwt.verify(token, devSecret, { algorithms: ['HS256'], issuer: ISSUER });
  }
  if (publicPem) {
    return jwt.verify(token, publicPem, { algorithms: ['RS256'], issuer: ISSUER });
  }
  // JWKS path — kid → key lookup left as a later enhancement; prefer publicPem.
  throw new Error('JWKS path not implemented in v1 — set AUTH_PUBLIC_PEM');
}

function authMiddleware(opts) {
  return async function adminGate(req, res, next) {
    const token = (req.cookies && req.cookies[COOKIE_NAME]) || null;
    if (!token) return res.status(401).json({ error: 'no_auth' });
    try {
      const claims = await verifyJwt(token, opts);
      if (!claims || claims.role !== 'admin') {
        return res.status(403).json({ error: 'not_admin' });
      }
      req.editor = {
        email: claims.email,
        role: claims.role,
        iss: claims.iss
      };
      return next();
    } catch (e) {
      return res.status(401).json({ error: 'invalid_token', detail: String(e.message || e) });
    }
  };
}

// Soft check — readable from middleware/templates to decide whether to inject editor JS
function readAdminFromCookie(req, opts) {
  const token = req.cookies && req.cookies[COOKIE_NAME];
  if (!token) return null;
  try {
    const decoded = jwt.decode(token);
    if (decoded && decoded.role === 'admin' && decoded.exp * 1000 > Date.now()) {
      return decoded;
    }
  } catch {}
  return null;
}

module.exports = { authMiddleware, readAdminFromCookie, COOKIE_NAME, ISSUER };