build-container / build (push) Successful in 2m39s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
156 lines
9.2 KiB
JavaScript
156 lines
9.2 KiB
JavaScript
// Passkey (WebAuthn) sign-in that gates the whole app.
|
|
// First passkey: enrolled with a setup code (HUB_SETUP_CODE, or a random one printed to the server log).
|
|
// After that, further passkeys can only be added from an already signed-in session.
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const {
|
|
generateRegistrationOptions, verifyRegistrationResponse,
|
|
generateAuthenticationOptions, verifyAuthenticationResponse,
|
|
} = require('@simplewebauthn/server');
|
|
|
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8')); } catch { return d; } };
|
|
const writeJson = (f, v) => fs.writeFileSync(path.join(DATA_DIR, f), JSON.stringify(v, null, 2), { mode: 0o600 });
|
|
|
|
// Signing secret for cookies: env wins, otherwise generated once and kept so sessions survive restarts.
|
|
let secret = process.env.COOKIE_SECRET;
|
|
if (secret && secret.length < 24) console.warn('[auth] COOKIE_SECRET is short; use at least 32 random characters (openssl rand -hex 32).');
|
|
if (secret && process.env.HUB_SETUP_CODE && secret === process.env.HUB_SETUP_CODE) console.warn('[auth] COOKIE_SECRET equals HUB_SETUP_CODE. Anyone who knows the setup code could forge sessions. Use different random values.');
|
|
if (!secret) {
|
|
secret = readJson('secret.json', {}).secret;
|
|
if (!secret) { secret = crypto.randomBytes(32).toString('hex'); writeJson('secret.json', { secret }); }
|
|
}
|
|
|
|
const store = readJson('passkeys.json', { userId: crypto.randomBytes(32).toString('base64url'), creds: [] });
|
|
const save = () => writeJson('passkeys.json', store);
|
|
|
|
const SETUP_CODE = process.env.HUB_SETUP_CODE || crypto.randomBytes(4).toString('hex');
|
|
if (!store.creds.length) console.log(`[auth] No passkeys yet. Setup code: ${SETUP_CODE}`);
|
|
|
|
const SESSION_DAYS = 30;
|
|
const mac = v => crypto.createHmac('sha256', secret).update(v).digest('base64url');
|
|
const cookies = req => Object.fromEntries((req.headers.cookie || '').split(/; */).filter(Boolean).map(c => { const i = c.indexOf('='); return [c.slice(0, i), decodeURIComponent(c.slice(i + 1))]; }));
|
|
const isHttps = req => (req.headers['x-forwarded-proto'] || '') === 'https' || (process.env.PUBLIC_URL || '').startsWith('https://');
|
|
|
|
function isAuthed(req) {
|
|
const [exp, sig] = String(cookies(req).hub_session || '').split('.');
|
|
if (!exp || !sig) return false;
|
|
const want = mac('s' + exp);
|
|
return sig.length === want.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want)) && Number(exp) > Date.now();
|
|
}
|
|
function startSession(req, res) {
|
|
const exp = Date.now() + SESSION_DAYS * 864e5;
|
|
res.setHeader('Set-Cookie', `hub_session=${exp}.${mac('s' + exp)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_DAYS * 86400}${isHttps(req) ? '; Secure' : ''}`);
|
|
}
|
|
|
|
const challenges = new Map(); // cid -> { challenge, kind, exp }
|
|
const newChallenge = (challenge, kind) => {
|
|
for (const [k, v] of challenges) if (v.exp < Date.now()) challenges.delete(k);
|
|
const cid = crypto.randomBytes(16).toString('base64url');
|
|
challenges.set(cid, { challenge, kind, exp: Date.now() + 5 * 60e3 });
|
|
return cid;
|
|
};
|
|
const takeChallenge = (cid, kind) => {
|
|
const c = challenges.get(cid); challenges.delete(cid);
|
|
return c && c.kind === kind && c.exp > Date.now() ? c.challenge : null;
|
|
};
|
|
|
|
const hits = new Map(); // crude per-IP throttle on anything that checks a secret
|
|
const throttled = req => {
|
|
const ip = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
|
|
const h = hits.get(ip) || { n: 0, t: Date.now() };
|
|
if (Date.now() - h.t > 60e3) { h.n = 0; h.t = Date.now(); }
|
|
h.n++; hits.set(ip, h);
|
|
return h.n > 10;
|
|
};
|
|
|
|
// When PUBLIC_URL is set it is the source of truth for the passkey origin, so a reverse proxy that
|
|
// forgets Host / X-Forwarded-Proto cannot break passkeys. Otherwise derive it from the request.
|
|
const PUBLIC = (process.env.PUBLIC_URL || '').replace(/\/$/, '');
|
|
const rp = req => {
|
|
if (PUBLIC) { const u = new URL(PUBLIC); return { rpID: u.hostname, origin: u.origin }; }
|
|
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost';
|
|
const proto = req.headers['x-forwarded-proto'] || 'http';
|
|
return { rpID: host.split(':')[0], origin: `${proto}://${host}` };
|
|
};
|
|
const equalStr = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && crypto.timingSafeEqual(x, y); };
|
|
|
|
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
|
|
const send = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', ...(res.getHeader('Set-Cookie') ? { 'Set-Cookie': res.getHeader('Set-Cookie') } : {}) }); res.end(JSON.stringify(obj)); };
|
|
|
|
async function handle(req, res) {
|
|
const url = new URL(req.url, 'http://x'), p = url.pathname;
|
|
try {
|
|
if (p === '/api/auth/state') return send(res, 200, { registered: store.creds.length > 0, authed: isAuthed(req) });
|
|
if (p === '/api/auth/logout' && req.method === 'POST') {
|
|
res.setHeader('Set-Cookie', 'hub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
|
return send(res, 200, { ok: true });
|
|
}
|
|
const body = req.method === 'POST' || req.method === 'DELETE' ? JSON.parse((await readBody(req)) || '{}') : {};
|
|
const { rpID, origin } = rp(req);
|
|
|
|
if (p === '/api/auth/register/options' && req.method === 'POST') {
|
|
const authed = isAuthed(req);
|
|
if (!authed) {
|
|
if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
|
|
if (store.creds.length || !equalStr(body.setupCode || '', SETUP_CODE)) return send(res, 403, { error: 'Wrong setup code' });
|
|
}
|
|
const options = await generateRegistrationOptions({
|
|
rpName: 'NE Hub', rpID, userName: 'NE Hub owner', userID: Buffer.from(store.userId, 'base64url'),
|
|
attestationType: 'none',
|
|
excludeCredentials: store.creds.map(c => ({ id: c.id, transports: c.transports })),
|
|
authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
|
|
});
|
|
return send(res, 200, { cid: newChallenge(options.challenge, authed ? 'reg-authed' : 'reg-setup'), options });
|
|
}
|
|
if (p === '/api/auth/register/verify' && req.method === 'POST') {
|
|
const authed = isAuthed(req);
|
|
if (!authed && throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
|
|
const challenge = takeChallenge(body.cid, authed ? 'reg-authed' : 'reg-setup');
|
|
if (!challenge) return send(res, 400, { error: 'Request expired, try again' });
|
|
if (!authed && store.creds.length) return send(res, 403, { error: 'Not allowed' });
|
|
const v = await verifyRegistrationResponse({ response: body.response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID });
|
|
if (!v.verified) return send(res, 400, { error: 'Could not verify passkey' });
|
|
const c = v.registrationInfo.credential;
|
|
store.creds.push({ id: c.id, publicKey: Buffer.from(c.publicKey).toString('base64url'), counter: c.counter, transports: c.transports || [],
|
|
name: String(body.name || 'Passkey').slice(0, 40), created: Date.now() });
|
|
save();
|
|
if (!authed) startSession(req, res);
|
|
return send(res, 200, { ok: true });
|
|
}
|
|
if (p === '/api/auth/login/options' && req.method === 'POST') {
|
|
if (!store.creds.length) return send(res, 400, { error: 'No passkeys set up yet' });
|
|
const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred' });
|
|
return send(res, 200, { cid: newChallenge(options.challenge, 'login'), options });
|
|
}
|
|
if (p === '/api/auth/login/verify' && req.method === 'POST') {
|
|
if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
|
|
const challenge = takeChallenge(body.cid, 'login');
|
|
const cred = store.creds.find(c => c.id === body.response?.id);
|
|
if (!challenge || !cred) return send(res, 400, { error: 'Passkey not recognised' });
|
|
const v = await verifyAuthenticationResponse({
|
|
response: body.response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID,
|
|
credential: { id: cred.id, publicKey: Buffer.from(cred.publicKey, 'base64url'), counter: cred.counter, transports: cred.transports },
|
|
});
|
|
if (!v.verified) return send(res, 401, { error: 'Sign-in failed' });
|
|
cred.counter = v.authenticationInfo.newCounter; cred.lastUsed = Date.now(); save();
|
|
startSession(req, res);
|
|
return send(res, 200, { ok: true });
|
|
}
|
|
// Everything below needs a signed-in session.
|
|
if (!isAuthed(req)) return send(res, 401, { error: 'Not signed in' });
|
|
if (p === '/api/auth/passkeys' && req.method === 'GET') {
|
|
return send(res, 200, store.creds.map(c => ({ id: c.id, name: c.name, created: c.created, lastUsed: c.lastUsed || null })));
|
|
}
|
|
if (p === '/api/auth/passkeys' && req.method === 'DELETE') {
|
|
store.creds = store.creds.filter(c => c.id !== body.id); save();
|
|
return send(res, 200, { ok: true, remaining: store.creds.length });
|
|
}
|
|
send(res, 404, { error: 'Not found' });
|
|
} catch (e) { send(res, 400, { error: e.message }); }
|
|
}
|
|
|
|
module.exports = { handle, isAuthed, secret };
|