Session 10: Internal auth refactor, prefetch cascade keys, Sentry, welcome email (1286 tests)

This commit is contained in:
Kev
2026-06-10 20:45:05 -04:00
parent b55dcbd614
commit e5c45ecc8e
22 changed files with 3837 additions and 94 deletions
+97
View File
@@ -0,0 +1,97 @@
/**
* Internal authentication middleware (Session 10).
*
* Protects internal-only endpoints — the grading pipeline, the
* resolution poll-back, the corrections sweep — that are called by
* pollers, n8n workflows, and cron jobs but NEVER by browser users.
* Uses a shared secret in `VYNDR_INTERNAL_KEY` checked against the
* request header.
*
* Deliberately separate from `requireAuth` (Supabase JWT). Internal
* callers don't have user sessions.
*
* Header compatibility:
* `x-internal-key` — Session 10 short form (n8n + new callers)
* `X-VYNDR-Internal-Key` — legacy form, kept for backwards
* compatibility with the poller and
* the existing test suite. The
* middleware accepts either; callers
* should prefer the short form.
*
* Options:
* loopbackOnly (default false) — additionally enforce that the
* request originated from 127.0.0.1 / ::1. Use for endpoints that
* should ONLY be reachable from co-located processes (the poller
* pulling box scores). Endpoints called from n8n or other
* containers MUST omit this option.
*
* Responses:
* 503 — VYNDR_INTERNAL_KEY env var not set (misconfigured)
* 401 — header missing OR key mismatch
* 403 — loopbackOnly=true and the request came from off-host
*/
const crypto = require('crypto');
const LOOPBACK_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
// Timing-safe string compare. crypto.timingSafeEqual throws on
// length mismatch, so we pad to a fixed length first to keep the
// comparison constant-time regardless of input length.
function timingSafeStringEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
const len = Math.max(a.length, b.length);
// Pad with a NUL byte (will never appear in a real key) so the
// shorter side still has the same length.
const ba = Buffer.alloc(len, 0);
const bb = Buffer.alloc(len, 0);
ba.write(a, 'utf8');
bb.write(b, 'utf8');
// Even on length mismatch the compare runs to completion; we just
// also require lengths match to count as equal.
const equal = crypto.timingSafeEqual(ba, bb);
return equal && a.length === b.length;
}
function readHeader(req) {
// Express normalizes header names to lowercase. Try the short form
// first (the documented one), then the legacy long form.
return req.get('x-internal-key') || req.get('X-VYNDR-Internal-Key') || null;
}
function requireInternalAuth(options = {}) {
const loopbackOnly = !!options.loopbackOnly;
return function internalAuthMiddleware(req, res, next) {
const expected = process.env.VYNDR_INTERNAL_KEY;
if (!expected) {
// Refuse to serve when the secret is unset — better than
// accidentally exposing the endpoint with a default empty
// value. n8n uses this to distinguish "misconfigured" (503)
// from "wrong key" (401).
return res.status(503).json({ error: 'Internal auth not configured' });
}
const provided = readHeader(req);
if (!provided || !timingSafeStringEqual(provided, expected)) {
return res.status(401).json({ error: 'Invalid internal key' });
}
if (loopbackOnly) {
const remoteIp = req.ip || req.socket?.remoteAddress;
if (!LOOPBACK_IPS.has(remoteIp)) {
return res.status(403).json({ error: 'Origin not permitted' });
}
}
return next();
};
}
module.exports = {
requireInternalAuth,
__internals: {
LOOPBACK_IPS,
timingSafeStringEqual,
readHeader,
},
};