diff --git a/packages/agent-auth/src/__tests__/helpers.ts b/packages/agent-auth/src/__tests__/helpers.ts index 7ce1692f3..c2245d48d 100644 --- a/packages/agent-auth/src/__tests__/helpers.ts +++ b/packages/agent-auth/src/__tests__/helpers.ts @@ -3,7 +3,7 @@ import { exportJWK, generateKeyPair, importJWK, SignJWT, calculateJwkThumbprint import { expect } from "vitest"; import { agentAuth as _agentAuth } from "../index"; import { agentAuthClient } from "../client"; -import type { AgentAuthOptions, AgentJWK } from "../types"; +import type { AgentAuthOptions, AgentHost, AgentJWK } from "../types"; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const agentAuth = (opts?: AgentAuthOptions): any => _agentAuth(opts); @@ -192,7 +192,7 @@ export async function expectError( * Reduces boilerplate in test files. */ export async function createTestContext(pluginOpts?: AgentAuthOptions) { - const t = await getTestInstance( + const { auth, signInWithTestUser } = await getTestInstance( { plugins: [agentAuth(pluginOpts)], }, @@ -200,18 +200,11 @@ export async function createTestContext(pluginOpts?: AgentAuthOptions) { clientOptions: { plugins: [agentAuthClientPlugin()] }, }, ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const auth = t.auth as any; const client = createTestClient((req: Request) => auth.handler(req)); - const { headers } = await t.signInWithTestUser(); - const sessionCookie = headers.get("set-cookie") ?? ""; - const sessionRes = await client.api("/get-session", { - method: "GET", - headers: { cookie: sessionCookie }, - }); - const sessionBody = await json>(sessionRes); - const userId = (sessionBody as { user?: { id?: string } }).user?.id ?? ""; + const { headers, user } = await signInWithTestUser(); + const sessionCookie = headers.get("cookie") ?? ""; + const userId = user.id; async function createHost(opts?: { capabilities?: string[]; name?: string }): Promise<{ hostId: string; @@ -227,8 +220,27 @@ export async function createTestContext(pluginOpts?: AgentAuthOptions) { }, sessionCookie, ); - const hostBody = await json<{ id: string }>(hostRes); - return { hostId: hostBody.id, hostKeypair }; + const hostBody = await json<{ hostId: string }>(hostRes); + if (!hostRes.ok) { + throw new Error(`createHost failed: ${JSON.stringify(hostBody)}`); + } + return { hostId: hostBody.hostId, hostKeypair }; + } + + /** + * Read a persisted host row by id. Throws when the row is absent, so + * callers get a non-nullable `AgentHost` and never need a `!`. + */ + async function getHost(hostId: string): Promise { + const context = await auth.$context; + const host = await context.adapter.findOne({ + model: "agentHost", + where: [{ field: "id", value: hostId }], + }); + if (!host) { + throw new Error(`no agentHost row persisted for id ${hostId}`); + } + return host; } async function registerAgent(opts: { @@ -260,6 +272,7 @@ export async function createTestContext(pluginOpts?: AgentAuthOptions) { sessionCookie, userId, createHost, + getHost, registerAgent, }; } @@ -269,5 +282,5 @@ export async function createTestContext(pluginOpts?: AgentAuthOptions) { * how the system derives host IDs from keys. */ export async function computeThumbprint(publicKey: AgentJWK): Promise { - return calculateJwkThumbprint(publicKey as Parameters[0]); + return calculateJwkThumbprint(publicKey); } diff --git a/packages/agent-auth/src/__tests__/kidless-host-jwk.test.ts b/packages/agent-auth/src/__tests__/kidless-host-jwk.test.ts new file mode 100644 index 000000000..5287dfefc --- /dev/null +++ b/packages/agent-auth/src/__tests__/kidless-host-jwk.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import { + generateTestKeypair, + createHostJWT, + signTestJWT, + json, + createTestContext, + computeThumbprint, + BASE, +} from "./helpers"; +import type { AgentAuthOptions } from "../types"; + +/** + * Regression: hosts registering with a kid-less JWK must remain findable. + * + * `kid` is OPTIONAL in a JWK (RFC 7517 §4.5), and RFC 7638 §3.1 blesses + * the JWK thumbprint as a `kid` value — which is exactly what a kid-less + * host uses as its `iss`. The dynamic-registration branches previously + * stored `kid = publicKey.kid ?? null`, so a spec-compliant host whose + * JWK omitted the member was persisted with `kid = null`. Its next JWT + * (`iss` = thumbprint) matched neither the `id` nor the `kid` lookup and + * every request failed with AGENT_NOT_FOUND — permanently. + * + * The official SDK masks this because it stamps `kid = thumbprint` at + * keygen; only clients that legitimately omit `kid` ever hit it. + * Fix: derive and persist the thumbprint whenever `kid` is absent. + */ +describe("dynamic host registration — kid-less JWK", () => { + const DYNAMIC_REGISTRATION_OPTIONS: AgentAuthOptions = { + providerName: "test-service", + allowDynamicHostRegistration: true, + modes: ["delegated", "autonomous"], + capabilities: [{ name: "ping", description: "ping" }], + resolveAutonomousUser: async ({ hostId }) => ({ + id: `synthetic_${hostId}`, + name: "Autonomous User", + email: `auto_${hostId}@test.local`, + }), + }; + + it("stores the JWK thumbprint as kid and authenticates the host on subsequent requests", async () => { + const { client, getHost } = await createTestContext(DYNAMIC_REGISTRATION_OPTIONS); + + const hostKeypair = await generateTestKeypair(); + const agentKeypair = await generateTestKeypair(); + const thumbprint = await computeThumbprint(hostKeypair.publicKey); + + // The host key carries no `kid` — allowed by RFC 7517 §4.5 — and the + // host identifies itself by its thumbprint, per RFC 7638 §3.1. + expect(hostKeypair.publicKey.kid).toBeUndefined(); + const hostJWT = await createHostJWT( + hostKeypair.privateKey, + hostKeypair.publicKey, + agentKeypair.publicKey, + thumbprint, + ); + + const registerRes = await client.api("/agent/register", { + method: "POST", + headers: { authorization: `Bearer ${hostJWT}` }, + body: JSON.stringify({ name: "Kid-less Host Agent", mode: "autonomous" }), + }); + const registerBody = await json<{ agent_id: string; host_id: string }>(registerRes); + expect(registerRes.ok, JSON.stringify(registerBody)).toBe(true); + + // The stored row carries the derived thumbprint, not null. + const host = await getHost(registerBody.host_id); + expect(host.kid).toBe(thumbprint); + + // A follow-up host JWT (iss = thumbprint) must resolve the host. + // Before the fix this failed with AGENT_NOT_FOUND: the row's id is a + // generated UUID and its kid was null, so neither lookup matched. + const followUpJWT = await signTestJWT({ + privateKey: hostKeypair.privateKey, + subject: thumbprint, + issuer: thumbprint, + typ: "host+jwt", + audience: BASE, + }); + const statusRes = await client.api(`/agent/status?agent_id=${registerBody.agent_id}`, { + method: "GET", + headers: { authorization: `Bearer ${followUpJWT}` }, + }); + const statusBody = await json<{ error?: string }>(statusRes); + expect(statusRes.ok, JSON.stringify(statusBody)).toBe(true); + expect(statusBody.error).toBeUndefined(); + }); + + it("keeps an explicit kid unchanged when the JWK carries one", async () => { + const { client, getHost } = await createTestContext(DYNAMIC_REGISTRATION_OPTIONS); + + const hostKeypair = await generateTestKeypair(); + const agentKeypair = await generateTestKeypair(); + const explicitKid = `explicit-kid-${crypto.randomUUID()}`; + const publicKeyWithKid = { ...hostKeypair.publicKey, kid: explicitKid }; + + const hostJWT = await createHostJWT( + hostKeypair.privateKey, + publicKeyWithKid, + agentKeypair.publicKey, + explicitKid, + ); + + const registerRes = await client.api("/agent/register", { + method: "POST", + headers: { authorization: `Bearer ${hostJWT}` }, + body: JSON.stringify({ name: "Explicit Kid Agent", mode: "autonomous" }), + }); + const registerBody = await json<{ agent_id: string; host_id: string }>(registerRes); + expect(registerRes.ok, JSON.stringify(registerBody)).toBe(true); + + const host = await getHost(registerBody.host_id); + expect(host.kid).toBe(explicitKid); + }); +}); + +/** + * Same root cause on the session-authenticated management routes: + * /host/create and /host/enroll persisted `kid = publicKey.kid ?? null`, + * leaving kid-less hosts unable to authenticate with iss = thumbprint. + */ +describe("host provisioning — kid-less JWK", () => { + it("derives the thumbprint on /host/create", async () => { + const { client, sessionCookie, getHost } = await createTestContext({ + providerName: "test-service", + }); + + const hostKeypair = await generateTestKeypair(); + const thumbprint = await computeThumbprint(hostKeypair.publicKey); + + const createRes = await client.authedPost( + "/host/create", + { name: "Kid-less Host", public_key: hostKeypair.publicKey }, + sessionCookie, + ); + expect(createRes.ok).toBe(true); + const { hostId } = await json<{ hostId: string }>(createRes); + + const host = await getHost(hostId); + expect(host.kid).toBe(thumbprint); + }); + + it("derives the thumbprint on /host/enroll", async () => { + const { client, sessionCookie, getHost } = await createTestContext({ + providerName: "test-service", + }); + + const provisionRes = await client.authedPost( + "/host/create", + { name: "Pre-enrolled kid-less host" }, + sessionCookie, + ); + const { hostId, enrollmentToken } = await json<{ + hostId: string; + enrollmentToken: string; + }>(provisionRes); + + const hostKeypair = await generateTestKeypair(); + const thumbprint = await computeThumbprint(hostKeypair.publicKey); + + const enrollRes = await client.api("/host/enroll", { + method: "POST", + body: JSON.stringify({ + token: enrollmentToken, + public_key: hostKeypair.publicKey, + }), + }); + const enrollBody = await json>(enrollRes); + expect(enrollRes.ok, JSON.stringify(enrollBody)).toBe(true); + + const host = await getHost(hostId); + expect(host.kid).toBe(thumbprint); + }); +}); diff --git a/packages/agent-auth/src/routes/claim.ts b/packages/agent-auth/src/routes/claim.ts index c77dddf6f..4c948fd0b 100644 --- a/packages/agent-auth/src/routes/claim.ts +++ b/packages/agent-auth/src/routes/claim.ts @@ -6,7 +6,7 @@ import { TABLE, CLOCK_SKEW_TOLERANCE_SEC } from "../constants"; import { agentError, AGENT_AUTH_ERROR_CODES as ERR } from "../errors"; import { emit } from "../emit"; import { sanitizeDisplayText, DISPLAY_LIMITS } from "../utils/sanitize"; -import { verifyJWT } from "../utils/crypto"; +import { resolveHostKid, verifyJWT } from "../utils/crypto"; import type { JtiCacheStore } from "../utils/jti-cache"; import type { JwksCacheStore } from "../utils/jwks-cache"; import { MemoryJwksCache } from "../utils/jwks-cache"; @@ -214,7 +214,7 @@ export function claimAgent( hostRecord = existingHost; } else { const hostNow = new Date(); - const hostKid = resolvedHostPubKey.kid ?? null; + const hostKid = await resolveHostKid(resolvedHostPubKey); const jwtHostName = typeof decoded.host_name === "string" ? decoded.host_name : null; const dynCaps = await resolveDefaultHostCapabilities(opts, { ctx, diff --git a/packages/agent-auth/src/routes/host/create.ts b/packages/agent-auth/src/routes/host/create.ts index bb0a7f787..a1d1d5904 100644 --- a/packages/agent-auth/src/routes/host/create.ts +++ b/packages/agent-auth/src/routes/host/create.ts @@ -5,6 +5,7 @@ import { TABLE, DEFAULTS } from "../../constants"; import { agentError, AGENT_AUTH_ERROR_CODES as ERR } from "../../errors"; import { emit } from "../../emit"; import { generateEnrollmentToken } from "../../utils/approval"; +import { resolveHostKid } from "../../utils/crypto"; import type { AgentHost, ResolvedAgentAuthOptions } from "../../types"; import { findHostByKey, @@ -76,7 +77,7 @@ export function createHost(opts: ResolvedAgentAuthOptions) { await validateCapabilitiesExist(defaultCapabilityIds, opts); const now = new Date(); - const kid = publicKey ? ((publicKey.kid as string | undefined) ?? null) : null; + const kid = publicKey ? await resolveHostKid(publicKey) : null; const expiresAt = !isEnrollmentFlow && opts.agentSessionTTL > 0 ? new Date(now.getTime() + opts.agentSessionTTL * 1000) diff --git a/packages/agent-auth/src/routes/host/enroll.ts b/packages/agent-auth/src/routes/host/enroll.ts index 5cd5add63..e9414c861 100644 --- a/packages/agent-auth/src/routes/host/enroll.ts +++ b/packages/agent-auth/src/routes/host/enroll.ts @@ -5,6 +5,7 @@ import { agentError, AGENT_AUTH_ERROR_CODES as ERR } from "../../errors"; import { emit } from "../../emit"; import { hashToken } from "../../utils/approval"; import { parseCapabilityIds } from "../../utils/capabilities"; +import { resolveHostKid } from "../../utils/crypto"; import type { Agent, AgentHost, ResolvedAgentAuthOptions } from "../../types"; import { claimAutonomousAgents, findHostByKey, validateKeyAlgorithm } from "../_helpers"; @@ -62,7 +63,7 @@ export function enrollHost(opts: ResolvedAgentAuthOptions) { } const now = new Date(); - const kid = (publicKey.kid as string | undefined) ?? null; + const kid = await resolveHostKid(publicKey); const expiresAt = opts.agentSessionTTL > 0 ? new Date(now.getTime() + opts.agentSessionTTL * 1000) : null; diff --git a/packages/agent-auth/src/routes/host/rotate-key.ts b/packages/agent-auth/src/routes/host/rotate-key.ts index f68e7f161..2bffad813 100644 --- a/packages/agent-auth/src/routes/host/rotate-key.ts +++ b/packages/agent-auth/src/routes/host/rotate-key.ts @@ -127,12 +127,11 @@ export function rotateHostKey( validateKeyAlgorithm(publicKey, opts.allowedKeyAlgorithms); - const kid = (publicKey.kid as string | undefined) ?? null; - // §8.7: Host ID is derived from JWK thumbprint — must update on rotation const newThumbprint = await calculateJwkThumbprint( publicKey as Parameters[0], ); + const kid = (publicKey.kid as string | undefined) ?? newThumbprint; const oldHostId = host.id; const newHostId = newThumbprint; diff --git a/packages/agent-auth/src/routes/host/update.ts b/packages/agent-auth/src/routes/host/update.ts index 8bd1220a5..1484e0409 100644 --- a/packages/agent-auth/src/routes/host/update.ts +++ b/packages/agent-auth/src/routes/host/update.ts @@ -5,6 +5,7 @@ import { TABLE } from "../../constants"; import { agentError, AGENT_AUTH_ERROR_CODES as ERR } from "../../errors"; import { emit } from "../../emit"; import { parseCapabilityIds } from "../../utils/capabilities"; +import { resolveHostKid } from "../../utils/crypto"; import type { AgentHost, ResolvedAgentAuthOptions } from "../../types"; import { checkSharedOrg, @@ -89,7 +90,7 @@ export function updateHost(opts: ResolvedAgentAuthOptions) { } validateKeyAlgorithm(publicKey, opts.allowedKeyAlgorithms); update.publicKey = JSON.stringify(publicKey); - update.kid = (publicKey.kid as string | undefined) ?? null; + update.kid = await resolveHostKid(publicKey); } if (jwksUrl !== undefined) { diff --git a/packages/agent-auth/src/routes/register.ts b/packages/agent-auth/src/routes/register.ts index 5b37e750d..532742b17 100644 --- a/packages/agent-auth/src/routes/register.ts +++ b/packages/agent-auth/src/routes/register.ts @@ -6,7 +6,7 @@ import { TABLE, CLOCK_SKEW_TOLERANCE_SEC } from "../constants"; import { agentError, AGENT_AUTH_ERROR_CODES as ERR } from "../errors"; import { emit } from "../emit"; import { hasCapability, parseCapabilityIds } from "../utils/capabilities"; -import { verifyJWT } from "../utils/crypto"; +import { resolveHostKid, verifyJWT } from "../utils/crypto"; import { sanitizeDisplayText, DISPLAY_LIMITS } from "../utils/sanitize"; import type { JwksCacheStore } from "../utils/jwks-cache"; import { MemoryJwksCache } from "../utils/jwks-cache"; @@ -420,7 +420,7 @@ export function register( } else { const isAutonomous = mode === "autonomous"; const hostNow = new Date(); - const hostKid = resolvedHostPubKey.kid ?? null; + const hostKid = await resolveHostKid(resolvedHostPubKey); const jwtHostName = typeof decoded.host_name === "string" ? decoded.host_name : null; const resolvedDynHostName = jwtHostName ?? bodyHostName ?? null; const dynCaps = await resolveDefaultHostCapabilities(opts, { diff --git a/packages/agent-auth/src/utils/crypto.ts b/packages/agent-auth/src/utils/crypto.ts index eba9f0033..27ba27978 100644 --- a/packages/agent-auth/src/utils/crypto.ts +++ b/packages/agent-auth/src/utils/crypto.ts @@ -1,4 +1,5 @@ import { + calculateJwkThumbprint, exportJWK, generateKeyPair, jwtVerify, @@ -138,3 +139,17 @@ export async function verifyJWT(opts: VerifyJWTOptions): Promise): Promise { + if (typeof publicKey.kid === "string") return publicKey.kid; + return calculateJwkThumbprint(publicKey as Parameters[0]); +}