feat(mam-api): requireAuth middleware — session + bearer + idle/absolute timeout
This commit is contained in:
parent
3bca290e09
commit
0248a68f57
2 changed files with 212 additions and 0 deletions
63
services/mam-api/src/middleware/auth.js
Normal file
63
services/mam-api/src/middleware/auth.js
Normal 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' });
|
||||
}
|
||||
149
services/mam-api/test/middleware/auth.test.js
Normal file
149
services/mam-api/test/middleware/auth.test.js
Normal 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(); }
|
||||
});
|
||||
Loading…
Reference in a new issue