Files
picpeak/backend/src/middleware/apiTokenAuth.js
T
Paul Nothaft c6ec93eef9 fix(dates): normalize SQLite epoch timestamps at remaining API surfaces (#485 follow-up) (#857)
The audit #485 called for: on SQLite (native installs), timestamp
columns written with a raw `new Date()` through knex store epoch-ms
numbers; Postgres returns ISO strings. Frontend code written against
Postgres calls parseISO() on them — parseISO(number) throws and crashes
the page. #485 fixed admin Users and listed api tokens / photos /
activity as out-of-scope follow-ups.

Verified crash on main: Timeline gallery layout parseISO(uploaded_at)
against photos written by the archive-RESTORE path (raw Date). Other
raw-write surfaces (api_tokens last_used_at/revoked_at, email_queue)
degrade rather than crash but violate the ISO contract.

- extract toIso() from adminUsers.js into utils/dateNormalize.js
  (contract unchanged — the 10 existing #485 tests still pin it)
- write-side: archive-restore uploaded_at, api-token last_used_at /
  revoked_at, email_queue created_at/sent_at now write ISO strings
- read-side (heals existing corrupted rows): gallery /photos normalizes
  uploaded_at/captured_at; api-tokens list normalizes all four
  timestamp fields
- frontend defence-in-depth: Timeline layout parses uploaded_at
  tolerantly (typeof guard) for stale caches / old backends
- 2 regression tests seed literal epoch numbers and assert the API
  serves ISO strings

activity_logs turned out safe (created_at comes from the DB default,
not a raw Date) — left untouched.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-22 21:07:32 +02:00

124 lines
3.8 KiB
JavaScript

const crypto = require('crypto');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const TOKEN_PREFIX = 'pp_live_';
const VALID_SCOPES = ['read', 'write', 'admin'];
function hashToken(plaintext) {
return crypto.createHash('sha256').update(plaintext).digest('hex');
}
/**
* Generate a new API token. Returns the plaintext (return once, never
* stored) plus the row payload to insert. Caller persists.
*/
function generateApiToken() {
const random = crypto.randomBytes(24).toString('base64url'); // 32 chars
const plaintext = `${TOKEN_PREFIX}${random}`;
return {
plaintext,
hashed: hashToken(plaintext),
preview: random.slice(0, 8)
};
}
function parseScopes(raw) {
if (!raw) return [];
return String(raw)
.split(',')
.map((s) => s.trim().toLowerCase())
.filter((s) => VALID_SCOPES.includes(s));
}
/**
* Middleware: authenticate via API token. Maps the token to its owner
* admin user, attaches { req.admin, req.apiToken }, then defers to the
* regular permission machinery on top.
*
* Mount this *instead* of `adminAuth` on /api/v1/* routes. Existing
* permission decorators (`requirePermission('events.create')`) still
* work because they read `req.admin.id`.
*/
async function apiTokenAuth(req, res, next) {
try {
const header = req.headers?.authorization || '';
if (!header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing API token', code: 'NO_TOKEN' });
}
const token = header.slice(7).trim();
if (!token.startsWith(TOKEN_PREFIX)) {
return res.status(401).json({ error: 'Invalid token format', code: 'INVALID_TOKEN' });
}
const hashed = hashToken(token);
const row = await db('api_tokens').where({ hashed_token: hashed }).first();
if (!row) {
return res.status(401).json({ error: 'Invalid token', code: 'INVALID_TOKEN' });
}
if (row.revoked_at) {
return res.status(401).json({ error: 'Token revoked', code: 'TOKEN_REVOKED' });
}
if (row.expires_at && new Date(row.expires_at) <= new Date()) {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
const admin = await db('admin_users')
.where({ id: row.created_by, is_active: true })
.select('id', 'username', 'email', 'role_id')
.first();
if (!admin) {
return res.status(401).json({ error: 'Token owner unavailable', code: 'OWNER_INACTIVE' });
}
// Touch last_used_at — async, don't block the request.
db('api_tokens').where({ id: row.id }).update({ last_used_at: new Date().toISOString() })
.catch((err) => logger.debug('api_tokens last_used update failed', { err: err.message }));
req.admin = admin;
req.apiToken = {
id: row.id,
name: row.name,
scopes: parseScopes(row.scopes)
};
return next();
} catch (error) {
logger.error('apiTokenAuth error', { error: error.message });
return res.status(500).json({ error: 'Authentication error' });
}
}
/**
* Middleware factory: require a specific scope on the API token. Use
* after apiTokenAuth — `requireApiScope('write')` rejects read-only
* tokens trying to mutate.
*/
function requireApiScope(scope) {
return (req, res, next) => {
const have = req.apiToken?.scopes || [];
// 'admin' implies write/read; 'write' implies read.
const expanded = new Set(have);
if (have.includes('admin')) ['write', 'read'].forEach((s) => expanded.add(s));
if (have.includes('write')) expanded.add('read');
if (!expanded.has(scope)) {
return res.status(403).json({
error: `Token lacks required scope: ${scope}`,
code: 'INSUFFICIENT_SCOPE',
required: scope,
granted: have
});
}
next();
};
}
module.exports = {
apiTokenAuth,
requireApiScope,
generateApiToken,
hashToken,
parseScopes,
TOKEN_PREFIX,
VALID_SCOPES
};