diff --git a/services/mam-api/src/middleware/auth.js b/services/mam-api/src/middleware/auth.js new file mode 100644 index 0000000..234c364 --- /dev/null +++ b/services/mam-api/src/middleware/auth.js @@ -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' }); +} diff --git a/services/mam-api/test/middleware/auth.test.js b/services/mam-api/test/middleware/auth.test.js new file mode 100644 index 0000000..a23a1fb --- /dev/null +++ b/services/mam-api/test/middleware/auth.test.js @@ -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(); } +});