feat(mam-api): requireAuth middleware — session + bearer + idle/absolute timeout

This commit is contained in:
Zac Gaetano 2026-05-27 13:59:50 -04:00
parent 3bca290e09
commit 0248a68f57
2 changed files with 212 additions and 0 deletions

View file

@ -0,0 +1,63 @@
import pool from '../db/pool.js';
import { parseBearer, hashToken } from '../auth/tokens.js';
// Stable UUID matching migration 023's seeded dev user.
export const DEV_USER_ID = '00000000-0000-4000-8000-000000000dev';
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)' };
const ABSOLUTE_MS = 8 * 3600 * 1000;
const IDLE_MS = 1 * 3600 * 1000;
async function destroyAnd401(req, res) {
if (req.session?.destroy) {
await new Promise(r => req.session.destroy(() => r()));
}
return res.status(401).json({ error: 'unauthorized' });
}
async function loadUser(id) {
const { rows } = await pool.query(
`SELECT id, username, display_name FROM users WHERE id = $1`, [id]);
return rows[0] || null;
}
export async function requireAuth(req, res, next) {
// Dev mode — attach the seeded dev user so FK-bearing routes work.
if (process.env.AUTH_ENABLED !== 'true') {
req.user = DEV_USER;
return next();
}
// 1. Session
if (req.session?.user_id) {
const now = Date.now();
const first = req.session.first_seen_at || 0;
const last = req.session.last_seen_at || 0;
if (now - first > ABSOLUTE_MS) return destroyAnd401(req, res);
if (now - last > IDLE_MS) return destroyAnd401(req, res);
req.session.last_seen_at = now;
const u = await loadUser(req.session.user_id);
if (!u) return destroyAnd401(req, res);
req.user = u;
return next();
}
// 2. Bearer
const bearer = parseBearer(req.headers.authorization);
if (bearer) {
const hash = hashToken(bearer);
const { rows } = await pool.query(
`SELECT t.id AS token_id, t.user_id, t.expires_at, u.username, u.display_name
FROM api_tokens t JOIN users u ON u.id = t.user_id
WHERE t.token_hash = $1`, [hash]);
if (rows.length && (!rows[0].expires_at || rows[0].expires_at > new Date())) {
pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [rows[0].token_id])
.catch(err => console.error('[auth] token last_used_at update failed:', err.message));
req.user = { id: rows[0].user_id, username: rows[0].username, display_name: rows[0].display_name };
return next();
}
}
// 3. Nothing matched
return res.status(401).json({ error: 'unauthorized' });
}

View file

@ -0,0 +1,149 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import { requireAuth, DEV_USER_ID } from '../../src/middleware/auth.js';
import { generateToken, hashToken } from '../../src/auth/tokens.js';
function mockRes() {
const res = {
statusCode: 200, body: null,
status(n) { this.statusCode = n; return this; },
json(o) { this.body = o; return this; },
};
return res;
}
function mockReq({ session = null, authHeader = null } = {}) {
return {
session,
headers: authHeader ? { authorization: authHeader } : {},
};
}
test('AUTH_ENABLED=false → attaches dev user and calls next', async () => {
delete process.env.AUTH_ENABLED;
const req = mockReq(); const res = mockRes(); let called = false;
await requireAuth(req, res, () => { called = true; });
assert.equal(called, true);
assert.equal(req.user.id, DEV_USER_ID);
assert.equal(req.user.username, 'dev');
});
test('AUTH_ENABLED=true + no session + no bearer → 401', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
const pool = await setupTestDb();
process.env.AUTH_ENABLED = 'true';
try {
const { requireAuth } = await import('../../src/middleware/auth.js?cache=' + Date.now());
const req = mockReq(); const res = mockRes();
await requireAuth(req, res, () => {});
assert.equal(res.statusCode, 401);
assert.deepEqual(res.body, { error: 'unauthorized' });
} finally { await pool.end(); }
});
test('valid session within idle/absolute window → next + req.user populated', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
const pool = await setupTestDb();
process.env.AUTH_ENABLED = 'true';
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
const { rows } = await pool.query(
`INSERT INTO users (username, password_hash, display_name, role)
VALUES ('alice', 'x', 'Alice', 'admin') RETURNING id`);
const userId = rows[0].id;
try {
const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
const now = Date.now();
const req = mockReq({ session: { user_id: userId, first_seen_at: now - 1000, last_seen_at: now - 500 } });
const res = mockRes(); let called = false;
await requireAuth(req, res, () => { called = true; });
assert.equal(called, true, 'next not called; body=' + JSON.stringify(res.body));
assert.equal(req.user.id, userId);
assert.equal(req.user.username, 'alice');
} finally { await pool.end(); }
});
test('idle-expired session (>1h since last_seen_at) → 401', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
const pool = await setupTestDb();
process.env.AUTH_ENABLED = 'true';
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
const { rows } = await pool.query(
`INSERT INTO users (username, password_hash) VALUES ('b', 'x') RETURNING id`);
try {
const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
const now = Date.now();
const session = { user_id: rows[0].id, first_seen_at: now - 1000, last_seen_at: now - (61 * 60 * 1000), destroy(cb){ cb(); } };
const req = mockReq({ session }); const res = mockRes();
await requireAuth(req, res, () => {});
assert.equal(res.statusCode, 401);
} finally { await pool.end(); }
});
test('absolute-expired session (>8h since first_seen_at) → 401', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
const pool = await setupTestDb();
process.env.AUTH_ENABLED = 'true';
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
const { rows } = await pool.query(
`INSERT INTO users (username, password_hash) VALUES ('c', 'x') RETURNING id`);
try {
const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
const now = Date.now();
const session = { user_id: rows[0].id, first_seen_at: now - (9 * 3600 * 1000), last_seen_at: now - 100, destroy(cb){ cb(); } };
const req = mockReq({ session }); const res = mockRes();
await requireAuth(req, res, () => {});
assert.equal(res.statusCode, 401);
} finally { await pool.end(); }
});
test('valid bearer token → next + req.user populated', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
const pool = await setupTestDb();
process.env.AUTH_ENABLED = 'true';
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
const { rows: u } = await pool.query(
`INSERT INTO users (username, password_hash) VALUES ('d', 'x') RETURNING id`);
const token = generateToken();
await pool.query(
`INSERT INTO api_tokens (user_id, name, token_hash, token_prefix)
VALUES ($1, 'test', $2, $3)`,
[u[0].id, hashToken(token), token.slice(0, 8)]);
try {
const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
const req = mockReq({ authHeader: 'Bearer ' + token });
const res = mockRes(); let called = false;
await requireAuth(req, res, () => { called = true; });
assert.equal(called, true, 'next not called; body=' + JSON.stringify(res.body));
assert.equal(req.user.username, 'd');
} finally { await pool.end(); }
});
test('invalid bearer token → 401', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
const pool = await setupTestDb();
process.env.AUTH_ENABLED = 'true';
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
const req = mockReq({ authHeader: 'Bearer dfl_nope' });
const res = mockRes();
await requireAuth(req, res, () => {});
assert.equal(res.statusCode, 401);
} finally { await pool.end(); }
});
test('bearer token whose user was deleted → 401', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
const pool = await setupTestDb();
process.env.AUTH_ENABLED = 'true';
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
const { rows: u } = await pool.query(
`INSERT INTO users (username, password_hash) VALUES ('e', 'x') RETURNING id`);
const token = generateToken();
await pool.query(
`INSERT INTO api_tokens (user_id, name, token_hash, token_prefix)
VALUES ($1, 'test', $2, $3)`,
[u[0].id, hashToken(token), token.slice(0, 8)]);
await pool.query(`DELETE FROM users WHERE id = $1`, [u[0].id]);
try {
const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
const req = mockReq({ authHeader: 'Bearer ' + token });
const res = mockRes();
await requireAuth(req, res, () => {});
assert.equal(res.statusCode, 401);
} finally { await pool.end(); }
});