feat(mam-api): API token CRUD — show raw once, bearer-authenticate via SHA-256 lookup
This commit is contained in:
parent
b7f5a84d2d
commit
56b661ef65
3 changed files with 150 additions and 0 deletions
|
|
@ -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);
|
||||
|
|
|
|||
46
services/mam-api/src/routes/tokens.js
Normal file
46
services/mam-api/src/routes/tokens.js
Normal file
|
|
@ -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;
|
||||
102
services/mam-api/test/routes/tokens.test.js
Normal file
102
services/mam-api/test/routes/tokens.test.js
Normal file
|
|
@ -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(); }
|
||||
});
|
||||
Loading…
Reference in a new issue