/* Unit tests for agent auth covering its documented public contract and regression-sensitive edge cases. Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only. RFC-1187: initial agent-auth tests. RFC-1187: phases 1-3 — contracts, Redis registry, AS core + Bearer pipeline RFC-1187: phases 5-7 — OAuth route/discovery generators, env schema, operator commands */ import { test, expect } from "vitest"; import { createHash, randomBytes } from "node:crypto"; import { generateKeyPair, signBytes } from "@warpgogol/werkstatt-shared/signing"; import { signAgentToken, verifyAgentToken, b64uEncode, AGENT_TOKEN_MAX_TTL_SECONDS, type AgentRegistration, type AgentRegistrationRegistry, } from "@warpgogol/werkstatt-shared/agent"; import { runOAuthChallenge, runAgentRegister, runAgentToken, runWhoami, runRotate, resolveBearerAgent, type AgentAuthPorts, } from "../agent-auth.ts"; import { createAgentGate, __resetLimiterCache, type AgentGatePorts } from "../index.ts"; import { createFixedWindowLimiter } from "../limits.ts"; import { buildAgentSurfaceManifest } from "@warpgogol/werkstatt-shared/agent"; import type { CapabilityRecord } from "@warpgogol/werkstatt-shared/ontology"; const ISSUER = "https://test.example"; function sha256Hex(input: string): string { return createHash("sha256").update(input, "utf8").digest("hex"); } /** In-memory registry — mirrors the Redis contract including challenge single-use. */ function makeRegistry( seed: AgentRegistration[] = [], opts: { failGet?: boolean } = {}, ): AgentRegistrationRegistry & { store: Map } { const store = new Map(seed.map((r) => [r.agentId, r])); const challenges = new Set(); return { store, get: async (id) => { if (opts.failGet) throw new Error("redis down"); return store.get(id) ?? null; }, put: async (reg) => { store.set(reg.agentId, reg); }, putChallenge: async (nonce) => { challenges.add(nonce); }, consumeChallenge: async (nonce) => challenges.delete(nonce), list: async () => [...store.values()], }; } function makeAuthPorts( keys: { privateKey: Uint8Array; publicKey: Uint8Array }, registry: AgentRegistrationRegistry, verifier?: AgentAuthPorts["verifier"], ): AgentAuthPorts { return { registry, verifier: verifier ?? { verify: async () => ({ agentId: "agent-deadbeef00000000" }), }, tokenPrivateKey: keys.privateKey, tokenPublicKey: keys.publicKey, keyVersion: "v1", issuer: ISSUER, resourceMetadataUrl: `${ISSUER}/.well-known/oauth-protected-resource`, }; } // --------------------------------------------------------------------------- // JWT primitives // --------------------------------------------------------------------------- test("signAgentToken/verifyAgentToken roundtrip: valid token verifies", async () => { const { privateKey, publicKey } = await generateKeyPair(); const now = Math.floor(Date.now() / 1000); const token = await signAgentToken( { iss: ISSUER, sub: "agent-1", iat: now, exp: now + 600, scope: "agent.actions.elevated" }, privateKey, "v1", ); const result = await verifyAgentToken(token, publicKey, ISSUER); expect(result.ok).toBe(true); if (result.ok) { expect(result.claims.sub).toBe("agent-1"); expect(result.claims.scope).toBe("agent.actions.elevated"); } }); test("signAgentToken: header carries alg/typ/kid — AC-2 contract", async () => { const { privateKey } = await generateKeyPair(); const now = Math.floor(Date.now() / 1000); const token = await signAgentToken( { iss: ISSUER, sub: "a", iat: now, exp: now + 60 }, privateKey, "v7", ); const [headerB64] = token.split("."); const header = JSON.parse( new TextDecoder().decode(Buffer.from(headerB64, "base64url")), ) as Record; expect(header).toEqual({ alg: "EdDSA", typ: "JWT", kid: "v7" }); }); test("verifyAgentToken: kid-less or wrong-typ header rejected before signature", async () => { const { privateKey, publicKey } = await generateKeyPair(); const now = Math.floor(Date.now() / 1000); const good = await signAgentToken( { iss: ISSUER, sub: "a", iat: now, exp: now + 60 }, privateKey, "v1", ); const [, payloadB64] = good.split("."); const noKid = `${b64uEncode(JSON.stringify({ alg: "EdDSA", typ: "JWT" }))}.${payloadB64}.AAAA`; expect(await verifyAgentToken(noKid, publicKey, ISSUER)).toEqual({ ok: false, reason: "header", }); const wrongTyp = `${b64uEncode(JSON.stringify({ alg: "EdDSA", typ: "JWT+at", kid: "v1" }))}.${payloadB64}.AAAA`; expect(await verifyAgentToken(wrongTyp, publicKey, ISSUER)).toEqual({ ok: false, reason: "header", }); }); test("signAgentToken: rejects TTL above 3600s", async () => { const { privateKey } = await generateKeyPair(); const now = Math.floor(Date.now() / 1000); await expect( signAgentToken( { iss: ISSUER, sub: "a", iat: now, exp: now + AGENT_TOKEN_MAX_TTL_SECONDS + 1 }, privateKey, "v1", ), ).rejects.toThrow("AGENT-TOKEN-TTL"); }); test("verifyAgentToken: expired / wrong issuer / tampered signature / wrong key", async () => { const { privateKey, publicKey } = await generateKeyPair(); const other = await generateKeyPair(); const now = Math.floor(Date.now() / 1000); const good = await signAgentToken( { iss: ISSUER, sub: "a", iat: now - 100, exp: now + 100 }, privateKey, "v1", ); expect((await verifyAgentToken(good, publicKey, ISSUER)).ok).toBe(true); const expired = await signAgentToken( { iss: ISSUER, sub: "a", iat: now - 7200, exp: now - 3600 }, privateKey, "v1", ); expect(await verifyAgentToken(expired, publicKey, ISSUER)).toEqual({ ok: false, reason: "expired", }); expect(await verifyAgentToken(good, publicKey, "https://other.example")).toEqual({ ok: false, reason: "issuer", }); expect(await verifyAgentToken(good, other.publicKey, ISSUER)).toEqual({ ok: false, reason: "signature", }); // alg "none" header tampering — signature check must fail before claims are read. const parts = good.split("."); const tampered = `${b64uEncode(JSON.stringify({ alg: "none", typ: "JWT" }))}.${parts[1]}.`; const tamperedResult = await verifyAgentToken(tampered, publicKey, ISSUER); expect(tamperedResult.ok).toBe(false); }); // --------------------------------------------------------------------------- // Challenge + register // --------------------------------------------------------------------------- test("runOAuthChallenge → runAgentRegister: consumes nonce once, stores hash only", async () => { const keys = await generateKeyPair(); const registry = makeRegistry(); const ports = makeAuthPorts(keys, registry); const challengeRes = await runOAuthChallenge(ports); expect(challengeRes.status).toBe(201); const { challenge } = (await challengeRes.json()) as { challenge: string }; const register = (challengeValue: string) => runAgentRegister( new Request(`${ISSUER}/api/agent/v1/registration`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ signature_agent: "https://agent.example/jwks.json", key_proof: { challenge: challengeValue, signature: "c2ln" }, }), }), ports, ); const ok = await register(challenge); expect(ok.status).toBe(201); const body = (await ok.json()) as { agent_id: string; credential_secret: string; token_uri: string; }; expect(body.agent_id).toBe("agent-deadbeef00000000"); expect(body.credential_secret.startsWith("gogol-agent-")).toBe(true); const stored = await registry.get(body.agent_id); expect(stored).not.toBeNull(); expect(stored!.credentialSecretHash).toBe(sha256Hex(body.credential_secret)); expect(stored!.credentialSecretHash).not.toContain(body.credential_secret); // Replay: the same nonce must not verify twice. const replay = await register(challenge); expect(replay.status).toBe(400); const replayBody = (await replay.json()) as { code: string }; expect(replayBody.code).toBe("invalid-challenge"); // Second registration with a fresh challenge → already-registered 409. const { challenge: c2 } = (await (await runOAuthChallenge(ports)).json()) as { challenge: string; }; const conflict = await register(c2); expect(conflict.status).toBe(409); }); test("runAgentRegister: bad proof fails after challenge consumption", async () => { const keys = await generateKeyPair(); const registry = makeRegistry(); const ports = makeAuthPorts(keys, registry, { verify: async () => null }); const { challenge } = (await (await runOAuthChallenge(ports)).json()) as { challenge: string; }; const res = await runAgentRegister( new Request(`${ISSUER}/api/agent/v1/registration`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ signature_agent: "https://agent.example/jwks.json", key_proof: { challenge, signature: "AAAA" }, }), }), ports, ); expect(res.status).toBe(400); expect(((await res.json()) as { code: string }).code).toBe("invalid-proof"); }); // --------------------------------------------------------------------------- // Token endpoint // --------------------------------------------------------------------------- async function seedRegistration( ports: AgentAuthPorts, registry: AgentRegistrationRegistry, ): Promise<{ agentId: string; secret: string }> { const secret = "gogol-agent-testsecret"; const reg: AgentRegistration = { agentId: "agent-test0000000001", signatureAgent: "https://agent.example/jwks.json", credentialSecretHash: sha256Hex(secret), scopes: ["agent.actions.elevated", "agent.registration"], registeredAt: "2026-01-01T00:00:00.000Z", credentialVersion: 1, }; await registry.put(reg); return { agentId: reg.agentId, secret }; } test("runAgentToken: client_credentials issues a verifiable EdDSA JWT (3600s)", async () => { const keys = await generateKeyPair(); const registry = makeRegistry(); const ports = makeAuthPorts(keys, registry); const { agentId, secret } = await seedRegistration(ports, registry); const res = await runAgentToken( new Request(`${ISSUER}/api/agent/oauth/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: `grant_type=client_credentials&client_id=${agentId}&client_secret=${secret}`, }), ports, ); expect(res.status).toBe(200); const body = (await res.json()) as { access_token: string; token_type: string; expires_in: number; }; expect(body.token_type).toBe("Bearer"); expect(body.expires_in).toBe(3600); const claims = await verifyAgentToken(body.access_token, keys.publicKey, ISSUER); expect(claims.ok).toBe(true); if (claims.ok) { expect(claims.claims.sub).toBe(agentId); expect(claims.claims.exp - claims.claims.iat).toBe(3600); } }); test("runAgentToken: wrong secret / unknown client / wrong grant → OAuth errors", async () => { const keys = await generateKeyPair(); const registry = makeRegistry(); const ports = makeAuthPorts(keys, registry); const { agentId } = await seedRegistration(ports, registry); const post = (form: string) => runAgentToken( new Request(`${ISSUER}/api/agent/oauth/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: form, }), ports, ); expect( (await post(`grant_type=client_credentials&client_id=${agentId}&client_secret=wrong`)).status, ).toBe(401); expect( (await post(`grant_type=client_credentials&client_id=nobody&client_secret=x`)).status, ).toBe(401); expect((await post(`grant_type=password&client_id=${agentId}&client_secret=x`)).status).toBe(400); }); // --------------------------------------------------------------------------- // Protected routes — whoami / rotate // --------------------------------------------------------------------------- async function issueToken(keys: { privateKey: Uint8Array }, agentId: string): Promise { const now = Math.floor(Date.now() / 1000); return signAgentToken( { iss: ISSUER, sub: agentId, iat: now, exp: now + 600 }, keys.privateKey, "v1", ); } function authed(token: string, url: string, method = "GET") { return new Request(url, { method, headers: { authorization: `Bearer ${token}` } }); } test("runWhoami: 401 + WWW-Authenticate resource_metadata without Bearer", async () => { const keys = await generateKeyPair(); const registry = makeRegistry(); const ports = makeAuthPorts(keys, registry); const res = await runWhoami(new Request(`${ISSUER}/api/agent/v1/registration/whoami`), ports); expect(res.status).toBe(401); const wwwAuth = res.headers.get("WWW-Authenticate"); expect(wwwAuth).toContain("Bearer"); // RFC-1187: the realm is the site origin — never the generic "agent". expect(wwwAuth).toContain(`realm="${ISSUER}"`); expect(wwwAuth).toContain(`resource_metadata="${ISSUER}/.well-known/oauth-protected-resource"`); }); test("runWhoami: valid Bearer returns the registration record", async () => { const keys = await generateKeyPair(); const registry = makeRegistry(); const ports = makeAuthPorts(keys, registry); const { agentId } = await seedRegistration(ports, registry); const token = await issueToken(keys, agentId); const res = await runWhoami(authed(token, `${ISSUER}/api/agent/v1/registration/whoami`), ports); expect(res.status).toBe(200); const body = (await res.json()) as { agent_id: string; scopes: string[] }; expect(body.agent_id).toBe(agentId); expect(body.scopes).toContain("agent.actions.elevated"); }); test("runRotate: issues new secret, bumps version, old secret dies", async () => { const keys = await generateKeyPair(); const registry = makeRegistry(); const ports = makeAuthPorts(keys, registry); const { agentId, secret } = await seedRegistration(ports, registry); const token = await issueToken(keys, agentId); const res = await runRotate( authed(token, `${ISSUER}/api/agent/v1/registration/rotate`, "POST"), ports, ); expect(res.status).toBe(200); const body = (await res.json()) as { credential_secret: string; credential_version: number }; expect(body.credential_version).toBe(2); expect(body.credential_secret).not.toBe(secret); const stored = await registry.get(agentId); expect(stored!.credentialSecretHash).toBe(sha256Hex(body.credential_secret)); }); test("resolveBearerAgent: revoked registration resolves to anonymous", async () => { const keys = await generateKeyPair(); const registry = makeRegistry(); const ports = makeAuthPorts(keys, registry); const { agentId } = await seedRegistration(ports, registry); const reg = await registry.get(agentId); await registry.put({ ...reg!, revokedAt: "2026-02-01T00:00:00.000Z" }); const token = await issueToken(keys, agentId); const resolved = await resolveBearerAgent( authed(token, `${ISSUER}/api/agent/v1/actions/lead.submit`), ports, ); expect(resolved).toBeNull(); }); test("resolveBearerAgent: registry outage degrades to anonymous, never throws", async () => { const keys = await generateKeyPair(); const registry = makeRegistry([], { failGet: true }); const ports = makeAuthPorts(keys, registry); const token = await issueToken(keys, "agent-anything000000"); const resolved = await resolveBearerAgent( authed(token, `${ISSUER}/api/agent/v1/actions/lead.submit`), ports, ); expect(resolved).toBeNull(); }); // --------------------------------------------------------------------------- // Elevated tier in the action pipeline // --------------------------------------------------------------------------- const WRITE_CAP: CapabilityRecord = { id: "lead.submit", version: 2, kind: "action", sideEffect: "write", title: { de: "Senden", en: "Submit" }, description: { de: "Sendet.", en: "Delivers." }, input: { type: "object", required: ["message"], additionalProperties: false, properties: { message: { type: "string", minLength: 10, maxLength: 4000 } }, }, output: { type: "object", required: ["receiptId"], additionalProperties: false, properties: { receiptId: { type: "string" } }, }, integration: { eventKind: "lead", source: "agent" }, requires: { entitlements: [], sections: [] }, limits: { perMinutePerIp: 1, perMinutePerAgent: 10, maxPayloadBytes: 16384 }, }; function makeGateManifest() { return buildAgentSurfaceManifest({ generatedAt: "2026-01-01T00:00:00.000Z", site: "test-bundle", baseUrl: ISSUER, languages: { default: "de", supported: ["de"] }, knowledge: [], actions: [ { id: "lead.submit", url: "/api/agent/v1/actions/lead.submit", title: WRITE_CAP.title, inputSchemaRef: "#/components/schemas/lead.submit-input", entitlement: "agent.actions", }, ], }); } function actionRequest(token?: string): Request { return new Request(`${ISSUER}/api/agent/v1/actions/lead.submit`, { method: "POST", headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ message: "hello there, this is a test" }), }); } test("pipeline: verified Bearer stamps verifiedAgentId into _agentIdentity", async () => { __resetLimiterCache(); const keys = await generateKeyPair(); const registry = makeRegistry(); const auth = makeAuthPorts(keys, registry); const { agentId } = await seedRegistration(auth, registry); const seen: Array> = []; const ports: AgentGatePorts = { knowledge: { read: async () => null }, dispatch: { send: async (event) => { seen.push(event.payload); return { receiptId: event.eventId, status: "accepted" as const, duplicate: false, submittedAt: "2026-07-05T00:00:00.000Z", }; }, }, now: () => new Date("2026-07-05T00:00:00.000Z"), createRateLimiter: (max) => createFixedWindowLimiter(60, max), auth, }; const gate = createAgentGate(makeGateManifest(), [WRITE_CAP], ports); const token = await issueToken(keys, agentId); const res = await gate.handleAction("lead.submit", actionRequest(token)); expect(res.status).toBe(200); const identity = seen[0]?._agentIdentity as Record | undefined; expect(identity?.verifiedAgentId).toBe(agentId); }); test("pipeline: anonymous caller is rate-limited at the anonymous cap; elevated agent is not", async () => { __resetLimiterCache(); const keys = await generateKeyPair(); const registry = makeRegistry(); const auth = makeAuthPorts(keys, registry); const { agentId } = await seedRegistration(auth, registry); const ports: AgentGatePorts = { knowledge: { read: async () => null }, dispatch: { send: async (event) => ({ receiptId: event.eventId, status: "accepted" as const, duplicate: false, submittedAt: "2026-07-05T00:00:00.000Z", }), }, now: () => new Date("2026-07-05T00:00:00.000Z"), createRateLimiter: (max) => createFixedWindowLimiter(60, max), auth, }; const gate = createAgentGate(makeGateManifest(), [WRITE_CAP], ports); // Anonymous: perMinutePerIp = 1 → second call in the window is 429. await gate.handleAction("lead.submit", actionRequest()); const second = await gate.handleAction("lead.submit", actionRequest()); expect(second.status).toBe(429); // Elevated: perMinutePerAgent = 10 → several calls pass under the agent bucket. const token = await issueToken(keys, agentId); for (let i = 0; i < 3; i++) { const res = await gate.handleAction("lead.submit", actionRequest(token)); expect(res.status).toBe(200); } }); test("pipeline: invalid Bearer falls through to anonymous handling", async () => { __resetLimiterCache(); const keys = await generateKeyPair(); const registry = makeRegistry(); const auth = makeAuthPorts(keys, registry); const ports: AgentGatePorts = { knowledge: { read: async () => null }, dispatch: { send: async (event) => ({ receiptId: event.eventId, status: "accepted" as const, duplicate: false, submittedAt: "2026-07-05T00:00:00.000Z", }), }, now: () => new Date("2026-07-05T00:00:00.000Z"), createRateLimiter: (max) => createFixedWindowLimiter(60, max), auth, }; const gate = createAgentGate(makeGateManifest(), [WRITE_CAP], ports); const res = await gate.handleAction("lead.submit", actionRequest("not-a-jwt")); expect(res.status).toBe(200); }); // --------------------------------------------------------------------------- // Auth-endpoint body cap (64 KiB streamed) // --------------------------------------------------------------------------- test("runAgentRegister: body over 64 KiB → 413 payload-too-large", async () => { const keys = await generateKeyPair(); const registry = makeRegistry(); const ports = makeAuthPorts(keys, registry); const res = await runAgentRegister( new Request(`${ISSUER}/api/agent/v1/registration`, { method: "POST", headers: { "content-type": "application/json", "content-length": String(70 * 1024), }, body: "x".repeat(70 * 1024), }), ports, ); expect(res.status).toBe(413); expect(((await res.json()) as { code: string }).code).toBe("payload-too-large"); }); test("runAgentToken: over-cap form body → 413, not a parse error", async () => { const keys = await generateKeyPair(); const registry = makeRegistry(); const ports = makeAuthPorts(keys, registry); const res = await runAgentToken( new Request(`${ISSUER}/api/agent/oauth/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", "content-length": String(70 * 1024), }, body: `grant_type=client_credentials&pad=${"x".repeat(70 * 1024)}`, }), ports, ); expect(res.status).toBe(413); }); // --------------------------------------------------------------------------- // Bearer on MCP transport — same elevated-tier contract as the HTTP route // --------------------------------------------------------------------------- function mcpCall(token?: string): Request { return new Request(`${ISSUER}/api/agent/mcp`, { method: "POST", headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "action.lead.submit", arguments: { message: "hello there, this is a test" }, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }, }, }), }); } function makeMcpPorts(auth: AgentAuthPorts, seen: Array>): AgentGatePorts { return { knowledge: { read: async () => null }, dispatch: { send: async (event) => { seen.push(event.payload); return { receiptId: event.eventId, status: "accepted" as const, duplicate: false, submittedAt: "2026-07-05T00:00:00.000Z", }; }, }, now: () => new Date("2026-07-05T00:00:00.000Z"), createRateLimiter: (max) => createFixedWindowLimiter(60, max), auth, }; } test("MCP tools/call: verified Bearer stamps verifiedAgentId + elevated bucket", async () => { __resetLimiterCache(); const keys = await generateKeyPair(); const registry = makeRegistry(); const auth = makeAuthPorts(keys, registry); const { agentId } = await seedRegistration(auth, registry); const seen: Array> = []; const gate = createAgentGate(makeGateManifest(), [WRITE_CAP], makeMcpPorts(auth, seen)); const token = await issueToken(keys, agentId); // Elevated bucket (per-agent max 10): three calls pass; verifiedAgentId stamped. for (let i = 0; i < 3; i++) { const res = await gate.handleMcp(mcpCall(token)); expect(res.status).toBe(200); const rpc = (await res.json()) as { error?: unknown }; expect(rpc.error).toBeUndefined(); } const identity = seen[0]?._agentIdentity as Record | undefined; expect(identity?.verifiedAgentId).toBe(agentId); }); test("MCP tools/call: anonymous caller is per-IP limited; Bearer bypasses it", async () => { __resetLimiterCache(); const keys = await generateKeyPair(); const registry = makeRegistry(); const auth = makeAuthPorts(keys, registry); const { agentId } = await seedRegistration(auth, registry); const gate = createAgentGate(makeGateManifest(), [WRITE_CAP], makeMcpPorts(auth, [])); // Anonymous: perMinutePerIp = 1 → second call is a JSON-RPC rate-limit error (HTTP 200). const first = await gate.handleMcp(mcpCall()); expect(((await first.json()) as { error?: unknown }).error).toBeUndefined(); const second = await gate.handleMcp(mcpCall()); expect(((await second.json()) as { error?: unknown }).error).toBeDefined(); // Verified Bearer: the per-agent bucket is untouched by the IP bucket. const token = await issueToken(keys, agentId); const elevated = await gate.handleMcp(mcpCall(token)); expect(((await elevated.json()) as { error?: unknown }).error).toBeUndefined(); }); // Sanity: the proof-signature primitive the verifier consumes — signature over // challenge bytes verifies under the registrant's Ed25519 key (mirrors astro.ts). test("key proof: Ed25519 signature over challenge bytes verifies", async () => { const agent = await generateKeyPair(); const challenge = b64uEncode(randomBytes(32)); const signature = await signBytes(agent.privateKey, new TextEncoder().encode(challenge)); const { verifyBytes } = await import("@warpgogol/werkstatt-shared/signing"); expect(await verifyBytes(agent.publicKey, new TextEncoder().encode(challenge), signature)).toBe( true, ); });