111 lines
4.5 KiB
TypeScript
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]);
|
||
|
|
}
|