From 56b661ef656a02331783d987ecf1eb3f48d7f892 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:52:07 -0400 Subject: [PATCH] =?UTF-8?q?feat(mam-api):=20API=20token=20CRUD=20=E2=80=94?= =?UTF-8?q?=20show=20raw=20once,=20bearer-authenticate=20via=20SHA-256=20l?= =?UTF-8?q?ookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/mam-api/src/index.js | 2 + services/mam-api/src/routes/tokens.js | 46 +++++++++ services/mam-api/test/routes/tokens.test.js | 102 ++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 services/mam-api/src/routes/tokens.js create mode 100644 services/mam-api/test/routes/tokens.test.js diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 407f20a..3ee3ea7 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -12,6 +12,7 @@ import { requireAuth } from './middleware/auth.js'; import { loadS3ConfigFromDb } from './s3/client.js'; import authRouter from './routes/auth.js'; +import tokensRouter from './routes/tokens.js'; import usersRouter from './routes/users.js'; // Routes import assetsRouter from './routes/assets.js'; @@ -108,6 +109,7 @@ app.use('/api/v1', (req, res, next) => { // ── API Routes ──────────────────────────────────────────────────────────────── app.use('/api/v1/auth', authRouter); app.use('/api/v1/auth/users', usersRouter); +app.use('/api/v1/auth/tokens', requireAuth, tokensRouter); app.use('/api/v1/assets', assetsRouter); app.use('/api/v1/projects', projectsRouter); app.use('/api/v1/bins', binsRouter); diff --git a/services/mam-api/src/routes/tokens.js b/services/mam-api/src/routes/tokens.js new file mode 100644 index 0000000..195d2bc --- /dev/null +++ b/services/mam-api/src/routes/tokens.js @@ -0,0 +1,46 @@ +// Current-user API token CRUD. The raw token is returned exactly once at +// creation time; only the SHA-256 hash and an 8-char display prefix are stored. +import express from 'express'; +import pool from '../db/pool.js'; +import { generateToken, hashToken, tokenDisplayPrefix } from '../auth/tokens.js'; + +const router = express.Router(); + +// GET / — list current user's tokens (prefix only, never the raw token) +router.get('/', async (req, res, next) => { + try { + const { rows } = await pool.query( + `SELECT id, name, token_prefix AS prefix, last_used_at, expires_at, created_at + FROM api_tokens WHERE user_id = $1 ORDER BY created_at DESC`, + [req.user.id]); + res.json(rows); + } catch (err) { next(err); } +}); + +// POST / — create a new token +router.post('/', async (req, res, next) => { + try { + const { name, expires_at } = req.body || {}; + if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name required' }); + const raw = generateToken(); + const { rows } = await pool.query( + `INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, name, token_prefix AS prefix, expires_at, created_at`, + [req.user.id, name.trim(), hashToken(raw), tokenDisplayPrefix(raw), expires_at || null]); + res.status(201).json({ ...rows[0], token: raw }); // raw token shown exactly once + } catch (err) { next(err); } +}); + +// DELETE /:id — revoke; only the owner can revoke +router.delete('/:id', async (req, res, next) => { + try { + const { rowCount } = await pool.query( + `DELETE FROM api_tokens WHERE id = $1 AND user_id = $2`, + [req.params.id, req.user.id]); + if (rowCount === 0) return res.status(404).json({ error: 'token not found' }); + res.status(204).end(); + } catch (err) { next(err); } +}); + +export default router; diff --git a/services/mam-api/test/routes/tokens.test.js b/services/mam-api/test/routes/tokens.test.js new file mode 100644 index 0000000..50c7baa --- /dev/null +++ b/services/mam-api/test/routes/tokens.test.js @@ -0,0 +1,102 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import session from 'express-session'; +import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js'; +import tokensRouter from '../../src/routes/tokens.js'; +import authRouter from '../../src/routes/auth.js'; +import { requireAuth } from '../../src/middleware/auth.js'; +import { hashPassword } from '../../src/auth/passwords.js'; + +async function app(pool) { + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + process.env.AUTH_ENABLED = 'true'; + const ConnectPg = (await import('connect-pg-simple')).default(session); + const a = express(); + a.use(express.json()); + a.use(session({ + store: new ConnectPg({ pool, tableName: 'sessions' }), + secret: 'test', name: 'dragonflight.sid', + cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 }, + rolling: false, resave: false, saveUninitialized: false, + })); + a.use('/api/v1/auth', authRouter); + a.use('/api/v1/auth/tokens', requireAuth, tokensRouter); + a.get('/api/v1/protected/ping', requireAuth, (req, res) => res.json({ user: req.user.username })); + return new Promise(r => { + const srv = a.listen(0, '127.0.0.1', () => { + r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }); + }); + }); +} + +async function loginCookie(baseUrl, u, p) { + const r = await fetch(baseUrl + '/api/v1/auth/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: u, password: p }), + }); + return (r.headers.get('set-cookie') || '').split(';')[0]; +} + +test('tokens: create returns the raw token exactly once; bearer of that token works; revoke 401s subsequent calls', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]); + const { baseUrl, close } = await app(pool); + try { + const cookie = await loginCookie(baseUrl, 'alice', 'correct-horse-battery'); + + // Create + const create = await fetch(baseUrl + '/api/v1/auth/tokens', { + method: 'POST', headers: { 'Content-Type': 'application/json', cookie }, + body: JSON.stringify({ name: 'Premiere panel' }), + }); + assert.equal(create.status, 201); + const created = await create.json(); + assert.match(created.token, /^dfl_[0-9a-f]{64}$/); + assert.equal(created.prefix, created.token.slice(0, 8)); + + // List — should NOT include the raw token, only the prefix. + const list = await fetch(baseUrl + '/api/v1/auth/tokens', { headers: { cookie } }); + const rows = await list.json(); + assert.equal(rows.length, 1); + assert.equal(rows[0].prefix, created.prefix); + assert.equal(rows[0].token, undefined); + + // The raw token authenticates as a bearer. + const ping = await fetch(baseUrl + '/api/v1/protected/ping', { + headers: { authorization: 'Bearer ' + created.token }, + }); + assert.equal(ping.status, 200); + + // Revoke. + const rev = await fetch(baseUrl + '/api/v1/auth/tokens/' + created.id, { + method: 'DELETE', headers: { cookie }, + }); + assert.equal(rev.status, 204); + + // Same bearer now 401s. + const ping2 = await fetch(baseUrl + '/api/v1/protected/ping', { + headers: { authorization: 'Bearer ' + created.token }, + }); + assert.equal(ping2.status, 401); + } finally { await close(); await pool.end(); } +}); + +test('tokens: cannot revoke another users token', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]); + await pool.query(`INSERT INTO users (username, password_hash) VALUES ('bob', $1)`, [await hashPassword('bob-passphrase-12')]); + const { baseUrl, close } = await app(pool); + try { + const aliceCookie = await loginCookie(baseUrl, 'alice', 'correct-horse-battery'); + const bobCookie = await loginCookie(baseUrl, 'bob', 'bob-passphrase-12'); + const aliceTok = await (await fetch(baseUrl + '/api/v1/auth/tokens', { + method: 'POST', headers: { 'Content-Type': 'application/json', cookie: aliceCookie }, + body: JSON.stringify({ name: 'alice token' }), + })).json(); + const r = await fetch(baseUrl + '/api/v1/auth/tokens/' + aliceTok.id, { + method: 'DELETE', headers: { cookie: bobCookie }, + }); + assert.equal(r.status, 404); // not found from bob's perspective + } finally { await close(); await pool.end(); } +});