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 }