import { createHash, randomBytes } from "node:crypto"; import { db, transaction } from "./db"; import { getUserById } from "./users"; export const TOKEN_PREFIX = "vec_"; /** Prefix of tokens issued before the rename to Vectrum; still accepted. */ const LEGACY_TOKEN_PREFIX = "mng_"; export type TokenPermission = "read" | "write"; export type ApiToken = { id: number; userId: number; name: string; hint: string; permission: TokenPermission; allRepos: boolean; repos: { id: number; fullName: string }[]; expiresAt: number | null; expired: boolean; createdAt: number; lastUsedAt: number | null; }; type TokenRow = { id: number; user_id: number; name: string; hint: string; permission: TokenPermission; all_repos: number; expires_at: number | null; created_at: number; last_used_at: number | null; }; const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); function tokenRepos(tokenId: number) { return db .prepare( `SELECT r.id, u.username || '/' || r.name AS fullName FROM api_token_repos t JOIN repos r ON r.id = t.repo_id JOIN users u ON u.id = r.owner_id WHERE t.token_id = ? ORDER BY fullName`, ) .all(tokenId) as { id: number; fullName: string }[]; } function toToken(r: TokenRow): ApiToken { return { id: r.id, userId: r.user_id, name: r.name, hint: r.hint, permission: r.permission, allRepos: r.all_repos === 1, repos: r.all_repos === 1 ? [] : tokenRepos(r.id), expiresAt: r.expires_at, expired: r.expires_at !== null && r.expires_at < Date.now(), createdAt: r.created_at, lastUsedAt: r.last_used_at, }; } export class TokenError extends Error {} /** Creates a token and returns its secret value; only its hash is stored. */ export function createToken( userId: number, input: { name: string; permission: TokenPermission; expiresInDays: number | null; repoIds: number[] | null }, ) { const name = input.name.trim().slice(0, 100); if (!name) throw new TokenError("Give your token a name so you can recognize it later."); if (input.repoIds && input.repoIds.length === 0) throw new TokenError("Select at least one repository."); const secret = `${TOKEN_PREFIX}${randomBytes(30).toString("base64url")}`; const now = Date.now(); transaction(() => { const res = db .prepare( `INSERT INTO api_tokens (user_id, name, token_hash, hint, permission, all_repos, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( userId, name, sha256(secret), secret.slice(-4), input.permission, input.repoIds ? 0 : 1, input.expiresInDays ? now + input.expiresInDays * 86400e3 : null, now, ); for (const repoId of input.repoIds ?? []) { db.prepare("INSERT INTO api_token_repos (token_id, repo_id) VALUES (?, ?)").run(res.lastInsertRowid, repoId); } }); return secret; } export function listTokens(userId: number) { return (db.prepare("SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC").all(userId) as TokenRow[]).map( toToken, ); } export function deleteToken(userId: number, tokenId: number) { db.prepare("DELETE FROM api_tokens WHERE id = ? AND user_id = ?").run(tokenId, userId); } /** Resolves a secret token to its owner, or null if unknown or expired. */ export function authenticateToken(secret: string) { if (!secret.startsWith(TOKEN_PREFIX) && !secret.startsWith(LEGACY_TOKEN_PREFIX)) return null; const row = db.prepare("SELECT * FROM api_tokens WHERE token_hash = ?").get(sha256(secret)) as TokenRow | undefined; if (!row || (row.expires_at && row.expires_at < Date.now())) return null; const user = getUserById(row.user_id); if (!user) return null; db.prepare("UPDATE api_tokens SET last_used_at = ? WHERE id = ?").run(Date.now(), row.id); return { user, token: toToken(row) }; } /** * Credentials of git and registry requests: HTTP Basic auth with an API token as the password * (the username is ignored). Null without credentials, "invalid" for an unknown or expired token. */ export function basicAuthToken(request: Request) { const header = request.headers.get("authorization"); if (!header?.startsWith("Basic ")) return null; const decoded = Buffer.from(header.slice(6), "base64").toString("utf8"); const password = decoded.slice(decoded.indexOf(":") + 1); return authenticateToken(password) ?? "invalid"; } export function tokenCovers(token: ApiToken, repoId: number, needWrite: boolean) { if (needWrite && token.permission !== "write") return false; return token.allRepos || token.repos.some((r) => r.id === repoId); }