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
15 changes: 15 additions & 0 deletions .changeset/jwt-require-audience.md
Original file line number Diff line number Diff line change
@@ -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`.
5 changes: 3 additions & 2 deletions packages/ack-id/src/a2a/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,16 +174,17 @@ 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"), {
did: agentDid,
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(),
})
Expand Down
10 changes: 7 additions & 3 deletions packages/ack-id/src/a2a/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<JwtVerified> {
// Ensure the message is a valid A2A signed message
// We need to remove the auto-generated contextId from the message
Expand All @@ -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,
})
Expand Down
115 changes: 115 additions & 0 deletions packages/jwt/src/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ describe("verifyJwt()", () => {
let signer: ReturnType<typeof createJwtSigner>

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)
})
Expand Down Expand Up @@ -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(
{
Expand Down
22 changes: 21 additions & 1 deletion packages/jwt/src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
Loading