import { createHash, randomBytes } from "node:crypto"; import { db, filter, findFirst, orNull, type RecordModel } from "./db"; import { toUser } 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: string; userId: string; name: string; hint: string; permission: TokenPermission; allRepos: boolean; repos: { id: string; fullName: string }[]; expiresAt: number | null; expired: boolean; createdAt: number; lastUsedAt: number | null; }; const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); /** The repositories a token or grant lists, with `expand: "repos.owner"`. */ export function coveredRepos(r: RecordModel) { if (r.all_repos) return []; return ((r.expand?.repos ?? []) as RecordModel[]) .map((p) => ({ id: p.id, fullName: `${p.expand?.owner?.username}/${p.name}` })) .sort((a, b) => a.fullName.localeCompare(b.fullName)); } function toToken(r: RecordModel): ApiToken { return { id: r.id, userId: r.user, name: r.name, hint: r.hint, permission: r.permission, allRepos: r.all_repos, repos: coveredRepos(r), expiresAt: orNull(r.expires_at), expired: !!r.expires_at && r.expires_at < Date.now(), createdAt: r.created_at, lastUsedAt: orNull(r.last_used_at), }; } const EXPAND = "repos.owner"; export class TokenError extends Error {} /** Creates a token and returns its secret value; only its hash is stored. */ export async function createToken( userId: string, input: { name: string; permission: TokenPermission; expiresInDays: number | null; repoIds: string[] | 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(); await (await db()).collection("api_tokens").create({ user: userId, name, token_hash: sha256(secret), hint: secret.slice(-4), permission: input.permission, all_repos: !input.repoIds, repos: input.repoIds ?? [], expires_at: input.expiresInDays ? now + input.expiresInDays * 86400e3 : 0, created_at: now, }); return secret; } export async function listTokens(userId: string) { const rows = await (await db()) .collection("api_tokens") .getFullList({ filter: filter("user = {:userId}", { userId }), sort: "-created_at", expand: EXPAND }); return rows.map(toToken); } export async function deleteToken(userId: string, tokenId: string) { const token = await findFirst("api_tokens", filter("id = {:tokenId} && user = {:userId}", { tokenId, userId }), { fields: "id" }); if (token) await (await db()).collection("api_tokens").delete(token.id); } /** Resolves a secret token to its owner, or null if unknown or expired. */ export async function authenticateToken(secret: string) { if (!secret.startsWith(TOKEN_PREFIX) && !secret.startsWith(LEGACY_TOKEN_PREFIX)) return null; const row = await findFirst("api_tokens", filter("token_hash = {:hash}", { hash: sha256(secret) }), { expand: `user,${EXPAND}` }); const user = row?.expand?.user as RecordModel | undefined; if (!row || !user || (row.expires_at && row.expires_at < Date.now())) return null; await (await db()).collection("api_tokens").update(row.id, { last_used_at: Date.now() }); return { user: toUser(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 async 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 (await authenticateToken(password)) ?? "invalid"; } /** What a token may reach. OAuth grants for agents (lib/oauth.ts) have the same shape. */ export type TokenScope = Pick; export function tokenCovers(token: TokenScope, repoId: string, needWrite: boolean) { if (needWrite && token.permission !== "write") return false; return token.allRepos || token.repos.some((r) => r.id === repoId); }