import crypto from 'node:crypto'; import type { Request } from 'express'; import { pool } from './db'; // API tokens for the Chrome sync extension. Format: fg__. // Only the SHA-256 of the secret is stored; token_id is a non-secret locator. // Mirrors the Husky template's scheme (see its ADR), trimmed to what the sync // route needs. The raw token is shown ONCE at mint time and never logged. const PREFIX = 'fg_'; const ID_BYTES = 9; // ~12 url-safe chars const SECRET_BYTES = 32; // 256-bit secret const ID_LEN = Math.ceil((ID_BYTES * 4) / 3); // base64url unpadded → 12 const TTL_DAYS = 180; export function sha256Hex(input: string): string { return crypto.createHash('sha256').update(input).digest('hex'); } export interface MintedToken { raw: string; tokenId: string; tokenHash: string; } export function mintToken(): MintedToken { const tokenId = crypto.randomBytes(ID_BYTES).toString('base64url'); const secret = crypto.randomBytes(SECRET_BYTES).toString('base64url'); return { raw: `${PREFIX}${tokenId}_${secret}`, tokenId, tokenHash: sha256Hex(secret) }; } // Fixed-length id, so parse by offset (base64url maps `/`→`_`, so first-`_` // splitting would truncate ids containing an underscore). export function parseToken(raw: string): { tokenId: string; secret: string } | null { if (!raw.startsWith(PREFIX)) return null; const rest = raw.slice(PREFIX.length); if (rest.length <= ID_LEN || rest[ID_LEN] !== '_') return null; const tokenId = rest.slice(0, ID_LEN); const secret = rest.slice(ID_LEN + 1); if (!tokenId || !secret) return null; return { tokenId, secret }; } export function bearerFromRequest(req: Request): string | null { const auth = req.headers.authorization; if (typeof auth === 'string' && auth.startsWith('Bearer ')) { const t = auth.slice('Bearer '.length).trim(); if (t) return t; } const x = req.headers['x-api-token']; if (typeof x === 'string' && x.trim()) return x.trim(); return null; } // Resolve a presented bearer token to its token_id, or null (missing / malformed // / unknown / revoked / expired). Never throws on auth failure; never logs the // token. Best-effort last_used_at bump on success. export async function resolveToken(req: Request): Promise<{ tokenId: string } | null> { const raw = bearerFromRequest(req); if (!raw) return null; const parsed = parseToken(raw); if (!parsed) return null; try { const { rows } = await pool.query<{ id: number; token_hash: string }>( `SELECT id, token_hash FROM api_tokens WHERE token_id = $1 AND revoked = false AND (expires_at IS NULL OR expires_at > NOW())`, [parsed.tokenId], ); const row = rows[0]; if (!row) return null; const presented = Buffer.from(sha256Hex(parsed.secret)); const stored = Buffer.from(row.token_hash); if (presented.length !== stored.length || !crypto.timingSafeEqual(presented, stored)) return null; pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [row.id]) .catch(err => console.error('api_tokens last_used_at update failed:', (err as Error).message)); return { tokenId: parsed.tokenId }; } catch (err) { console.error('resolveToken error:', (err as Error).message); return null; } } // Mint + persist a token. Returns the raw token to show the user once. export async function createToken(label: string): Promise { const t = mintToken(); await pool.query( `INSERT INTO api_tokens (token_id, token_hash, label, expires_at) VALUES ($1, $2, $3, NOW() + ($4 || ' days')::interval)`, [t.tokenId, t.tokenHash, label || 'sync-extension', String(TTL_DAYS)], ); return t.raw; } export interface TokenInfo { id: number; tokenId: string; label: string; createdAt: string | null; lastUsedAt: string | null; expiresAt: string | null; revoked: boolean; } const iso = (v: unknown) => (v instanceof Date ? v.toISOString() : (v as string | null)); export async function listTokens(): Promise { const { rows } = await pool.query( `SELECT id, token_id, label, created_at, last_used_at, expires_at, revoked FROM api_tokens ORDER BY created_at DESC`, ); return rows.map(r => ({ id: r.id, tokenId: r.token_id, label: r.label, createdAt: iso(r.created_at), lastUsedAt: iso(r.last_used_at), expiresAt: iso(r.expires_at), revoked: r.revoked, })); } export async function revokeToken(id: number): Promise { await pool.query(`UPDATE api_tokens SET revoked = true WHERE id = $1`, [id]); }