From 73ca3cf2581d76135db9655bbb1b1b61976b5bcb Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Fri, 12 Jun 2026 13:33:17 -0500 Subject: [PATCH] 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) --- server/config/api-surface.js | 50 ++++++++++++++ server/db/database.js | 3 + server/db/schema.sql | 21 ++++++ server/middleware/apiToken.js | 118 +++++++++++++++++++++++++++++++++ server/routes/device-groups.js | 5 +- server/server.js | 67 ++++++++----------- 6 files changed, 224 insertions(+), 40 deletions(-) create mode 100644 server/config/api-surface.js create mode 100644 server/middleware/apiToken.js diff --git a/server/config/api-surface.js b/server/config/api-surface.js new file mode 100644 index 0000000..b32a2f0 --- /dev/null +++ b/server/config/api-surface.js @@ -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 }; diff --git a/server/db/database.js b/server/db/database.js index 47d4861..c052f41 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -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(); diff --git a/server/db/schema.sql b/server/db/schema.sql index 1082214..36bfd22 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -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 ( diff --git a/server/middleware/apiToken.js b/server/middleware/apiToken.js new file mode 100644 index 0000000..e747e27 --- /dev/null +++ b/server/middleware/apiToken.js @@ -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, +}; diff --git a/server/routes/device-groups.js b/server/routes/device-groups.js index 5893d7c..6054c1a 100644 --- a/server/routes/device-groups.js +++ b/server/routes/device-groups.js @@ -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' }); diff --git a/server/server.js b/server/server.js index e953aa0..ca70769 100644 --- a/server/server.js +++ b/server/server.js @@ -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');