← 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 };