import { assertAccountActive } from "./account-disable"; import { accountDb, repoDb, stmt } from "./scoped-db"; import { forgetLocator, registerLocator } from "./scoped-db/locators"; import { accountToken } from "./account-scope"; import { notifySecurityEvent } from "./account-security"; import { randomId, sha256Hex, toHex } from "./crypto"; import { PAT_PREFIX } from "./auth"; import { badRequest } from "./errors"; import { assertBelowCap, CAPS } from "./limits"; import { recordDeployToken } from "./deploy-token-index"; 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. The token embeds the id * of the account or repository that stores it, so lookups skip the locator. */ async function mint(prefix: string, scopeId: string): Promise<{ token: string; hash: string; visible: string }> { const token = accountToken(prefix, scopeId, toHex(crypto.getRandomValues(new Uint8Array(20)))); 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 db = accountDb(viewer.id); const count = await db.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(); await assertAccountActive(viewer.id); const secret = await mint(PAT_PREFIX, viewer.id); const expiresAt = input.lineage ? cappedTokenExpiry(input.expiresInDays, input.lineage.notAfter) : tokenExpiry(input.expiresInDays); await db.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 Promise.all([ registerLocator("access_token", secret.hash, viewer.id), registerLocator("access_token_id", id, viewer.id), ]); 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 db = repoDb(repo.id); const count = await db.first<{ count: number }>( "SELECT COUNT(*) AS count FROM deploy_tokens WHERE repo_id = ?", repo.id, ); assertBelowCap(count?.count ?? 0, CAPS.deployTokensPerRepo, "deploy tokens"); await assertAccountActive(viewer.id); const secret = await mint(DEPLOY_TOKEN_PREFIX, repo.id); const id = randomId(); const expiresAt = input.lineage ? cappedTokenExpiry(input.expiresInDays, input.lineage.notAfter) : tokenExpiry(input.expiresInDays); await db.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, ); await registerLocator("deploy_token", secret.hash, repo.id); await recordDeployToken(viewer.id, { id, repoId: repo.id, parentTokenId: 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 db = repoDb(repoId); const row = await db.first<{ token_hash: string }>( "SELECT token_hash FROM deploy_tokens WHERE id = ? AND repo_id = ?", id, repoId, ); if (!row) return false; await db.run("DELETE FROM deploy_tokens WHERE id = ? AND repo_id = ?", id, repoId); await forgetLocator("deploy_token", row.token_hash); return true; }