screentinker/server/middleware/apiToken.js
ScreenTinker 40102b2b41 feat(api): agency portal endpoints + router.param target seam (#73)
The agency capability behind the proven off-ladder/agencyGate primitive:
- agencyGate is now SCOPE-only at the mount; the per-target check is router.param
  ('playlistId') in routes/agency.js - it fires WITH the param before the handler, so no
  :playlistId route can skip it (drift-proof). A mount-level target check was silently
  bypassed (Express populates req.params only at route match); the integration bite-suite
  caught it - this is the fix.
- routes/agency.js: POST /content (shared ingest) + POST /playlists/:id/items (date-bounded
  #74/#75 item; lands as draft so the admin's re-publish is the approval gate).
- tokens.js: issue scope='agency' tokens bound to a non-empty in-workspace playlist
  allowlist (atomic); PUT /:id/targets re-designates (JWT-only -> can't self-widen).
- server.js: AGENCY_ROUTERS mounted bearerAuth + resolveTenancy + agencyGate.

Full bite-suite (test/agency.test.js) GREEN and re-proven to bite on the SHIPPING path:
neutralizing the router.param check makes non-designated->403 go red. Four assertions at
three seams: target (router.param), off-ladder (tokenScopeGate), can't-widen (tokens
JWT-only), issuance cross-workspace (create validation). 139 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:48:42 -05:00

134 lines
6 KiB
JavaScript

// Public API token auth — a parallel front door to requireAuth, used ONLY on the
// documented public routers (see server.js). A token (Authorization: Bearer st_...)
// authenticates as its owner user, bound to ONE workspace, with a scope
// (read|write|full).
//
// SECURITY MODEL: a token NEVER carries platform/cross-org powers. apiTokenAuth
// forces the effective platform role to 'user', so every PLATFORM_ROLES /
// ELEVATED_ROLES / isPlatformStaff check downstream evaluates false and the token
// acts purely as a workspace member — workspace permissions still come from
// req.workspaceRole (resolved by resolveTenancy from the token's bound workspace),
// exactly as for a JWT session. Combined with mount-by-exclusion (tokens are never
// attached to /api/admin, auth, billing, workspaces, provisioning, status), a token
// cannot reach any privileged surface.
const crypto = require('crypto');
const { db } = require('../db/database');
const { requireAuth } = require('./auth');
const TOKEN_PREFIX = 'st_';
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
// Generate a new token string: st_ + 32 random bytes, base64url (~43 chars).
function generateToken() {
return TOKEN_PREFIX + crypto.randomBytes(32).toString('base64url');
}
// Display prefix kept in the DB for the UI list (never the secret).
function displayPrefix(token) {
return token.slice(0, TOKEN_PREFIX.length + 8); // e.g. 'st_a1b2c3d4'
}
// Throttle last_used_at writes to at most once/min per token (no write per request).
const lastUsedThrottle = new Map();
function touchLastUsed(tokenId) {
const now = Date.now();
if (now - (lastUsedThrottle.get(tokenId) || 0) < 60_000) return;
lastUsedThrottle.set(tokenId, now);
try { db.prepare("UPDATE api_tokens SET last_used_at = strftime('%s','now') WHERE id = ?").run(tokenId); } catch { /* best-effort */ }
}
function apiTokenAuth(req, res, next) {
const header = req.headers.authorization || '';
const raw = header.startsWith('Bearer ') ? header.slice(7).trim() : '';
if (!raw.startsWith(TOKEN_PREFIX)) {
return res.status(401).json({ error: 'Invalid API token' });
}
const row = db.prepare('SELECT * FROM api_tokens WHERE token_hash = ?').get(hashToken(raw));
if (!row || row.revoked_at) {
return res.status(401).json({ error: 'Invalid or revoked API token' });
}
const user = db.prepare(
'SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts, must_change_password FROM users WHERE id = ?'
).get(row.user_id);
if (!user) return res.status(401).json({ error: 'Token owner not found' });
if (user.must_change_password) {
return res.status(403).json({ error: 'Token owner must change their password before using the API' });
}
// Act AS the owner but with platform powers stripped (role forced to 'user').
req.user = { ...user, role: 'user' };
// The token's workspace is authoritative: drop X-Workspace-Id / ?workspace_id so a
// token can't be steered out of its bound workspace into another the owner happens
// to have access to (resolveTenancy precedence is header > query > jwt).
delete req.headers['x-workspace-id'];
if (req.query) delete req.query.workspace_id;
req.jwtWorkspaceId = row.workspace_id; // resolveTenancy scopes to the bound workspace
req.viaToken = true;
req.tokenScope = row.scope;
req.apiToken = { id: row.id, prefix: row.prefix, name: row.name, workspace_id: row.workspace_id };
touchLastUsed(row.id);
next();
}
// Front door: token path for "Bearer st_...", else the existing JWT requireAuth
// (unchanged). Used in place of requireAuth on the public routers only.
function bearerAuth(req, res, next) {
const header = req.headers.authorization || '';
if (header.startsWith('Bearer ' + TOKEN_PREFIX)) return apiTokenAuth(req, res, next);
return requireAuth(req, res, next);
}
// Scope ordering: read < write < full.
const SCOPE_RANK = { read: 1, write: 2, full: 3 };
function scopeAllows(have, need) {
return (SCOPE_RANK[have] || 0) >= (SCOPE_RANK[need] || 99);
}
// Method-based scope gate, mounted on the token routers AFTER resolveTenancy.
// JWT sessions pass straight through (their role gates apply). For tokens:
// GET/HEAD -> 'read', any mutation -> 'write'. Operational routes additionally
// apply requireScope('full').
function tokenScopeGate(req, res, next) {
if (!req.viaToken) return next();
const need = (req.method === 'GET' || req.method === 'HEAD') ? 'read' : 'write';
if (!scopeAllows(req.tokenScope, need)) {
return res.status(403).json({ error: `API token scope '${req.tokenScope}' cannot perform a '${need}' operation` });
}
next();
}
// Per-route override for fleet-affecting actions (device/group commands, reboot).
function requireScope(need) {
return (req, res, next) => {
if (!req.viaToken) return next();
if (!scopeAllows(req.tokenScope, need)) {
return res.status(403).json({ error: `API token scope '${req.tokenScope}' insufficient (need '${need}')` });
}
next();
};
}
// #73: mount seam for capability-restricted ('agency') tokens. SCOPE/off-ladder check ONLY:
// only an agency token reaches the agency router (a read/write/full token or a JWT is
// rejected). The PER-TARGET check CANNOT live here - Express doesn't populate req.params at
// app.use-level middleware (params land at route match, inside the router), so a mount-level
// target check is silently bypassed (the integration bite-suite caught exactly this). The
// target check is router.param('playlistId') in routes/agency.js - it fires WITH the param
// before the handler and can't be skipped by any :playlistId route. Two single-registration,
// drift-proof seams: scope (here) + target (router.param).
function agencyGate(req, res, next) {
if (!req.viaToken || req.tokenScope !== 'agency') {
return res.status(403).json({ error: 'agency token required' });
}
next();
}
module.exports = {
bearerAuth, apiTokenAuth, tokenScopeGate, requireScope, agencyGate,
hashToken, generateToken, displayPrefix, TOKEN_PREFIX,
};