diff --git a/.changeset/jwt-require-audience.md b/.changeset/jwt-require-audience.md new file mode 100644 index 0000000..cd361d9 --- /dev/null +++ b/.changeset/jwt-require-audience.md @@ -0,0 +1,15 @@ +--- +"@agentcommercekit/jwt": minor +"@agentcommercekit/ack-id": patch +--- + +`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..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 @@ -74,9 +76,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 62b81e8..41ae414 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,119 @@ 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`; 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 }, + ) + + 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", + }) + + expect(verifyJWT).toHaveBeenCalledWith( + jwt, + expect.objectContaining({ audience: "did:example:audience" }), + ) + expect(result.payload.aud).toBe("did:example:audience") + }) + + 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: [""] }, + ])( + "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 === undefined ? {} : { 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("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 () => { const jwt = await createJwt( { diff --git a/packages/jwt/src/verify.ts b/packages/jwt/src/verify.ts index 4d5f9ec..8f0d95f 100644 --- a/packages/jwt/src/verify.ts +++ b/packages/jwt/src/verify.ts @@ -6,8 +6,24 @@ export type VerifyJwtOptions = JWTVerifyOptions & { issuer?: string } +/** 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 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, @@ -19,5 +35,9 @@ export async function verifyJwt( throw new Error(`Expected issuer ${issuer}, got ${result.payload.iss}`) } + if (options.audience !== undefined && !hasAudience(result.payload.aud)) { + throw new Error("JWT audience is required but missing") + } + return result }