mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 11:42:40 -06:00
The capability/target-restricted token model for the agency portal (#73 option B), proven before any endpoint sits on it: - 'agency' scope value is OFF the read/write/full ladder, so the existing tokenScopeGate rejects it on every public router by construction (auto-confinement, no new code). - api_token_targets join table: which playlists an agency token may act on. - agencyGate: THE single seam - agency-scope-only + (playlist in this token's allowlist AND in the bound workspace), one query enforcing target + cross-workspace isolation. - AGENCY_ROUTERS category in config/api-surface.js (mounted with agencyGate, not tokenScopeGate) - declared; router/mount land with the endpoints. Both bite-tested: spine (agency 403s on tokenScopeGate; read/write still pass) and the gate (non-designated/cross-workspace/non-agency/JWT -> 403; neutralizing the target check goes red). NARROW - not the general capability-scope system. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
143 lines
6.4 KiB
JavaScript
143 lines
6.4 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: THE single seam for capability-restricted ('agency') tokens. Mounted on the
|
|
// AGENCY_ROUTER (config/api-surface.js) in place of tokenScopeGate. Two checks, no more:
|
|
// (1) only an agency token passes (a JWT or read/write/full token is rejected);
|
|
// (2) if the request targets a playlist, that playlist must be in THIS token's
|
|
// allowlist AND in the token's bound workspace - one query enforces both the
|
|
// target restriction and cross-workspace isolation.
|
|
// Every agency capability route passes through here, so the whole primitive is proven
|
|
// at one place. Removing the api_token_targets condition makes the bite-test go red.
|
|
function agencyGate(req, res, next) {
|
|
if (!req.viaToken || req.tokenScope !== 'agency') {
|
|
return res.status(403).json({ error: 'agency token required' });
|
|
}
|
|
const playlistId = req.params.playlistId || (req.body && req.body.playlist_id);
|
|
if (playlistId) {
|
|
const ok = db.prepare(`
|
|
SELECT 1 FROM api_token_targets t
|
|
JOIN playlists p ON p.id = t.playlist_id
|
|
WHERE t.token_id = ? AND t.playlist_id = ? AND p.workspace_id = ?
|
|
`).get(req.apiToken.id, playlistId, req.jwtWorkspaceId);
|
|
if (!ok) return res.status(403).json({ error: 'playlist not in this agency token\'s allowlist' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
module.exports = {
|
|
bearerAuth, apiTokenAuth, tokenScopeGate, requireScope, agencyGate,
|
|
hashToken, generateToken, displayPrefix, TOKEN_PREFIX,
|
|
};
|