import { batch, first, run, stmt } from "./db"; import { notifySecurityEvent } from "./account-security"; import { randomId, randomToken, sha256Hex } from "./crypto"; import { PAT_PREFIX } from "./auth"; import { badRequest } from "./errors"; import { assertBelowCap, CAPS } from "./limits"; import { requireRepoAccess } from "./repos"; import { DEPLOY_TOKEN_PREFIX, parseTokenPermissions } from "./token-scope"; import type { TokenPermissions } from "./token-scope"; import type { RepoRow, Viewer } from "~/lib/types"; const VISIBLE_TOKEN_CHARS = 6; const DAY_MS = 24 * 60 * 60 * 1000; /** Only the SHA-256 of a token is stored; the plaintext is returned once to the caller. */ async function mint(prefix: string): Promise<{ token: string; hash: string; visible: string }> { const token = randomToken(prefix); return { token, hash: await sha256Hex(token), visible: token.slice(0, prefix.length + 1 + VISIBLE_TOKEN_CHARS) }; } export function tokenExpiry(days: number): number | null { return days > 0 ? Date.now() + days * DAY_MS : null; } /** Expiry for a token minted by another token: never later than the parent's own expiry. */ export function cappedTokenExpiry(days: number, notAfter: number | null | undefined): number | null { const requested = tokenExpiry(days); if (notAfter === null || notAfter === undefined) return requested; return requested === null ? notAfter : Math.min(requested, notAfter); } export type TokenLineageInput = { /** The token that minted this one; it is revoked along with it (AUTH-A1). */ parentTokenId: string; /** The parent's expiry, a hard ceiling on the child's. */ notAfter: number | null; }; export type FineGrainedTokenInput = { name: string; expiresInDays: number; allRepos: boolean; repos: string[]; permissions: TokenPermissions; lineage?: TokenLineageInput; }; /** Callers must have authenticated the viewer; each selected repo must be readable by them (checked here). */ export async function createFineGrainedToken( viewer: Viewer, input: FineGrainedTokenInput, ): Promise<{ id: string; token: string; repoIds: string[]; expiresAt: number | null }> { const count = await first<{ count: number }>( "SELECT COUNT(*) AS count FROM access_tokens WHERE user_id = ?", viewer.id, ); assertBelowCap(count?.count ?? 0, CAPS.tokensPerUser, "tokens"); if (!input.allRepos && input.repos.length === 0) throw badRequest("Select at least one repository"); const repoIds = input.allRepos ? [] : await Promise.all( [...new Set(input.repos)].map(async (fullName) => { const [owner, repo] = fullName.split("/"); return (await requireRepoAccess(owner, repo, viewer, "read")).repo.id; }), ); const permissions = parseTokenPermissions(JSON.stringify(input.permissions)); const id = randomId(); const secret = await mint(PAT_PREFIX); const expiresAt = input.lineage ? cappedTokenExpiry(input.expiresInDays, input.lineage.notAfter) : tokenExpiry(input.expiresInDays); await batch([ stmt( `INSERT INTO access_tokens (id, user_id, name, token_hash, token_prefix, scopes, permissions, all_repos, expires_at, created_at, parent_token_id) VALUES (?, ?, ?, ?, ?, 'fine-grained', ?, ?, ?, ?, ?)`, id, viewer.id, input.name, secret.hash, secret.visible, JSON.stringify(permissions), input.allRepos ? 1 : 0, expiresAt, Date.now(), input.lineage?.parentTokenId ?? null, ), ...repoIds.map((repoId) => stmt("INSERT INTO access_token_repos (token_id, repo_id) VALUES (?, ?)", id, repoId)), ]); await notifySecurityEvent( viewer.id, input.lineage ? `a fine-grained personal access token named "${input.name}" was created by another of your tokens (it is revoked with it)` : `a fine-grained personal access token named "${input.name}" was created`, ); return { id, token: secret.token, repoIds, expiresAt }; } /** Callers must have checked admin access on `repo`. */ export async function createDeployToken( repo: Pick, viewer: Viewer, input: { name: string; permission: "read" | "write"; expiresInDays: number; lineage?: TokenLineageInput }, ): Promise<{ id: string; token: string; expiresAt: number | null }> { const count = await first<{ count: number }>( "SELECT COUNT(*) AS count FROM deploy_tokens WHERE repo_id = ?", repo.id, ); assertBelowCap(count?.count ?? 0, CAPS.deployTokensPerRepo, "deploy tokens"); const secret = await mint(DEPLOY_TOKEN_PREFIX); const id = randomId(); const expiresAt = input.lineage ? cappedTokenExpiry(input.expiresInDays, input.lineage.notAfter) : tokenExpiry(input.expiresInDays); await run( `INSERT INTO deploy_tokens (id, repo_id, name, token_hash, token_prefix, permission, created_by, expires_at, created_at, parent_token_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, repo.id, input.name, secret.hash, secret.visible, input.permission, viewer.id, expiresAt, Date.now(), input.lineage?.parentTokenId ?? null, ); return { id, token: secret.token, expiresAt }; } /** Returns whether a token was revoked. Callers must have checked admin access on the repo. */ export async function revokeDeployToken(repoId: string, id: string): Promise { const result = await run("DELETE FROM deploy_tokens WHERE id = ? AND repo_id = ?", id, repoId); return result.meta.changes > 0; }