diff --git a/examples/local-did-host/package.json b/examples/local-did-host/package.json index ba57666..6163283 100644 --- a/examples/local-did-host/package.json +++ b/examples/local-did-host/package.json @@ -31,5 +31,8 @@ "@repo/api-utils": "workspace:*", "hono": "catalog:", "valibot": "catalog:" + }, + "devDependencies": { + "vite-tsconfig-paths": "6.1.1" } } diff --git a/examples/local-did-host/src/index.test.ts b/examples/local-did-host/src/index.test.ts new file mode 100644 index 0000000..0271e97 --- /dev/null +++ b/examples/local-did-host/src/index.test.ts @@ -0,0 +1,104 @@ +import { randomBytes } from "node:crypto" + +import { type DidDocument, getDidResolver } from "@agentcommercekit/did" +import { verifyJwt } from "@agentcommercekit/jwt" +import { beforeEach, describe, expect, it } from "vitest" + +import app from "./index" + +type Entity = "agent" | "controller" + +function randomHexPrivateKey(): `0x${string}` { + return `0x${randomBytes(32).toString("hex")}` +} + +async function getDidDocument(entity: Entity): Promise { + const res = await app.request(`/${entity}/.well-known/did.json`) + expect(res.status).toBe(200) + return res.json() +} + +async function signAs(entity: Entity): Promise { + const res = await app.request(`/${entity}/sign`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + subject: "did:web:subject.example.com", + payload: {}, + }), + }) + expect(res.status).toBe(200) + const body: { jwt: string } = await res.json() + return body.jwt +} + +describe("POST /:entity/sign", () => { + beforeEach(() => { + // Each identity needs its own distinct private key so that a bug which + // conflates the two entities is actually observable in the resulting + // JWT (same key material would make agent- and controller-signed JWTs + // indistinguishable). + process.env.HOSTNAME = "0.0.0.0" + process.env.PORT = "3458" + process.env.AGENT_PRIVATE_KEY = randomHexPrivateKey() + process.env.CONTROLLER_PRIVATE_KEY = randomHexPrivateKey() + }) + + it("issues a JWT from the agent identity when signing as agent", async () => { + const agentDidDocument = await getDidDocument("agent") + const controllerDidDocument = await getDidDocument("controller") + + const resolver = getDidResolver() + resolver.addToCache(agentDidDocument.id, agentDidDocument) + resolver.addToCache(controllerDidDocument.id, controllerDidDocument) + + const jwt = await signAs("agent") + const verified = await verifyJwt(jwt, { resolver }) + + expect(verified.payload.iss).toBe(agentDidDocument.id) + expect(verified.issuer).toBe(agentDidDocument.id) + }) + + it("issues a JWT from the controller identity when signing as controller", async () => { + const agentDidDocument = await getDidDocument("agent") + const controllerDidDocument = await getDidDocument("controller") + + // Sanity check: the two identities must actually be distinct, or this + // test can't tell agent-signed and controller-signed JWTs apart. + expect(controllerDidDocument.id).not.toBe(agentDidDocument.id) + + const resolver = getDidResolver() + resolver.addToCache(agentDidDocument.id, agentDidDocument) + resolver.addToCache(controllerDidDocument.id, controllerDidDocument) + + const jwt = await signAs("controller") + + // This is the core regression check: the JWT returned by + // POST /controller/sign must actually verify against the controller's + // key and carry the controller's DID as issuer, not the agent's. + const verified = await verifyJwt(jwt, { resolver }) + + expect(verified.payload.iss).toBe(controllerDidDocument.id) + expect(verified.issuer).toBe(controllerDidDocument.id) + + // Verifying against the agent's DID must fail: the signature was not + // produced by the agent's key, and the JWT's `iss` claim doesn't match + // the agent's DID either. + await expect( + verifyJwt(jwt, { resolver, issuer: agentDidDocument.id }), + ).rejects.toThrow("Expected issuer") + }) + + it("produces different signatures for agent and controller for the same payload", async () => { + const agentJwt = await signAs("agent") + const controllerJwt = await signAs("controller") + + // Compare the signature segment specifically, not the full JWT string, + // since the header and payload already differ (different `iss`) even + // if the signatures happened to collide. + const agentSignature = agentJwt.split(".")[2] + const controllerSignature = controllerJwt.split(".")[2] + + expect(agentSignature).not.toBe(controllerSignature) + }) +}) diff --git a/examples/local-did-host/src/index.ts b/examples/local-did-host/src/index.ts index 77e0ae6..c7e00ef 100644 --- a/examples/local-did-host/src/index.ts +++ b/examples/local-did-host/src/index.ts @@ -50,7 +50,8 @@ app.post( }), ), async (c) => { - const { signer, did, alg } = c.get("identities").agent + const { entity } = c.req.valid("param") + const { signer, did, alg } = c.get("identities")[entity] const { subject, payload, audience, expiresIn } = c.req.valid("json") const jwt = await createJwt( @@ -60,12 +61,14 @@ app.post( aud: audience, }, { - alg, issuer: did, expiresIn, signer, canonicalize: true, }, + { + alg, + }, ) return c.json({ jwt }) diff --git a/examples/local-did-host/vitest.config.ts b/examples/local-did-host/vitest.config.ts index 7972f77..5cef97d 100644 --- a/examples/local-did-host/vitest.config.ts +++ b/examples/local-did-host/vitest.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from "vitest/config" +import tsconfigPaths from "vite-tsconfig-paths" export default defineConfig({ + plugins: [tsconfigPaths()], test: { passWithNoTests: true, watch: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 856c25e..53673c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -300,6 +300,10 @@ importers: valibot: specifier: 'catalog:' version: 1.4.1(typescript@6.0.3) + devDependencies: + vite-tsconfig-paths: + specifier: 6.1.1 + version: 6.1.1(typescript@6.0.3)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) examples/verifier: dependencies: