import { assertAccountActive } from "../account-disable"; import { accountDb, stmt, type ScopedStatement } from "../scoped-db"; import { forgetLocator, locate, registerLocator } from "../scoped-db/locators"; import { randomId, sha256Hex, toHex } from "../crypto"; import { accountToken } from "../account-scope"; import { revokeAccessTokens, revokeAccessTokensWhere } from "../access-token-store"; import { PAT_PREFIX } from "../auth"; import { assertBelowCap, CAPS } from "../limits"; import type { TokenPermissions } from "../token-scope"; const VISIBLE_TOKEN_CHARS = 6; /** OAuth-issued tokens are tracked separately so agents cannot crowd out a user's personal tokens. */ const OAUTH_TOKENS_PER_USER = 100; export type GrantSpec = { permissions: TokenPermissions; allRepos: boolean; repoIds: string[]; agent: string; }; export type AgentKind = "registered" | "oauth" | "delegated" | "unregistered"; export type MintedToken = { id: string; token: string; expiresAt: number | null }; function newPat(userId: string): string { return accountToken(PAT_PREFIX, userId, toHex(crypto.getRandomValues(new Uint8Array(20)))); } /** * Issues an ordinary fine-grained personal access token (`access_tokens` + `access_token_repos`) and * records the agent it belongs to, so OAuth grants inherit every existing permission check unchanged. */ export async function mintFineGrainedToken(input: { userId: string; name: string; scopes: string; grant: GrantSpec; ttlMs: number | null; clientId: string | null; /** Set for tokens delegated by another token; they are revoked along with it. */ parentTokenId?: string; /** Hard ceiling on the expiry, e.g. the delegating token's own expiry. */ notAfter?: number | null; /** How the agent identity was established; drives attribution. */ kind: AgentKind; /** The registered agent a token is minted for (kind `registered`). */ agentId?: string; /** `mcp` limits the token to the /mcp endpoint; null lets it also use git, LFS and REST. */ audience: string | null; }): Promise { await assertAccountActive(input.userId); const oauth = input.scopes.startsWith("oauth:"); const db = accountDb(input.userId); const count = await db.first<{ count: number }>( oauth ? `SELECT COUNT(*) AS count FROM access_tokens t WHERE t.user_id = ?1 AND t.scopes LIKE 'oauth:%' AND (t.expires_at IS NULL OR t.expires_at > ?2 OR EXISTS (SELECT 1 FROM oauth_refresh_tokens r WHERE r.access_token_id = t.id AND r.used_at IS NULL AND r.expires_at > ?2))` : "SELECT COUNT(*) AS count FROM access_tokens WHERE user_id = ?1 AND (expires_at IS NULL OR expires_at > ?2)", input.userId, Date.now(), ); assertBelowCap(count?.count ?? 0, oauth ? OAUTH_TOKENS_PER_USER : CAPS.tokensPerUser, "tokens"); const id = randomId(); const token = newPat(input.userId); const tokenHash = await sha256Hex(token); const now = Date.now(); const requested = input.ttlMs === null ? null : now + input.ttlMs; const ceiling = input.notAfter ?? null; const expiresAt = ceiling === null ? requested : Math.min(requested ?? ceiling, ceiling); await db.batch([ stmt( `INSERT INTO access_tokens (id, user_id, name, token_hash, token_prefix, scopes, permissions, all_repos, expires_at, created_at, audience, parent_token_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, input.userId, input.name.slice(0, 100), tokenHash, token.slice(0, PAT_PREFIX.length + 1 + VISIBLE_TOKEN_CHARS), input.scopes, JSON.stringify(input.grant.permissions), input.grant.allRepos ? 1 : 0, expiresAt, now, input.audience, input.parentTokenId ?? null, ), ...input.grant.repoIds.map((repoId) => stmt("INSERT INTO access_token_repos (token_id, repo_id) VALUES (?, ?)", id, repoId), ), stmt( `INSERT INTO mcp_token_agents (token_id, agent_name, client_id, authorized_by, parent_token_id, created_at, kind, agent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, id, input.grant.agent.slice(0, 100), input.clientId, input.userId, input.parentTokenId ?? null, now, input.kind, input.agentId ?? null, ), ]); await Promise.all([ registerLocator("access_token", tokenHash, input.userId), registerLocator("access_token_id", id, input.userId), ]); return { id, token, expiresAt }; } /** Also drops the token's refresh tokens and every token it delegated. */ export async function deleteAccessToken(tokenId: string, userId: string): Promise { await revokeAccessTokens(userId, [tokenId]); } /** * Refreshing an OAuth grant rotates the secret of its single access_tokens row in place, so the grant * keeps its id (sessions, claims and delegations stay attached) and the old secret stops working. * `successor` (the new refresh token insert) runs in the same transaction; its foreign key makes the * whole rotation fail if the grant was revoked meanwhile. */ export async function rotateAccessToken( tokenId: string, userId: string, repoIds: string[], ttlMs: number, successor: ScopedStatement, ): Promise { const token = newPat(userId); const tokenHash = await sha256Hex(token); const expiresAt = Date.now() + ttlMs; const db = accountDb(userId); const previous = await db.first<{ token_hash: string }>( "SELECT token_hash FROM access_tokens WHERE id = ? AND user_id = ?", tokenId, userId, ); await db.batch([ stmt( "UPDATE access_tokens SET token_hash = ?, token_prefix = ?, expires_at = ? WHERE id = ? AND user_id = ?", tokenHash, token.slice(0, PAT_PREFIX.length + 1 + VISIBLE_TOKEN_CHARS), expiresAt, tokenId, userId, ), stmt("DELETE FROM access_token_repos WHERE token_id = ?", tokenId), ...repoIds.map((repoId) => stmt("INSERT INTO access_token_repos (token_id, repo_id) VALUES (?, ?)", tokenId, repoId), ), successor, ]); await registerLocator("access_token", tokenHash, userId); if (previous) await forgetLocator("access_token", previous.token_hash); return { id: tokenId, token, expiresAt }; } export type TokenLineage = { parent_token_id: string | null; /** When the grant can last until: a PAT's expiry, or an OAuth grant's live refresh token expiry. */ lifetime_ends_at: number | null; audience: string | null; agent_id: string | null; }; export async function tokenLineage(tokenId: string): Promise { const userId = await locate("access_token_id", tokenId); if (!userId) return null; return accountDb(userId).first( `SELECT COALESCE(t.parent_token_id, m.parent_token_id) AS parent_token_id, CASE WHEN t.scopes LIKE 'oauth:%' THEN COALESCE( (SELECT MAX(r.expires_at) FROM oauth_refresh_tokens r WHERE r.access_token_id = t.id AND r.used_at IS NULL), t.expires_at) ELSE t.expires_at END AS lifetime_ends_at, t.audience, m.agent_id FROM access_tokens t LEFT JOIN mcp_token_agents m ON m.token_id = t.id WHERE t.id = ?`, tokenId, ); } /** Drops expired OAuth tokens whose grant can no longer be refreshed. */ export async function purgeExpiredOAuthTokens(userId: string): Promise { const now = Date.now(); await revokeAccessTokensWhere( userId, `scopes LIKE 'oauth:%' AND expires_at IS NOT NULL AND expires_at < ? AND NOT EXISTS (SELECT 1 FROM oauth_refresh_tokens r WHERE r.access_token_id = access_tokens.id AND r.used_at IS NULL AND r.expires_at > ?)`, now, now, ); } export type TokenAgentRow = { agent_name: string; client_id: string | null; authorized_by: string; kind: AgentKind; agent_id: string | null; registered_name: string | null; }; /** The agent recorded for a token; a registered agent's current name wins over the name at mint time. */ export async function agentForToken(tokenId: string): Promise { const userId = await locate("access_token_id", tokenId); if (!userId) return null; return accountDb(userId).first( `SELECT m.agent_name, m.client_id, m.authorized_by, m.kind, m.agent_id, a.name AS registered_name FROM mcp_token_agents m LEFT JOIN agents a ON a.id = m.agent_id WHERE m.token_id = ?`, tokenId, ); }