/* RFC-1187: the worker-hosted agent authorization server core — pure, framework-free handlers for challenge issuance, key-proof registration, client_credentials token issuance, JWKS, and the protected self-management surface (whoami, rotate). Also owns Bearer resolution for the action pipeline: a verified token upgrades the request to the registered tier (elevated limits + verifiedAgentId attribution); every failure degrades to anonymous, never to an error. Do not fetch, read env, or touch Redis directly — ports do that (AgentAuthPorts, injected by astro.ts). Do not decide whether auth is enabled — a missing `ports.auth` IS the disabled state; callers check it, these handlers assume it exists. RFC-1187: initial authorization-server core — challenge/register/token/jwks/whoami/rotate + Bearer resolution. RFC-1187: phases 1-3 — contracts, Redis registry, AS core + Bearer pipeline RFC-1187: fo-review — keyVersion port (JWT/JWKS kid), realm=issuer, VerifiedAgent.registration single-fetch, TOCTOU note */ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { buildAgentProblem, signAgentToken, b64uEncode, verifyAgentToken, AGENT_TOKEN_MAX_TTL_SECONDS, AGENT_SCOPE_ACTIONS_ELEVATED, AGENT_SCOPE_REGISTRATION_SELF, type AgentProblemDetails, type AgentRegistration, type AgentRegistrationRegistry, type AgentRegistrationVerifier, type VerifiedAgent, } from "@warpgogol/werkstatt-shared/agent"; /** RFC-1187: everything the AS needs from the runtime — injected by astro.ts. */ export interface AgentAuthPorts { registry: AgentRegistrationRegistry; verifier: AgentRegistrationVerifier; /** Ed25519 seed — signs access tokens (the site passport key). */ tokenPrivateKey: Uint8Array; /** Public half — verifies Bearer tokens locally (derived from the private key). */ tokenPublicKey: Uint8Array; /** Site origin — JWT `iss` and the issuer field in discovery metadata. */ issuer: string; /** * Active passport key version ("v1", "v2", …) — JWT header `kid` and the * JWKS `kid` both source it, so `passport.key.rotate` transitions stay * honest. Stamped into the manifest at generation (`interfaces.oauth.keyVersion`). */ keyVersion: string; /** Absolute URL of the RFC 9728 protected-resource document. */ resourceMetadataUrl: string; } const CHALLENGE_TTL_SECONDS = 300; /** RFC-1187: streamed cap on auth-endpoint bodies — 64 KiB, enforced WHILE reading. */ export const MAX_AUTH_BODY_BYTES = 64 * 1024; /** * Read a Request/Response body up to `maxBytes`; `null` when the stream * exceeds the cap or a read error occurs, `""` when no body exists. The cap * applies during the read — the isolate never buffers the unbounded body * first (the inbound counterpart of the outbound JWKS fetch cap in astro.ts). */ export async function readBoundedBodyText( source: { body: ReadableStream | null }, maxBytes: number, ): Promise { const body = source.body; if (!body) return ""; const reader = body.getReader(); const decoder = new TextDecoder(); let received = 0; const chunks: string[] = []; try { for (;;) { const { done, value } = await reader.read(); if (done) break; received += value.byteLength; if (received > maxBytes) { await reader.cancel().catch(() => undefined); return null; } chunks.push(decoder.decode(value, { stream: true })); } chunks.push(decoder.decode()); return chunks.join(""); } catch { return null; } } /** Content-Length fast-path + streamed cap; over-cap bodies become a 413 Response. */ async function readAuthRequestBody(request: Request): Promise { const contentLength = Number(request.headers.get("content-length") ?? "0"); if (contentLength > MAX_AUTH_BODY_BYTES) { return problemResponse( buildAgentProblem("payload-too-large", { detail: `Request body exceeds ${MAX_AUTH_BODY_BYTES} bytes.`, }), ); } const text = await readBoundedBodyText(request, MAX_AUTH_BODY_BYTES); if (text === null) { return problemResponse( buildAgentProblem("payload-too-large", { detail: `Request body exceeds ${MAX_AUTH_BODY_BYTES} bytes.`, }), ); } return text; } function jsonResponse( body: unknown, status = 200, extraHeaders?: Record, ): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...extraHeaders }, }); } function problemResponse( problem: AgentProblemDetails, extraHeaders?: Record, ): Response { return new Response(JSON.stringify(problem), { status: problem.status, headers: { "content-type": "application/problem+json", ...extraHeaders }, }); } function sha256Hex(input: string): string { return createHash("sha256").update(input, "utf8").digest("hex"); } /** RFC 6749 §5.2 error envelope for the token endpoint (OAuth semantics, not problem+json). */ function oauthErrorResponse( error: "invalid_request" | "invalid_client" | "unsupported_grant_type", status: number, ): Response { return jsonResponse({ error }, status); } /** POST /api/agent/oauth/challenge — issue a single-use registration nonce. */ export async function runOAuthChallenge(ports: AgentAuthPorts): Promise { const nonce = b64uEncode(randomBytes(32)); await ports.registry.putChallenge(nonce, CHALLENGE_TTL_SECONDS); return jsonResponse( { challenge: nonce, expires_at: new Date(Date.now() + CHALLENGE_TTL_SECONDS * 1000).toISOString(), expires_in: CHALLENGE_TTL_SECONDS, }, 201, ); } /** * POST /api/agent/v1/registration — WBA key-proof registration. The challenge * is consumed BEFORE verification (a failed proof still burns the nonce — * re-request a challenge to retry). On success the credential secret is * returned exactly once; only its sha256 is persisted. */ export async function runAgentRegister(request: Request, ports: AgentAuthPorts): Promise { const raw = await readAuthRequestBody(request); if (raw instanceof Response) return raw; let body: Record; try { body = JSON.parse(raw) as Record; } catch { return problemResponse(buildAgentProblem("invalid-json")); } const signatureAgent = typeof body.signature_agent === "string" ? body.signature_agent : null; const keyProof = typeof body.key_proof === "object" && body.key_proof !== null ? (body.key_proof as Record) : null; const challenge = typeof keyProof?.challenge === "string" ? keyProof.challenge : null; const signature = typeof keyProof?.signature === "string" ? keyProof.signature : null; const kid = typeof keyProof?.kid === "string" ? keyProof.kid : undefined; if (!signatureAgent || !challenge || !signature) { return problemResponse( buildAgentProblem("schema-violation", { detail: "Expected {signature_agent, key_proof: {challenge, signature}}.", }), ); } const consumed = await ports.registry.consumeChallenge(challenge); if (!consumed) { return problemResponse(buildAgentProblem("invalid-challenge")); } const verified = await ports.verifier.verify({ signatureAgent, signature, challenge, kid }); if (!verified) { return problemResponse(buildAgentProblem("invalid-proof")); } // TOCTOU note: the get→put pair is not atomic — two concurrent registrations // of the same agentId both pass the existing-check and last-write-wins (the // second secret's hash lands). Self-inflicted by one agent identity; the // returned credential of the loser simply never authenticates. const existing = await ports.registry.get(verified.agentId); if (existing && !existing.revokedAt) { return problemResponse( buildAgentProblem("already-registered", { detail: `Agent "${verified.agentId}" is already registered — use the rotate endpoint.`, }), ); } const credentialSecret = `gogol-agent-${b64uEncode(randomBytes(32))}`; const registration: AgentRegistration = { agentId: verified.agentId, signatureAgent, credentialSecretHash: sha256Hex(credentialSecret), scopes: [AGENT_SCOPE_ACTIONS_ELEVATED, AGENT_SCOPE_REGISTRATION_SELF], registeredAt: new Date().toISOString(), credentialVersion: (existing?.credentialVersion ?? 0) + 1, }; await ports.registry.put(registration); return jsonResponse( { agent_id: registration.agentId, credential_secret: credentialSecret, scopes: registration.scopes, token_uri: `${ports.issuer}/api/agent/oauth/token`, credential_types_supported: ["secret"], }, 201, ); } /** Read a token request body in either OAuth form-encoding or JSON. Response = error to return. */ async function readTokenBody(request: Request): Promise | Response> { const raw = await readAuthRequestBody(request); if (raw instanceof Response) return raw; const contentType = request.headers.get("content-type") ?? ""; if (contentType.includes("application/x-www-form-urlencoded")) { const params = new URLSearchParams(raw); return Object.fromEntries(params.entries()); } try { const parsed = JSON.parse(raw) as Record; return Object.fromEntries( Object.entries(parsed).filter((e): e is [string, string] => typeof e[1] === "string"), ); } catch { return {}; } } /** * POST /api/agent/oauth/token — client_credentials grant. Credential check is * sha256(secret) vs the stored hash; a successful check issues an EdDSA JWT * (sub = agentId, iss = site origin, TTL ≤ 3600s, scope from registration). */ export async function runAgentToken(request: Request, ports: AgentAuthPorts): Promise { const body = await readTokenBody(request); if (body instanceof Response) return body; if (body.grant_type !== "client_credentials") { return oauthErrorResponse("unsupported_grant_type", 400); } const clientId = body.client_id; const clientSecret = body.client_secret; if (!clientId || !clientSecret) { return oauthErrorResponse("invalid_request", 400); } const registration = await ports.registry.get(clientId); if (!registration || registration.revokedAt) { return oauthErrorResponse("invalid_client", 401); } if (sha256Hex(clientSecret) !== registration.credentialSecretHash) { return oauthErrorResponse("invalid_client", 401); } const now = Math.floor(Date.now() / 1000); const token = await signAgentToken( { iss: ports.issuer, sub: registration.agentId, iat: now, exp: now + AGENT_TOKEN_MAX_TTL_SECONDS, scope: registration.scopes.join(" "), jti: randomUUID(), }, ports.tokenPrivateKey, ports.keyVersion, ); return jsonResponse({ access_token: token, token_type: "Bearer", expires_in: AGENT_TOKEN_MAX_TTL_SECONDS, }); } /** GET /api/agent/oauth/jwks.json — the RFC-1188 directory shares key "v1". */ export function runAgentJwks(publicKeyJwk: JsonWebKey): Response { return jsonResponse({ keys: [publicKeyJwk] }, 200, { "cache-control": "public, max-age=300" }); } /** * Resolve a Bearer token into the registered-tier identity. Every failure — * malformed header, bad signature, expired, revoked, registry down — resolves * to null (anonymous), never throws. The action pipeline treats null as * "keep the anonymous contract". */ export async function resolveBearerAgent( request: Request, ports: AgentAuthPorts, ): Promise { const header = request.headers.get("authorization"); if (!header || !header.startsWith("Bearer ")) return null; const token = header.slice("Bearer ".length).trim(); if (!token) return null; try { const result = await verifyAgentToken(token, ports.tokenPublicKey, ports.issuer); if (!result.ok) return null; const registration = await ports.registry.get(result.claims.sub); if (!registration || registration.revokedAt) return null; return { agentId: registration.agentId, scopes: registration.scopes, registration, }; } catch (err) { // Registry/Redis outage: degrade to anonymous with a warning (operator decision). console.warn( `agent-auth: Bearer resolution failed, falling back to anonymous: ${err instanceof Error ? err.message : String(err)}`, ); return null; } } /** * Bearer gate for protected self-management routes — unlike the action * pipeline these routes REQUIRE identity. Returns the VerifiedAgent or a 401 * problem whose WWW-Authenticate carries the RFC 9728 resource_metadata link. */ export async function requireBearerAgent( request: Request, ports: AgentAuthPorts, ): Promise { const agent = await resolveBearerAgent(request, ports); if (agent) return agent; return problemResponse( buildAgentProblem("unauthorized", { detail: "A valid Bearer token is required for this endpoint.", }), { "WWW-Authenticate": `Bearer realm="${ports.issuer}", resource_metadata="${ports.resourceMetadataUrl}"`, }, ); } /** GET /api/agent/v1/registration/whoami — authenticated self-report. */ export async function runWhoami(request: Request, ports: AgentAuthPorts): Promise { const agent = await requireBearerAgent(request, ports); if (agent instanceof Response) return agent; // The registration record rides the resolved identity — no second registry // roundtrip (resolveBearerAgent already fetched and checked revokedAt). const { registration } = agent; return jsonResponse({ agent_id: registration.agentId, signature_agent: registration.signatureAgent, scopes: registration.scopes, registered_at: registration.registeredAt, credential_version: registration.credentialVersion, }); } /** * POST /api/agent/v1/registration/rotate — issue a new credential secret and * bump credentialVersion. The new secret is returned once; the old secret * stops working immediately (the hash is replaced). Live access tokens are * unaffected — they expire on their own clock (≤ 1h). */ export async function runRotate(request: Request, ports: AgentAuthPorts): Promise { const agent = await requireBearerAgent(request, ports); if (agent instanceof Response) return agent; // Reuse the registration record resolved with the Bearer token. const { registration } = agent; const credentialSecret = `gogol-agent-${b64uEncode(randomBytes(32))}`; const rotated: AgentRegistration = { ...registration, credentialSecretHash: sha256Hex(credentialSecret), credentialVersion: registration.credentialVersion + 1, }; await ports.registry.put(rotated); return jsonResponse({ agent_id: rotated.agentId, credential_secret: credentialSecret, credential_version: rotated.credentialVersion, }); }