From ea70b1f5be12e23baff36f1b32fe41df83e99a83 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Wed, 22 Jul 2026 02:53:47 +0300 Subject: [PATCH 1/6] feat(jwt): add optional requireAudience to verifyJwt --- .changeset/jwt-require-audience.md | 9 +++ packages/jwt/src/verify.test.ts | 116 +++++++++++++++++++++++++++++ packages/jwt/src/verify.ts | 28 ++++++- 3 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 .changeset/jwt-require-audience.md diff --git a/.changeset/jwt-require-audience.md b/.changeset/jwt-require-audience.md new file mode 100644 index 0000000..bbbbab9 --- /dev/null +++ b/.changeset/jwt-require-audience.md @@ -0,0 +1,9 @@ +--- +"@agentcommercekit/jwt": minor +--- + +Add an optional `requireAudience` to `verifyJwt`. `did-jwt` only validates the +`aud` claim when the token carries one, so a token that omits `aud` verifies +even when the caller supplies an `audience`, allowing cross-service replay. +Setting `requireAudience: true` rejects tokens with no audience. It defaults to +off, so existing behaviour is unchanged. diff --git a/packages/jwt/src/verify.test.ts b/packages/jwt/src/verify.test.ts index 62b81e8..389fa1d 100644 --- a/packages/jwt/src/verify.test.ts +++ b/packages/jwt/src/verify.test.ts @@ -20,6 +20,8 @@ describe("verifyJwt()", () => { let signer: ReturnType beforeEach(async () => { + // Clear call history so per-test call-index assertions see only this test. + vi.mocked(verifyJWT).mockClear() keypair = await generateKeypair("secp256k1") signer = createJwtSigner(keypair) }) @@ -109,6 +111,120 @@ describe("verifyJwt()", () => { expect(result.payload.iss).toBe("did:example:issuer") }) + it("forwards the audience and passes when the aud claim is present", async () => { + // The realistic path: the caller supplies `audience` (which did-jwt + // matches) and `requireAudience` on top (which rejects an absent aud). + const jwt = await createJwt( + { sub: "did:example:subject", aud: "did:example:audience" }, + { issuer: "did:example:issuer", signer }, + ) + + const mockVerifiedResult: JWTVerified = { + verified: true, + payload: { + iss: "did:example:issuer", + sub: "did:example:subject", + aud: "did:example:audience", + }, + didResolutionResult: { + didResolutionMetadata: {}, + didDocument: null, + didDocumentMetadata: {}, + }, + issuer: "did:example:issuer", + signer: { + id: "did:example:issuer#key-1", + type: "Multikey", + controller: "did:example:issuer", + publicKeyHex: "02...", + }, + jwt, + } + + vi.mocked(verifyJWT).mockResolvedValueOnce(mockVerifiedResult) + + const result = await verifyJwt(jwt, { + audience: "did:example:audience", + requireAudience: true, + }) + + // `audience` is forwarded to did-jwt, `requireAudience` is not. + expect(verifyJWT).toHaveBeenCalledWith( + jwt, + expect.objectContaining({ audience: "did:example:audience" }), + ) + expect(vi.mocked(verifyJWT).mock.calls[0]?.[1]).not.toHaveProperty( + "requireAudience", + ) + expect(result.payload.aud).toBe("did:example:audience") + }) + + it("throws when requireAudience is set and the aud claim is an empty array", async () => { + const jwt = await createJwt( + { sub: "did:example:subject" }, + { issuer: "did:example:issuer", signer }, + ) + + const mockVerifiedResult: JWTVerified = { + verified: true, + payload: { + iss: "did:example:issuer", + sub: "did:example:subject", + aud: [], + }, + didResolutionResult: { + didResolutionMetadata: {}, + didDocument: null, + didDocumentMetadata: {}, + }, + issuer: "did:example:issuer", + signer: { + id: "did:example:issuer#key-1", + type: "Multikey", + controller: "did:example:issuer", + publicKeyHex: "02...", + }, + jwt, + } + + vi.mocked(verifyJWT).mockResolvedValueOnce(mockVerifiedResult) + + await expect(verifyJwt(jwt, { requireAudience: true })).rejects.toThrow( + "JWT audience is required but missing", + ) + }) + + it("throws when requireAudience is set and the aud claim is missing", async () => { + const jwt = await createJwt( + { sub: "did:example:subject" }, + { issuer: "did:example:issuer", signer }, + ) + + const mockVerifiedResult: JWTVerified = { + verified: true, + payload: { iss: "did:example:issuer", sub: "did:example:subject" }, + didResolutionResult: { + didResolutionMetadata: {}, + didDocument: null, + didDocumentMetadata: {}, + }, + issuer: "did:example:issuer", + signer: { + id: "did:example:issuer#key-1", + type: "Multikey", + controller: "did:example:issuer", + publicKeyHex: "02...", + }, + jwt, + } + + vi.mocked(verifyJWT).mockResolvedValueOnce(mockVerifiedResult) + + await expect(verifyJwt(jwt, { requireAudience: true })).rejects.toThrow( + "JWT audience is required but missing", + ) + }) + it("throws error when issuer does not match expected issuer", async () => { const jwt = await createJwt( { diff --git a/packages/jwt/src/verify.ts b/packages/jwt/src/verify.ts index 4d5f9ec..32ffc61 100644 --- a/packages/jwt/src/verify.ts +++ b/packages/jwt/src/verify.ts @@ -4,14 +4,34 @@ export type JwtVerified = JWTVerified export type VerifyJwtOptions = JWTVerifyOptions & { issuer?: string + /** + * Require the JWT to carry a non-empty `aud` claim. `did-jwt` only runs its + * audience check when the token has an `aud`, so a token that omits it is + * accepted even when an `audience` is supplied. Setting this ensures the + * audience check actually runs; supply `audience` for the value to be + * matched. + */ + requireAudience?: boolean +} + +/** Whether a JWT `aud` claim carries at least one non-empty audience. */ +function hasAudience(aud: string | string[] | undefined): boolean { + if (typeof aud === "string") { + return aud.length > 0 + } + if (Array.isArray(aud)) { + return aud.some((entry) => entry.length > 0) + } + return false } /** - * Verify a JWT, with additional options to restrict to a specific issuer + * Verify a JWT, with additional options to restrict to a specific issuer and + * to require an audience claim. */ export async function verifyJwt( jwt: string, - { issuer, ...options }: VerifyJwtOptions = {}, + { issuer, requireAudience, ...options }: VerifyJwtOptions = {}, ): Promise { const result = await verifyJWT(jwt, options) @@ -19,5 +39,9 @@ export async function verifyJwt( throw new Error(`Expected issuer ${issuer}, got ${result.payload.iss}`) } + if (requireAudience && !hasAudience(result.payload.aud)) { + throw new Error("JWT audience is required but missing") + } + return result } From a5a6d0ddcadc29a10683bb678d40eed65ec28937 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Thu, 30 Jul 2026 16:50:40 +0300 Subject: [PATCH 2/6] feat(jwt)!: require aud when an audience is expected Per review: drop the standalone requireAudience flag. Supplying audience to verifyJwt now fails a token whose aud claim is missing or empty, matching jose and PyJWT semantics; did-jwt alone only matches aud when present. A2A call sites differ and are handled accordingly: - handshake verification keeps audience: did, since handshake JWTs have embedded aud (aud: params.recipient) since the original implementation - signed-message verification drops audience: did, since signed messages carry no aud (createSignedA2AMessage has no recipient parameter) and the option never provided a check there Changeset rewritten for the new semantics; tests updated on both packages. --- .changeset/jwt-require-audience.md | 16 ++++++--- packages/ack-id/src/a2a/verify.test.ts | 5 +-- packages/ack-id/src/a2a/verify.ts | 6 ++-- packages/jwt/src/verify.test.ts | 50 ++++++++++++++++++-------- packages/jwt/src/verify.ts | 20 +++++------ 5 files changed, 62 insertions(+), 35 deletions(-) diff --git a/.changeset/jwt-require-audience.md b/.changeset/jwt-require-audience.md index bbbbab9..cd361d9 100644 --- a/.changeset/jwt-require-audience.md +++ b/.changeset/jwt-require-audience.md @@ -1,9 +1,15 @@ --- "@agentcommercekit/jwt": minor +"@agentcommercekit/ack-id": patch --- -Add an optional `requireAudience` to `verifyJwt`. `did-jwt` only validates the -`aud` claim when the token carries one, so a token that omits `aud` verifies -even when the caller supplies an `audience`, allowing cross-service replay. -Setting `requireAudience: true` rejects tokens with no audience. It defaults to -off, so existing behaviour is unchanged. +`verifyJwt` now fails when an `audience` is supplied and the token carries no +non-empty `aud` claim, matching jose and PyJWT semantics. `did-jwt` only +validates `aud` when the token carries one, so previously a token that omitted +`aud` verified even when the caller expected an audience, allowing +cross-service replay. There is no flag: supplying `audience` is the signal. +Callers that accept audience-less tokens should omit the `audience` option. + +`verifyA2ASignedMessage` no longer passes `audience` to `verifyJwt`: signed +A2A messages do not carry an `aud` claim, so the option never provided any +check there. The handshake flow is unchanged and still verifies `aud`. diff --git a/packages/ack-id/src/a2a/verify.test.ts b/packages/ack-id/src/a2a/verify.test.ts index 9735113..3616321 100644 --- a/packages/ack-id/src/a2a/verify.test.ts +++ b/packages/ack-id/src/a2a/verify.test.ts @@ -174,7 +174,7 @@ describe("verifyA2ASignedMessage", () => { expect(result.verified).toBe(true) }) - it("requires audience=self and issuer=counterparty for signature verification", async () => { + it("requires issuer=counterparty for signature verification", async () => { mockValidSignature() await verifyA2ASignedMessage(signedMessage("hello", "the.sig"), { @@ -182,8 +182,9 @@ describe("verifyA2ASignedMessage", () => { counterparty: userDid, }) + // Signed messages carry no aud claim today, so no audience is expected; + // the handshake flow embeds and verifies aud. expect(verifyJwt).toHaveBeenCalledWith("the.sig", { - audience: agentDid, issuer: userDid, resolver: expect.anything(), }) diff --git a/packages/ack-id/src/a2a/verify.ts b/packages/ack-id/src/a2a/verify.ts index 47d9c3b..f15c1f7 100644 --- a/packages/ack-id/src/a2a/verify.ts +++ b/packages/ack-id/src/a2a/verify.ts @@ -74,9 +74,11 @@ export async function verifyA2ASignedMessage( } = v.parse(messageWithSignatureSchema, message) // Parse the signature from the message metadata, ensuring it is - // signed by the counterparty and intended for the provided DID + // signed by the counterparty. Signed messages do not carry an `aud` + // claim today (`createSignedA2AMessage` has no recipient parameter), so + // no audience is expected here; the handshake path above does embed and + // verify `aud`. const verified = await verifyJwt(metadata.sig, { - audience: did, issuer: counterparty, resolver, }) diff --git a/packages/jwt/src/verify.test.ts b/packages/jwt/src/verify.test.ts index 389fa1d..85ce3e7 100644 --- a/packages/jwt/src/verify.test.ts +++ b/packages/jwt/src/verify.test.ts @@ -112,8 +112,8 @@ describe("verifyJwt()", () => { }) it("forwards the audience and passes when the aud claim is present", async () => { - // The realistic path: the caller supplies `audience` (which did-jwt - // matches) and `requireAudience` on top (which rejects an absent aud). + // The realistic path: the caller supplies `audience`; did-jwt matches + // the value and verifyJwt rejects an absent aud. const jwt = await createJwt( { sub: "did:example:subject", aud: "did:example:audience" }, { issuer: "did:example:issuer", signer }, @@ -145,21 +145,16 @@ describe("verifyJwt()", () => { const result = await verifyJwt(jwt, { audience: "did:example:audience", - requireAudience: true, }) - // `audience` is forwarded to did-jwt, `requireAudience` is not. expect(verifyJWT).toHaveBeenCalledWith( jwt, expect.objectContaining({ audience: "did:example:audience" }), ) - expect(vi.mocked(verifyJWT).mock.calls[0]?.[1]).not.toHaveProperty( - "requireAudience", - ) expect(result.payload.aud).toBe("did:example:audience") }) - it("throws when requireAudience is set and the aud claim is an empty array", async () => { + it("throws when an audience is expected and the aud claim is an empty array", async () => { const jwt = await createJwt( { sub: "did:example:subject" }, { issuer: "did:example:issuer", signer }, @@ -189,12 +184,12 @@ describe("verifyJwt()", () => { vi.mocked(verifyJWT).mockResolvedValueOnce(mockVerifiedResult) - await expect(verifyJwt(jwt, { requireAudience: true })).rejects.toThrow( - "JWT audience is required but missing", - ) + await expect( + verifyJwt(jwt, { audience: "did:example:audience" }), + ).rejects.toThrow("JWT audience is required but missing") }) - it("throws when requireAudience is set and the aud claim is missing", async () => { + it("throws when an audience is expected and the aud claim is missing", async () => { const jwt = await createJwt( { sub: "did:example:subject" }, { issuer: "did:example:issuer", signer }, @@ -220,9 +215,36 @@ describe("verifyJwt()", () => { vi.mocked(verifyJWT).mockResolvedValueOnce(mockVerifiedResult) - await expect(verifyJwt(jwt, { requireAudience: true })).rejects.toThrow( - "JWT audience is required but missing", + await expect( + verifyJwt(jwt, { audience: "did:example:audience" }), + ).rejects.toThrow("JWT audience is required but missing") + }) + + it("does not require an aud claim when no audience is expected", async () => { + const jwt = await createJwt( + { sub: "did:example:subject" }, + { issuer: "did:example:issuer", signer }, ) + + const mockVerifiedResult: JWTVerified = { + verified: true, + payload: { iss: "did:example:issuer", sub: "did:example:subject" }, + didResolutionResult: { + didResolutionMetadata: {}, + didDocument: null, + didDocumentMetadata: {}, + }, + issuer: "did:example:issuer", + signer: { + id: "did:example:issuer#key-1", + type: "JsonWebKey2020", + controller: "did:example:issuer", + }, + jwt, + } + vi.mocked(verifyJWT).mockResolvedValueOnce(mockVerifiedResult) + + await expect(verifyJwt(jwt)).resolves.toMatchObject({ verified: true }) }) it("throws error when issuer does not match expected issuer", async () => { diff --git a/packages/jwt/src/verify.ts b/packages/jwt/src/verify.ts index 32ffc61..8f0d95f 100644 --- a/packages/jwt/src/verify.ts +++ b/packages/jwt/src/verify.ts @@ -4,14 +4,6 @@ export type JwtVerified = JWTVerified export type VerifyJwtOptions = JWTVerifyOptions & { issuer?: string - /** - * Require the JWT to carry a non-empty `aud` claim. `did-jwt` only runs its - * audience check when the token has an `aud`, so a token that omits it is - * accepted even when an `audience` is supplied. Setting this ensures the - * audience check actually runs; supply `audience` for the value to be - * matched. - */ - requireAudience?: boolean } /** Whether a JWT `aud` claim carries at least one non-empty audience. */ @@ -26,12 +18,16 @@ function hasAudience(aud: string | string[] | undefined): boolean { } /** - * Verify a JWT, with additional options to restrict to a specific issuer and - * to require an audience claim. + * Verify a JWT, with an additional option to restrict to a specific issuer. + * + * When an `audience` is supplied, a token without a non-empty `aud` claim + * fails verification. `did-jwt` only matches `aud` when the token carries + * one, so without this check a token that omits `aud` would verify even + * though the caller expected an audience. */ export async function verifyJwt( jwt: string, - { issuer, requireAudience, ...options }: VerifyJwtOptions = {}, + { issuer, ...options }: VerifyJwtOptions = {}, ): Promise { const result = await verifyJWT(jwt, options) @@ -39,7 +35,7 @@ export async function verifyJwt( throw new Error(`Expected issuer ${issuer}, got ${result.payload.iss}`) } - if (requireAudience && !hasAudience(result.payload.aud)) { + if (options.audience !== undefined && !hasAudience(result.payload.aud)) { throw new Error("JWT audience is required but missing") } From 8a1ef67a24fad10472bd52f81212330f0e695fdc Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Thu, 30 Jul 2026 16:57:38 +0300 Subject: [PATCH 3/6] test(jwt): cover empty-string and empty-entry aud variants Lock down both hasAudience branches: aud as "", [] and [""] all reject when an audience is expected. --- packages/jwt/src/verify.test.ts | 75 ++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/packages/jwt/src/verify.test.ts b/packages/jwt/src/verify.test.ts index 85ce3e7..24acbe9 100644 --- a/packages/jwt/src/verify.test.ts +++ b/packages/jwt/src/verify.test.ts @@ -154,40 +154,47 @@ describe("verifyJwt()", () => { expect(result.payload.aud).toBe("did:example:audience") }) - it("throws when an audience is expected and the aud claim is an empty array", async () => { - const jwt = await createJwt( - { sub: "did:example:subject" }, - { issuer: "did:example:issuer", signer }, - ) - - const mockVerifiedResult: JWTVerified = { - verified: true, - payload: { - iss: "did:example:issuer", - sub: "did:example:subject", - aud: [], - }, - didResolutionResult: { - didResolutionMetadata: {}, - didDocument: null, - didDocumentMetadata: {}, - }, - issuer: "did:example:issuer", - signer: { - id: "did:example:issuer#key-1", - type: "Multikey", - controller: "did:example:issuer", - publicKeyHex: "02...", - }, - jwt, - } - - vi.mocked(verifyJWT).mockResolvedValueOnce(mockVerifiedResult) - - await expect( - verifyJwt(jwt, { audience: "did:example:audience" }), - ).rejects.toThrow("JWT audience is required but missing") - }) + it.each([ + { label: "an empty array", aud: [] as string[] }, + { label: "an empty string", aud: "" }, + { label: "an array with only an empty string", aud: [""] }, + ])( + "throws when an audience is expected and the aud claim is $label", + async ({ aud }) => { + const jwt = await createJwt( + { sub: "did:example:subject" }, + { issuer: "did:example:issuer", signer }, + ) + + const mockVerifiedResult: JWTVerified = { + verified: true, + payload: { + iss: "did:example:issuer", + sub: "did:example:subject", + aud, + }, + didResolutionResult: { + didResolutionMetadata: {}, + didDocument: null, + didDocumentMetadata: {}, + }, + issuer: "did:example:issuer", + signer: { + id: "did:example:issuer#key-1", + type: "Multikey", + controller: "did:example:issuer", + publicKeyHex: "02...", + }, + jwt, + } + + vi.mocked(verifyJWT).mockResolvedValueOnce(mockVerifiedResult) + + await expect( + verifyJwt(jwt, { audience: "did:example:audience" }), + ).rejects.toThrow("JWT audience is required but missing") + }, + ) it("throws when an audience is expected and the aud claim is missing", async () => { const jwt = await createJwt( From e208acde4f3f8fbe8815e2cf280f17ba67a9e7f5 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Thu, 30 Jul 2026 17:49:37 +0300 Subject: [PATCH 4/6] test(jwt): fold the missing-aud case into the it.each --- packages/jwt/src/verify.test.ts | 37 ++++----------------------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/packages/jwt/src/verify.test.ts b/packages/jwt/src/verify.test.ts index 24acbe9..9da7073 100644 --- a/packages/jwt/src/verify.test.ts +++ b/packages/jwt/src/verify.test.ts @@ -154,8 +154,9 @@ describe("verifyJwt()", () => { expect(result.payload.aud).toBe("did:example:audience") }) - it.each([ - { label: "an empty array", aud: [] as string[] }, + it.each<{ label: string; aud: string | string[] | undefined }>([ + { label: "missing", aud: undefined }, + { label: "an empty array", aud: [] }, { label: "an empty string", aud: "" }, { label: "an array with only an empty string", aud: [""] }, ])( @@ -171,7 +172,7 @@ describe("verifyJwt()", () => { payload: { iss: "did:example:issuer", sub: "did:example:subject", - aud, + ...(aud === undefined ? {} : { aud }), }, didResolutionResult: { didResolutionMetadata: {}, @@ -196,36 +197,6 @@ describe("verifyJwt()", () => { }, ) - it("throws when an audience is expected and the aud claim is missing", async () => { - const jwt = await createJwt( - { sub: "did:example:subject" }, - { issuer: "did:example:issuer", signer }, - ) - - const mockVerifiedResult: JWTVerified = { - verified: true, - payload: { iss: "did:example:issuer", sub: "did:example:subject" }, - didResolutionResult: { - didResolutionMetadata: {}, - didDocument: null, - didDocumentMetadata: {}, - }, - issuer: "did:example:issuer", - signer: { - id: "did:example:issuer#key-1", - type: "Multikey", - controller: "did:example:issuer", - publicKeyHex: "02...", - }, - jwt, - } - - vi.mocked(verifyJWT).mockResolvedValueOnce(mockVerifiedResult) - - await expect( - verifyJwt(jwt, { audience: "did:example:audience" }), - ).rejects.toThrow("JWT audience is required but missing") - }) it("does not require an aud claim when no audience is expected", async () => { const jwt = await createJwt( From 6e0e687b0f42da0c5e030b89b8bf7f7a67366095 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Thu, 30 Jul 2026 17:50:42 +0300 Subject: [PATCH 5/6] style(jwt): drop stray blank line --- packages/jwt/src/verify.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/jwt/src/verify.test.ts b/packages/jwt/src/verify.test.ts index 9da7073..41ae414 100644 --- a/packages/jwt/src/verify.test.ts +++ b/packages/jwt/src/verify.test.ts @@ -197,7 +197,6 @@ describe("verifyJwt()", () => { }, ) - it("does not require an aud claim when no audience is expected", async () => { const jwt = await createJwt( { sub: "did:example:subject" }, From 8ece5e92a012cad9911b29d78dc5bbf747868596 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Thu, 30 Jul 2026 17:52:14 +0300 Subject: [PATCH 6/6] refactor(ack-id): stop destructuring the unused did in verifyA2ASignedMessage The option stays in the type for callers; signed messages carry no aud claim to verify it against today. --- packages/ack-id/src/a2a/verify.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ack-id/src/a2a/verify.ts b/packages/ack-id/src/a2a/verify.ts index f15c1f7..d19c326 100644 --- a/packages/ack-id/src/a2a/verify.ts +++ b/packages/ack-id/src/a2a/verify.ts @@ -62,7 +62,9 @@ export async function verifyA2AHandshakeMessage( export async function verifyA2ASignedMessage( message: Message, - { did, counterparty, resolver = getDidResolver() }: VerifyA2AHandshakeOptions, + // `did` stays in the options type for callers, but signed messages carry + // no `aud` claim today, so there is nothing to verify it against. + { counterparty, resolver = getDidResolver() }: VerifyA2AHandshakeOptions, ): Promise { // Ensure the message is a valid A2A signed message // We need to remove the auto-generated contextId from the message