import { tokenCredential } from "../token-scope"; import type { RequestCredential } from "../token-scope"; import { agentForToken } from "./grants"; import { protectedResourceMetadataUrl } from "./oauth"; import { RPC } from "./protocol"; import type { AgentIdentity } from "./protocol"; import type { Viewer } from "~/lib/types"; const MAX_AGENT_NAME = 80; type AgentRow = Awaited>; export type Authenticated = { credential: RequestCredential; viewer: Viewer; /** Stable for an OAuth grant's whole life: refreshes rotate the secret, not the row. */ tokenId: string; agentRow: AgentRow; }; function tokenIdOf(credential: RequestCredential): string | null { const grant = credential.grant; return grant && grant.kind !== "deploy" ? grant.tokenId : null; } /** Bearer personal tokens only (OAuth-issued or PAT); deploy tokens have no user to act for. */ export async function authenticate(request: Request): Promise { const credential = await tokenCredential(request); const tokenId = credential ? tokenIdOf(credential) : null; if (!credential?.viewer || !tokenId) return null; const agentRow = await agentForToken(tokenId); return { credential, viewer: credential.viewer, tokenId, agentRow }; } export function unauthorizedResponse(origin: string, error?: string): Response { const challenge = [ `Bearer resource_metadata="${protectedResourceMetadataUrl(origin)}"`, ...(error ? [`error="${error}"`] : []), ].join(", "); return Response.json( { jsonrpc: "2.0", id: null, error: { code: RPC.invalidRequest, message: "Authorization required" } }, { status: 401, headers: { "www-authenticate": challenge } }, ); } function cleanName(value: unknown): string | null { if (typeof value !== "string") return null; const cleaned = value .replace(/[\r\n\t]/g, " ") .replace(/[^\x20-\x7e]/g, "") .trim() .slice(0, MAX_AGENT_NAME); return cleaned || null; } /** * Who the agent is, from the token: a registered agent's name, the OAuth client the user consented to, * or the delegating agent. A plain PAT is an unregistered agent acting for its owner; the name it * declares (X-Gitfor-Agent or clientInfo) is kept only as an unverified claim. */ export async function agentIdentity( auth: Authenticated, request: Request, clientInfoName: unknown, ): Promise { const stored = auth.agentRow; const claimed = cleanName(request.headers.get("x-gitfor-agent")) ?? cleanName(clientInfoName); const base = { clientId: stored?.client_id ?? null, authorizedBy: auth.viewer.name, agentId: stored?.agent_id ?? null, }; if (!stored || stored.kind === "unregistered") { return { ...base, name: `unregistered agent via @${auth.viewer.name}`, kind: "unregistered", claimedName: claimed }; } return { ...base, name: stored.registered_name ?? stored.agent_name, kind: stored.kind, claimedName: null }; }