diff --git a/packages/sync-server/migrations/1777334400000-create-api-tokens.js b/packages/sync-server/migrations/1777334400000-create-api-tokens.js new file mode 100644 index 0000000000..f0eb842a2b --- /dev/null +++ b/packages/sync-server/migrations/1777334400000-create-api-tokens.js @@ -0,0 +1,52 @@ +import { getAccountDb } from '../src/account-db'; + +export const up = async function () { + const accountDb = getAccountDb(); + + accountDb.transaction(() => { + accountDb.exec( + ` + CREATE TABLE api_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + token_hash TEXT NOT NULL, + token_prefix TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + expires_at INTEGER NOT NULL, + enabled INTEGER DEFAULT 1, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE api_token_budgets ( + token_id TEXT NOT NULL, + file_id TEXT NOT NULL, + PRIMARY KEY (token_id, file_id), + FOREIGN KEY (token_id) REFERENCES api_tokens(id) ON DELETE CASCADE, + FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE + ); + + CREATE INDEX idx_api_tokens_user_id ON api_tokens(user_id); + CREATE INDEX idx_api_tokens_prefix ON api_tokens(token_prefix); + CREATE INDEX idx_api_token_budgets_token_id ON api_token_budgets(token_id); + `, + ); + }); +}; + +export const down = async function () { + const accountDb = getAccountDb(); + + accountDb.transaction(() => { + accountDb.exec( + ` + DROP INDEX IF EXISTS idx_api_token_budgets_token_id; + DROP INDEX IF EXISTS idx_api_tokens_prefix; + DROP INDEX IF EXISTS idx_api_tokens_user_id; + DROP TABLE IF EXISTS api_token_budgets; + DROP TABLE IF EXISTS api_tokens; + `, + ); + }); +}; diff --git a/packages/sync-server/src/services/api-token-service.ts b/packages/sync-server/src/services/api-token-service.ts new file mode 100644 index 0000000000..e476ad360e --- /dev/null +++ b/packages/sync-server/src/services/api-token-service.ts @@ -0,0 +1,296 @@ +import crypto from 'crypto'; + +import * as bcrypt from 'bcrypt'; + +import { getAccountDb } from '#account-db'; +import { TOKEN_EXPIRATION_NEVER } from '#util/validate-user'; + +// ============================================ +// Database Row Types (internal use) +// ============================================ + +/** Raw row from api_tokens table */ +type ApiTokenRow = { + id: string; + user_id: string; + name: string; + token_hash: string; + token_prefix: string; + created_at: number; + last_used_at: number | null; + expires_at: number; + enabled: number; // SQLite stores booleans as 0/1 +}; + +/** Raw row from api_token_budgets table */ +type ApiTokenBudgetRow = { + token_id: string; + file_id: string; +}; + +/** Database wrapper type */ +type WrappedDatabase = { + all(sql: string, params?: unknown[]): T[]; + first(sql: string, params?: unknown[]): T | null; + mutate( + sql: string, + params?: unknown[], + ): { changes: number; insertId: number | bigint }; + transaction(fn: () => T): T; +}; + +// ============================================ +// Public API Types (exported) +// ============================================ + +/** Result returned when creating a new token */ +export type CreateTokenResult = { + id: string; + token: string; // Only exposed at creation time + prefix: string; + name: string; + budgetIds: string[]; + createdAt: number; + expiresAt: number; +}; + +/** Token information returned when listing tokens */ +export type TokenListItem = { + id: string; + name: string; + prefix: string; + createdAt: number; + lastUsedAt: number | null; + expiresAt: number; + enabled: boolean; + budgetIds: string[]; +}; + +/** API Token Service interface */ +export type ApiTokenService = { + createToken( + userId: string, + name: string, + budgetIds?: string[], + expiresAt?: number | null, + ): Promise; + listTokens(userId: string): TokenListItem[]; + revokeToken(tokenId: string, userId: string): boolean; + setTokenEnabled(tokenId: string, userId: string, enabled: boolean): boolean; + getTokenBudgets(tokenId: string): string[]; +}; + +// ============================================ +// Constants +// ============================================ + +const TOKEN_PREFIX = 'act_' as const; +const TOKEN_LENGTH = 32 as const; +// base64 produces 4 chars per 3 bytes, then ~3% are filtered out as non-alphanumeric +// 32 chars * (3 bytes / 4 chars) * 1.34 safety margin = 32 bytes → 43 base64 chars → <1e-8 failure rate +const TOKEN_RANDOM_BYTES = Math.ceil(((TOKEN_LENGTH * 3) / 4) * 1.34); +const BCRYPT_ROUNDS = 12 as const; + +// ============================================ +// Helper Functions +// ============================================ + +/** + * Generate a secure random API token + * Format: act_<32 random chars> + */ +function generateToken(): string { + let randomPart = ''; + + // Loop until we have enough alphanumeric characters (extremely rare to need multiple iterations) + while (randomPart.length < TOKEN_LENGTH) { + const bytes = crypto.randomBytes(TOKEN_RANDOM_BYTES); + randomPart += bytes.toString('base64url').replace(/[^a-zA-Z0-9]/g, ''); + } + + return `${TOKEN_PREFIX}${randomPart.slice(0, TOKEN_LENGTH)}`; +} + +/** + * Extract the prefix portion of a token for lookup + * @param token - The full token + * @returns The prefix (first 12 chars: "act_" + 8 chars) + */ +function extractPrefix(token: string): string { + return token.slice(0, 12); +} + +/** + * Hash a token using bcrypt + * @param token - The token to hash + * @returns The bcrypt hash + */ +async function hashToken(token: string): Promise { + return bcrypt.hash(token, BCRYPT_ROUNDS); +} + +// ============================================ +// Service Implementation +// ============================================ + +export const apiTokenService: ApiTokenService = { + /** + * Create a new API token for a user + * @param userId - The user ID + * @param name - A friendly name for the token + * @param budgetIds - Array of budget/file IDs to scope the token to (empty = all) + * @param expiresAt - Unix timestamp for expiration, or null for never + */ + async createToken( + userId: string, + name: string, + budgetIds: string[] = [], + expiresAt: number | null = null, + ): Promise { + const accountDb = getAccountDb() as WrappedDatabase; + const token = generateToken(); + const tokenHash = await hashToken(token); + const tokenPrefix = extractPrefix(token); + const tokenId = crypto.randomUUID(); + const createdAt = Math.floor(Date.now() / 1000); + const expiration = expiresAt ?? TOKEN_EXPIRATION_NEVER; + + accountDb.transaction(() => { + accountDb.mutate( + `INSERT INTO api_tokens (id, user_id, name, token_hash, token_prefix, created_at, expires_at, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, 1)`, + [tokenId, userId, name, tokenHash, tokenPrefix, createdAt, expiration], + ); + + // Add budget scopes if specified + for (const budgetId of budgetIds) { + accountDb.mutate( + `INSERT INTO api_token_budgets (token_id, file_id) VALUES (?, ?)`, + [tokenId, budgetId], + ); + } + }); + + return { + id: tokenId, + token, // Only returned on creation + prefix: tokenPrefix, + name, + budgetIds, + createdAt, + expiresAt: expiration, + }; + }, + + /** + * List all API tokens for a user (without revealing token values) + * @param userId - The user ID + */ + listTokens(userId: string): TokenListItem[] { + const accountDb = getAccountDb() as WrappedDatabase; + + const tokens = accountDb.all( + `SELECT id, name, token_prefix, created_at, last_used_at, expires_at, enabled + FROM api_tokens + WHERE user_id = ? + ORDER BY created_at DESC`, + [userId], + ); + + // Early return if no tokens + if (tokens.length === 0) { + return []; + } + + // Fetch all budgets for all tokens in a single query (avoids N+1) + const tokenIds = tokens.map(t => t.id); + const placeholders = tokenIds.map(() => '?').join(','); + const allBudgets = accountDb.all( + `SELECT token_id, file_id FROM api_token_budgets WHERE token_id IN (${placeholders})`, + tokenIds, + ); + + // Build a lookup map: token_id -> array of file_ids + const budgetsByToken = new Map(); + for (const budget of allBudgets) { + const existing = budgetsByToken.get(budget.token_id) || []; + existing.push(budget.file_id); + budgetsByToken.set(budget.token_id, existing); + } + + return tokens.map(token => ({ + id: token.id, + name: token.name, + prefix: token.token_prefix, + createdAt: token.created_at, + lastUsedAt: token.last_used_at, + expiresAt: token.expires_at, + enabled: Boolean(token.enabled), + budgetIds: budgetsByToken.get(token.id) || [], + })); + }, + + /** + * Revoke (delete) an API token + * @param tokenId - The token ID + * @param userId - The user ID (for authorization) + * @returns True if the token was deleted + */ + revokeToken(tokenId: string, userId: string): boolean { + const accountDb = getAccountDb() as WrappedDatabase; + + // Verify ownership + const token = accountDb.first<{ id: string }>( + `SELECT id FROM api_tokens WHERE id = ? AND user_id = ?`, + [tokenId, userId], + ); + + if (!token) { + return false; + } + + accountDb.transaction(() => { + // Delete budget scopes first (cascade should handle this, but be explicit) + accountDb.mutate(`DELETE FROM api_token_budgets WHERE token_id = ?`, [ + tokenId, + ]); + accountDb.mutate(`DELETE FROM api_tokens WHERE id = ?`, [tokenId]); + }); + + return true; + }, + + /** + * Enable or disable an API token + * @param tokenId - The token ID + * @param userId - The user ID (for authorization) + * @param enabled - Whether to enable or disable + * @returns True if the token was updated + */ + setTokenEnabled(tokenId: string, userId: string, enabled: boolean): boolean { + const accountDb = getAccountDb() as WrappedDatabase; + + const result = accountDb.mutate( + `UPDATE api_tokens SET enabled = ? WHERE id = ? AND user_id = ?`, + [enabled ? 1 : 0, tokenId, userId], + ); + + return result.changes > 0; + }, + + /** + * Get the budget IDs that a token has access to + * @param tokenId - The token ID + * @returns Array of budget/file IDs (empty means all user's budgets) + */ + getTokenBudgets(tokenId: string): string[] { + const accountDb = getAccountDb() as WrappedDatabase; + + const budgetRows = accountDb.all( + `SELECT file_id FROM api_token_budgets WHERE token_id = ?`, + [tokenId], + ); + + return budgetRows.map(row => row.file_id); + }, +};