feat(api): scoped API token foundation + secure-by-exclusion mounts

Introduce the public API's token layer and make the router partition data-driven.

- api_tokens table: SHA-256 hashed secret, st_ prefix, workspace-bound, read/write/full scope.
- middleware/apiToken.js: bearerAuth front door (Bearer st_ -> token auth, else the
  unchanged requireAuth); apiTokenAuth acts as the owner with platform powers stripped
  to 'user' and the workspace binding made authoritative (X-Workspace-Id ignored);
  tokenScopeGate (read=GET, write=mutations) + requireScope('full') for commands.
- config/api-surface.js: single source of truth for the PUBLIC (token front door) vs
  JWT-ONLY (requireAuth) router partition. server.js mounts from these lists so the
  mount list and the partition firewall test cannot drift.
- device-groups: operational group commands (reboot/shutdown) require the full scope.

A Bearer st_ token fails jwt.verify on the JWT-only routers (401), so privileged
surfaces (admin, workspaces, ai, provision, white-label) are unreachable by exclusion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-12 13:33:17 -05:00 committed by screentinker
parent 300d331562
commit 73ca3cf258
6 changed files with 224 additions and 40 deletions

View file

@ -0,0 +1,50 @@
'use strict';
// SINGLE SOURCE OF TRUTH for the API router partition.
//
// server.js mounts from these two lists; test/api.test.js (the partition firewall
// test) asserts against the SAME lists. Because both read this one file, the mount
// list and the test cannot drift: add a router to PUBLIC_ROUTERS and it gets the
// token front door AND the firewall test covers it; the day a JWT-only router stops
// returning 401 to a `Bearer st_` token (e.g. someone gives it the token door), CI
// fails. This is the firewall-rule-as-code.
//
// PUBLIC_ROUTERS - token-reachable. Mounted with the bearerAuth front door +
// resolveTenancy + tokenScopeGate. A scoped API token AND a JWT
// session both reach these.
// JWT_ONLY_ROUTERS - requireAuth only (no token front door). A `Bearer st_` token
// fails jwt.verify -> 401, so these are unreachable by any token
// (secure by exclusion). Privileged surfaces live here.
//
// Per-entry flags:
// renderBypass: also exposes a public GET /:id/render (device render) that skips auth.
// tenancy: JWT-only router also runs resolveTenancy (acts on the caller's active
// workspace). Routers without it target a workspace by URL/body param
// and are gated per-handler (e.g. canAdminWorkspace).
const PUBLIC_ROUTERS = [
{ path: '/api/devices', mod: './routes/devices' },
{ path: '/api/content', mod: './routes/content' },
{ path: '/api/folders', mod: './routes/folders' },
{ path: '/api/assignments', mod: './routes/assignments' },
{ path: '/api/layouts', mod: './routes/layouts' },
{ path: '/api/widgets', mod: './routes/widgets', renderBypass: true },
{ path: '/api/schedules', mod: './routes/schedules' },
{ path: '/api/walls', mod: './routes/video-walls' },
{ path: '/api/reports', mod: './routes/reports' },
{ path: '/api/groups', mod: './routes/device-groups' },
{ path: '/api/playlists', mod: './routes/playlists' },
{ path: '/api/activity', mod: './routes/activity' },
{ path: '/api/kiosk', mod: './routes/kiosk', renderBypass: true },
];
const JWT_ONLY_ROUTERS = [
{ path: '/api/ai', mod: './routes/ai', tenancy: true },
{ path: '/api/provision', mod: './routes/provisioning', tenancy: true },
{ path: '/api/teams', mod: './routes/teams', tenancy: true },
{ path: '/api/white-label', mod: './routes/white-label', tenancy: true },
{ path: '/api/workspaces', mod: './routes/workspaces' },
{ path: '/api/admin', mod: './routes/admin' },
];
module.exports = { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS };

View file

@ -216,6 +216,9 @@ if (_migApplied > 0) console.log(`[migrate] applied ${_migApplied} new column mi
// self-applies on upgrade). Record it in schema_migrations for observability.
try { db.prepare("INSERT OR IGNORE INTO schema_migrations (id) VALUES ('phase7_playlist_item_schedules')").run(); } catch { /* schema_migrations not ready yet */ }
// Public API tokens: api_tokens table is created idempotently by schema.sql.
try { db.prepare("INSERT OR IGNORE INTO schema_migrations (id) VALUES ('phase8_api_tokens')").run(); } catch { /* schema_migrations not ready yet */ }
// Fix assignments table: make content_id nullable (SQLite requires table rebuild)
try {
const colInfo = db.prepare("PRAGMA table_info(assignments)").all();

View file

@ -500,6 +500,27 @@ CREATE TABLE IF NOT EXISTS player_debug_logs (
CREATE INDEX IF NOT EXISTS idx_player_debug_fingerprint ON player_debug_logs(error_fingerprint);
CREATE INDEX IF NOT EXISTS idx_player_debug_created_at ON player_debug_logs(created_at);
-- ===================== API TOKENS (public API, Phase 1) =====================
-- Scoped personal access tokens for the public API. The full token (st_...) is
-- shown to its owner exactly once at creation; only its SHA-256 hash is stored.
-- A token is bound to ONE workspace and a scope (read|write|full) and always acts
-- with the owner's workspace role - never platform/cross-org powers (apiTokenAuth
-- forces the effective platform role to 'user').
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE, -- SHA-256 hex of the full token
prefix TEXT NOT NULL, -- e.g. 'st_a1b2c3d4' (display only)
name TEXT NOT NULL, -- user-given label
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
scope TEXT NOT NULL DEFAULT 'read', -- 'read' | 'write' | 'full'
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
last_used_at INTEGER,
revoked_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
-- ===================== SCHEMA MIGRATIONS =====================
CREATE TABLE IF NOT EXISTS schema_migrations (

View file

@ -0,0 +1,118 @@
// 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();
};
}
module.exports = {
bearerAuth, apiTokenAuth, tokenScopeGate, requireScope,
hashToken, generateToken, displayPrefix, TOKEN_PREFIX,
};

View file

@ -5,6 +5,9 @@ const { db } = require('../db/database');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Phase 2.2i: workspace-aware access. Same pattern as devices/content/widgets.
const { accessContext } = require('../lib/tenancy');
// #public-api: operational fleet commands (reboot/shutdown/...) need the 'full' token
// scope. No-op for JWT sessions; for tokens a read/write scope is rejected.
const { requireScope } = require('../middleware/apiToken');
const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
const ALLOWED_COMMANDS = ['screen_on', 'screen_off', 'launch', 'update', 'reboot', 'shutdown'];
@ -288,7 +291,7 @@ router.post('/:id/assign-playlist', requireGroupWrite, (req, res) => {
});
// Send command to all devices in a group (reboot/shutdown/screen on/off etc.)
router.post('/:id/command', requireGroupWrite, (req, res) => {
router.post('/:id/command', requireScope('full'), requireGroupWrite, (req, res) => {
const { type, payload } = req.body;
if (!type) return res.status(400).json({ error: 'command type required' });
if (!ALLOWED_COMMANDS.includes(type)) return res.status(400).json({ error: 'invalid command type' });

View file

@ -422,6 +422,8 @@ app.get('/api/content/:id/thumbnail', (req, res) => {
// yet (they still filter by user_id); 2.2 will migrate them one route at a time.
const { requireAuth } = require('./middleware/auth');
const { resolveTenancy } = require('./lib/tenancy');
// Public API token front door (Phase 1). Attached ONLY to the public routers below.
const { bearerAuth, tokenScopeGate } = require('./middleware/apiToken');
// activityLogger wraps res.json on every subsequent route to auto-log
// successful POST/PUT/DELETE mutations. Mount it BEFORE the workspace routes
@ -432,47 +434,34 @@ const { resolveTenancy } = require('./lib/tenancy');
const { activityLogger } = require('./services/activity');
app.use(activityLogger);
// /api/workspaces: management endpoints that operate on a target workspace
// (URL param), not the caller's currently active one. Hence requireAuth only,
// no resolveTenancy. Permission gated per-handler via canAdminWorkspace().
app.use('/api/workspaces', requireAuth, require('./routes/workspaces'));
// #public-api Phase 1: the router partition is data-driven from config/api-surface.js
// so server.js and the partition firewall test (test/api.test.js) read the SAME list
// and cannot drift. PUBLIC routers get the token front door (bearerAuth + resolveTenancy
// + tokenScopeGate); JWT-ONLY routers keep requireAuth, so a Bearer st_... token fails
// their jwt.verify and is unreachable (secure by exclusion). Tokens act as a workspace
// member with platform powers stripped, so in-handler ELEVATED/PLATFORM checks (e.g.
// GET /api/devices/unassigned) still deny.
const { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS } = require('./config/api-surface');
// /api/admin: admin-provisioned user creation (#10). Like /api/workspaces it
// targets a workspace by body param (not the caller's active one), so
// requireAuth only - per-handler canAdminWorkspace() gates it. Mounted after
// activityLogger so creations are auto-logged.
app.use('/api/admin', requireAuth, require('./routes/admin'));
app.use('/api/devices', requireAuth, resolveTenancy, require('./routes/devices'));
app.use('/api/content', requireAuth, resolveTenancy, require('./routes/content'));
app.use('/api/ai', requireAuth, resolveTenancy, require('./routes/ai')); // #41 AI design (BYOK)
app.use('/api/folders', requireAuth, resolveTenancy, require('./routes/folders'));
app.use('/api/assignments', requireAuth, resolveTenancy, require('./routes/assignments'));
app.use('/api/provision', requireAuth, resolveTenancy, require('./routes/provisioning'));
app.use('/api/layouts', requireAuth, resolveTenancy, require('./routes/layouts'));
// Widget render is public (accessed by devices)
// Public device-render endpoints + the memory-heavy preview limiter must be registered
// BEFORE their parent router mount so the _skipAuth bypass / the limiter fire first.
app.get('/api/widgets/:id/render', (req, res, next) => { req._skipAuth = true; next(); });
// Rate limit preview endpoint — it inlines user content as base64 which is memory-intensive
app.use('/api/widgets/preview', rateLimit(60000, 30));
app.use('/api/widgets', (req, res, next) => { if (req._skipAuth) return next(); requireAuth(req, res, next); }, resolveTenancy, require('./routes/widgets'));
app.use('/api/schedules', requireAuth, resolveTenancy, require('./routes/schedules'));
app.use('/api/walls', requireAuth, resolveTenancy, require('./routes/video-walls'));
app.use('/api/teams', requireAuth, resolveTenancy, require('./routes/teams'));
app.use('/api/reports', requireAuth, resolveTenancy, require('./routes/reports'));
app.use('/api/groups', requireAuth, resolveTenancy, require('./routes/device-groups'));
app.use('/api/playlists', requireAuth, resolveTenancy, require('./routes/playlists'));
app.use('/api/activity', requireAuth, resolveTenancy, require('./routes/activity'));
app.use('/api/white-label', requireAuth, resolveTenancy, require('./routes/white-label'));
// Kiosk render is public (accessed by devices), CRUD is protected
app.get('/api/kiosk/:id/render', (req, res, next) => {
// Let it through to the kiosk route without auth
req._skipAuth = true;
next();
});
app.use('/api/kiosk', (req, res, next) => {
if (req._skipAuth) return next();
requireAuth(req, res, next);
}, resolveTenancy, require('./routes/kiosk'));
app.use('/api/widgets/preview', rateLimit(60000, 30)); // base64 inline = memory-intensive
app.get('/api/kiosk/:id/render', (req, res, next) => { req._skipAuth = true; next(); });
for (const r of PUBLIC_ROUTERS) {
// renderBypass routers let the public /:id/render through (req._skipAuth) before bearerAuth.
const front = r.renderBypass
? (req, res, next) => { if (req._skipAuth) return next(); bearerAuth(req, res, next); }
: bearerAuth;
app.use(r.path, front, resolveTenancy, tokenScopeGate, require(r.mod));
}
for (const r of JWT_ONLY_ROUTERS) {
// tenancy routers act on the caller's active workspace; the rest (workspaces, admin)
// target a workspace by URL/body param and are gated per-handler (canAdminWorkspace).
if (r.tenancy) app.use(r.path, requireAuth, resolveTenancy, require(r.mod));
else app.use(r.path, requireAuth, require(r.mod));
}
// Frontend version hash (changes when files are modified, triggers soft reload)
const crypto = require('crypto');