Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 28 additions & 15 deletions packages/agent-auth/src/__tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -192,26 +192,19 @@ 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)],
},
{
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<Record<string, unknown>>(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;
Expand All @@ -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<AgentHost> {
const context = await auth.$context;
const host = await context.adapter.findOne<AgentHost>({
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: {
Expand Down Expand Up @@ -260,6 +272,7 @@ export async function createTestContext(pluginOpts?: AgentAuthOptions) {
sessionCookie,
userId,
createHost,
getHost,
registerAgent,
};
}
Expand All @@ -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<string> {
return calculateJwkThumbprint(publicKey as Parameters<typeof calculateJwkThumbprint>[0]);
return calculateJwkThumbprint(publicKey);
}
174 changes: 174 additions & 0 deletions packages/agent-auth/src/__tests__/kidless-host-jwk.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>(enrollRes);
expect(enrollRes.ok, JSON.stringify(enrollBody)).toBe(true);

const host = await getHost(hostId);
expect(host.kid).toBe(thumbprint);
});
});
4 changes: 2 additions & 2 deletions packages/agent-auth/src/routes/claim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-auth/src/routes/host/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-auth/src/routes/host/enroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;

Expand Down
3 changes: 1 addition & 2 deletions packages/agent-auth/src/routes/host/rotate-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof calculateJwkThumbprint>[0],
);
const kid = (publicKey.kid as string | undefined) ?? newThumbprint;
const oldHostId = host.id;
const newHostId = newThumbprint;

Expand Down
3 changes: 2 additions & 1 deletion packages/agent-auth/src/routes/host/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-auth/src/routes/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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, {
Expand Down
15 changes: 15 additions & 0 deletions packages/agent-auth/src/utils/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
calculateJwkThumbprint,
exportJWK,
generateKeyPair,
jwtVerify,
Expand Down Expand Up @@ -138,3 +139,17 @@ export async function verifyJWT(opts: VerifyJWTOptions): Promise<Record<string,
throw err;
}
}

/**
* Resolve the lookup key for a host JWK.
*
* `kid` is OPTIONAL in a JWK (RFC 7517 §4.5), so a spec-compliant client may
* omit it. Such a client still needs a stable identifier, and RFC 7638 §3.1
* blesses the JWK thumbprint for exactly that purpose — which is what the SDK
* sends as `iss`. Persisting the thumbprint when `kid` is absent keeps the
* stored lookup key aligned with how the host identifies itself.
*/
export async function resolveHostKid(publicKey: Record<string, unknown>): Promise<string> {
if (typeof publicKey.kid === "string") return publicKey.kid;
return calculateJwkThumbprint(publicKey as Parameters<typeof calculateJwkThumbprint>[0]);
}