From ebc2c255a0d77cd5745c99423459d6b888dd105c Mon Sep 17 00:00:00 2001 From: Alp Karavil <46430499+Karavil@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:20:40 -0400 Subject: [PATCH 1/2] fix: derive kid from JWK thumbprint when the key omits one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `kid` is OPTIONAL in a JWK (RFC 7517 §4.5), but every host write path persisted `publicKey.kid ?? null`. A client that legitimately omits the member was stored with `kid = null`, and since the middleware resolves a host by `findHostById(iss) ?? findHostByKid(iss)`, its thumbprint `iss` matched neither lookup. Every authenticated request then failed with AGENT_NOT_FOUND, permanently. RFC 7638 §3.1 blesses the JWK thumbprint as a `kid` value, and the SDK already stamps `kid = thumbprint` at keygen — which is why the gap is invisible to hosts built with it. Deriving the same value server-side when `kid` is absent keeps the stored lookup key aligned with how a host identifies itself. Applies to /agent/register, /agent/claim, /host/create, /host/enroll, /host/update, and /host/rotate-key. A JWK that carries an explicit `kid` keeps it unchanged. --- .../src/__tests__/kidless-host-jwk.test.ts | 234 ++++++++++++++++++ packages/agent-auth/src/routes/claim.ts | 4 +- packages/agent-auth/src/routes/host/create.ts | 3 +- packages/agent-auth/src/routes/host/enroll.ts | 3 +- .../agent-auth/src/routes/host/rotate-key.ts | 3 +- packages/agent-auth/src/routes/host/update.ts | 3 +- packages/agent-auth/src/routes/register.ts | 4 +- packages/agent-auth/src/utils/crypto.ts | 15 ++ 8 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 packages/agent-auth/src/__tests__/kidless-host-jwk.test.ts 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..e54aa9703 --- /dev/null +++ b/packages/agent-auth/src/__tests__/kidless-host-jwk.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "vitest"; +import { getTestInstance } from "better-auth/test"; +import { + agentAuth, + agentAuthClientPlugin, + generateTestKeypair, + createHostJWT, + signTestJWT, + json, + createTestClient, + computeThumbprint, + BASE, +} from "./helpers"; +import type { AgentHost } 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", () => { + async function setup() { + const t = await getTestInstance( + { + plugins: [ + agentAuth({ + 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`, + }), + }), + ], + }, + { + 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)); + return { auth, client }; + } + + it("stores the JWK thumbprint as kid and authenticates the host on subsequent requests", async () => { + const { auth, client } = await setup(); + + 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 context = await auth.$context; + const host = await context.adapter.findOne({ + model: "agentHost", + where: [{ field: "id", value: registerBody.host_id }], + }); + expect(host).not.toBeNull(); + 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>(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 { auth, client } = await setup(); + + 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 context = await auth.$context; + const host = await context.adapter.findOne({ + model: "agentHost", + where: [{ field: "id", value: registerBody.host_id }], + }); + expect(host).not.toBeNull(); + 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 t = await getTestInstance( + { + plugins: [agentAuth({ providerName: "test-service" })], + }, + { + 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("cookie") ?? ""; + + 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 context = await auth.$context; + const host = await context.adapter.findOne({ + model: "agentHost", + where: [{ field: "id", value: hostId }], + }); + expect(host).not.toBeNull(); + expect(host!.kid).toBe(thumbprint); + }); + + it("derives the thumbprint on /host/enroll", async () => { + const t = await getTestInstance( + { + plugins: [agentAuth({ providerName: "test-service" })], + }, + { + 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("cookie") ?? ""; + + 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 context = await auth.$context; + const host = await context.adapter.findOne({ + model: "agentHost", + where: [{ field: "id", value: hostId }], + }); + expect(host).not.toBeNull(); + 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]); +} From 4b834ab933eb3fc08f288b88afeeae573c45e536 Mon Sep 17 00:00:00 2001 From: Alp Karavil <46430499+Karavil@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:46:12 -0400 Subject: [PATCH 2/2] test: use the shared test context in the kid-less host suite Replaces the duplicated getTestInstance setup and the `as any` casts with createTestContext, and adds a getHost helper so host row assertions no longer need non-null assertions. Behaviour and coverage are unchanged. --- packages/agent-auth/src/__tests__/helpers.ts | 43 ++++--- .../src/__tests__/kidless-host-jwk.test.ts | 120 +++++------------- 2 files changed, 58 insertions(+), 105 deletions(-) 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 index e54aa9703..5287dfefc 100644 --- a/packages/agent-auth/src/__tests__/kidless-host-jwk.test.ts +++ b/packages/agent-auth/src/__tests__/kidless-host-jwk.test.ts @@ -1,17 +1,14 @@ import { describe, expect, it } from "vitest"; -import { getTestInstance } from "better-auth/test"; import { - agentAuth, - agentAuthClientPlugin, generateTestKeypair, createHostJWT, signTestJWT, json, - createTestClient, + createTestContext, computeThumbprint, BASE, } from "./helpers"; -import type { AgentHost } from "../types"; +import type { AgentAuthOptions } from "../types"; /** * Regression: hosts registering with a kid-less JWK must remain findable. @@ -29,35 +26,20 @@ import type { AgentHost } from "../types"; * Fix: derive and persist the thumbprint whenever `kid` is absent. */ describe("dynamic host registration — kid-less JWK", () => { - async function setup() { - const t = await getTestInstance( - { - plugins: [ - agentAuth({ - 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`, - }), - }), - ], - }, - { - 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)); - return { auth, client }; - } + 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 { auth, client } = await setup(); + const { client, getHost } = await createTestContext(DYNAMIC_REGISTRATION_OPTIONS); const hostKeypair = await generateTestKeypair(); const agentKeypair = await generateTestKeypair(); @@ -82,13 +64,8 @@ describe("dynamic host registration — kid-less JWK", () => { expect(registerRes.ok, JSON.stringify(registerBody)).toBe(true); // The stored row carries the derived thumbprint, not null. - const context = await auth.$context; - const host = await context.adapter.findOne({ - model: "agentHost", - where: [{ field: "id", value: registerBody.host_id }], - }); - expect(host).not.toBeNull(); - expect(host!.kid).toBe(thumbprint); + 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 @@ -104,13 +81,13 @@ describe("dynamic host registration — kid-less JWK", () => { method: "GET", headers: { authorization: `Bearer ${followUpJWT}` }, }); - const statusBody = await json>(statusRes); + 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 { auth, client } = await setup(); + const { client, getHost } = await createTestContext(DYNAMIC_REGISTRATION_OPTIONS); const hostKeypair = await generateTestKeypair(); const agentKeypair = await generateTestKeypair(); @@ -132,13 +109,8 @@ describe("dynamic host registration — kid-less JWK", () => { const registerBody = await json<{ agent_id: string; host_id: string }>(registerRes); expect(registerRes.ok, JSON.stringify(registerBody)).toBe(true); - const context = await auth.$context; - const host = await context.adapter.findOne({ - model: "agentHost", - where: [{ field: "id", value: registerBody.host_id }], - }); - expect(host).not.toBeNull(); - expect(host!.kid).toBe(explicitKid); + const host = await getHost(registerBody.host_id); + expect(host.kid).toBe(explicitKid); }); }); @@ -149,20 +121,9 @@ describe("dynamic host registration — kid-less JWK", () => { */ describe("host provisioning — kid-less JWK", () => { it("derives the thumbprint on /host/create", async () => { - const t = await getTestInstance( - { - plugins: [agentAuth({ providerName: "test-service" })], - }, - { - 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("cookie") ?? ""; + const { client, sessionCookie, getHost } = await createTestContext({ + providerName: "test-service", + }); const hostKeypair = await generateTestKeypair(); const thumbprint = await computeThumbprint(hostKeypair.publicKey); @@ -175,30 +136,14 @@ describe("host provisioning — kid-less JWK", () => { expect(createRes.ok).toBe(true); const { hostId } = await json<{ hostId: string }>(createRes); - const context = await auth.$context; - const host = await context.adapter.findOne({ - model: "agentHost", - where: [{ field: "id", value: hostId }], - }); - expect(host).not.toBeNull(); - expect(host!.kid).toBe(thumbprint); + const host = await getHost(hostId); + expect(host.kid).toBe(thumbprint); }); it("derives the thumbprint on /host/enroll", async () => { - const t = await getTestInstance( - { - plugins: [agentAuth({ providerName: "test-service" })], - }, - { - 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("cookie") ?? ""; + const { client, sessionCookie, getHost } = await createTestContext({ + providerName: "test-service", + }); const provisionRes = await client.authedPost( "/host/create", @@ -223,12 +168,7 @@ describe("host provisioning — kid-less JWK", () => { const enrollBody = await json>(enrollRes); expect(enrollRes.ok, JSON.stringify(enrollBody)).toBe(true); - const context = await auth.$context; - const host = await context.adapter.findOne({ - model: "agentHost", - where: [{ field: "id", value: hostId }], - }); - expect(host).not.toBeNull(); - expect(host!.kid).toBe(thumbprint); + const host = await getHost(hostId); + expect(host.kid).toBe(thumbprint); }); });