Files
sencho/backend/src/routes/apiTokens.ts
T
Anso 50e64b058b refactor(backend): extract 8 low-blast-radius route groups into routers (phase 4a-1) (#734)
First slice of Phase 4 (route extraction). Pulls 8 well-tested, mostly
independent route groups out of index.ts into focused Router files. No
behavior change; every handler body moves verbatim.

New route files under backend/src/routes/:
- meta.ts            /api/health, /api/meta (mounted before authGate)
- license.ts         /api/license/* + /api/system/update,
                     exports scheduleLocalUpdate for the fleet route
- permissions.ts     /api/permissions/me
- convert.ts         POST /api/convert
- alerts.ts          /api/alerts/*
- labels.ts          /api/labels/* + PUT /api/stacks/:name/labels
                     (exported as stackLabelsRouter)
- apiTokens.ts       /api/api-tokens/*
- auditLog.ts        /api/audit-log/*

Shared helper lifts:
- helpers/cacheInvalidation.ts: invalidateNodeCaches()
- middleware/tierGates.ts: requireBody (was inline in index.ts)
- utils/errors.ts: isSqliteUniqueViolation (was inline in index.ts)
- middleware/apiTokenScope.ts: rejectApiTokenScope() helper (new)
- utils/csv.ts: escapeCsvField() (new)

index.ts drops from ~7520 to ~6775 lines and now mounts the routers right
after enforceApiTokenScope. The remote proxy and fleet/auth/webhooks/users
routes remain inline in index.ts pending later Phase 4 slices.

Code review fixes: rejectApiTokenScope helper replaces duplicated
`if (req.apiTokenScope) 403 SCOPE_DENIED` blocks in apiTokens.ts and
license.ts; escapeCsvField replaces the inline CSV escape in auditLog.ts.
2026-04-23 20:24:37 -04:00

132 lines
5.5 KiB
TypeScript

import { Router, type Request, type Response } from 'express';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { DatabaseService, type ApiTokenScope } from '../services/DatabaseService';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requireAdmiral } from '../middleware/tierGates';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
import { isDebugEnabled } from '../utils/debug';
// JWT ceiling exceeds the longest user-selectable expiry (365d) so the DB
// check (expires_at) is always the tighter bound.
const API_TOKEN_JWT_CEILING = '400d';
const MAX_ACTIVE_TOKENS_PER_USER = 25;
const API_TOKEN_SCOPE_MESSAGE = 'API tokens cannot manage other API tokens.';
export const apiTokensRouter = Router();
apiTokensRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, API_TOKEN_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const { name, scope, expires_in } = req.body;
if (!name || typeof name !== 'string' || !name.trim()) {
res.status(400).json({ error: 'Token name is required.' });
return;
}
if (name.trim().length > 100) {
res.status(400).json({ error: 'Token name must be 100 characters or fewer.' });
return;
}
const validScopes = ['read-only', 'deploy-only', 'full-admin'];
if (!scope || !validScopes.includes(scope)) {
res.status(400).json({ error: `Scope must be one of: ${validScopes.join(', ')}` });
return;
}
const validExpiry = [30, 60, 90, 365];
if (expires_in !== undefined && expires_in !== null && !validExpiry.includes(expires_in)) {
res.status(400).json({ error: `expires_in must be one of: ${validExpiry.join(', ')} (days), or null for no expiry.` });
return;
}
const expiresAt = typeof expires_in === 'number' ? Date.now() + expires_in * 24 * 60 * 60 * 1000 : null;
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) {
res.status(500).json({ error: 'No JWT secret configured.' });
return;
}
const db = DatabaseService.getInstance();
const user = db.getUserByUsername(req.user!.username);
if (!user) {
res.status(500).json({ error: 'User not found.' });
return;
}
const activeCount = db.getActiveApiTokenCountByUser(user.id);
if (activeCount >= MAX_ACTIVE_TOKENS_PER_USER) {
res.status(400).json({ error: `Maximum of ${MAX_ACTIVE_TOKENS_PER_USER} active API tokens per user.` });
return;
}
if (db.getActiveApiTokenByNameAndUser(name.trim(), user.id)) {
res.status(409).json({ error: 'An active token with this name already exists.' });
return;
}
const rawToken = jwt.sign({ scope: 'api_token', sub: user.username, jti: crypto.randomUUID() }, jwtSecret, { expiresIn: API_TOKEN_JWT_CEILING });
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const id = db.addApiToken({
token_hash: tokenHash,
name: name.trim(),
scope: scope as ApiTokenScope,
user_id: user.id,
created_at: Date.now(),
expires_at: expiresAt,
});
if (isDebugEnabled()) console.log('[ApiTokens:diag] Token created:', { name: name.trim(), scope, expires_in, user: req.user!.username });
res.status(201).json({ id, token: rawToken });
} catch (error) {
console.error('[ApiTokens] Create error:', error);
res.status(500).json({ error: 'Failed to create API token' });
}
});
apiTokensRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, API_TOKEN_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const user = DatabaseService.getInstance().getUserByUsername(req.user!.username);
if (!user) { res.status(500).json({ error: 'User not found.' }); return; }
const tokens = DatabaseService.getInstance().getApiTokensByUser(user.id);
// Never expose token hashes to the client.
const sanitized = tokens.map(({ token_hash: _hash, ...rest }) => rest);
res.json(sanitized);
} catch (error) {
console.error('[ApiTokens] List error:', error);
res.status(500).json({ error: 'Failed to list API tokens' });
}
});
apiTokensRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, API_TOKEN_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid token ID.' }); return; }
const apiToken = DatabaseService.getInstance().getApiTokenById(id);
if (!apiToken) { res.status(404).json({ error: 'API token not found.' }); return; }
const user = DatabaseService.getInstance().getUserByUsername(req.user!.username);
if (!user || apiToken.user_id !== user.id) {
res.status(403).json({ error: 'You can only revoke your own tokens.' });
return;
}
DatabaseService.getInstance().revokeApiToken(id);
if (isDebugEnabled()) console.log('[ApiTokens:diag] Token revoked:', { id, name: apiToken.name, user: req.user!.username });
res.json({ success: true });
} catch (error) {
console.error('[ApiTokens] Revoke error:', error);
res.status(500).json({ error: 'Failed to revoke API token' });
}
});