Files

111 lines
4.5 KiB
TypeScript

import crypto from 'node:crypto';
import type { Request } from 'express';
import { pool } from './db';
// API tokens for the Chrome sync extension. Format: fg_<token_id>_<secret>.
// 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<string> {
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<TokenInfo[]> {
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<void> {
await pool.query(`UPDATE api_tokens SET revoked = true WHERE id = $1`, [id]);
}