mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
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:
parent
300d331562
commit
73ca3cf258
50
server/config/api-surface.js
Normal file
50
server/config/api-surface.js
Normal 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 };
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
118
server/middleware/apiToken.js
Normal file
118
server/middleware/apiToken.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue