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
9 changes: 9 additions & 0 deletions .changeset/jwt-require-audience.md
Original file line number Diff line number Diff line change
@@ -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.
116 changes: 116 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,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",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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(
{
Expand Down
28 changes: 26 additions & 2 deletions packages/jwt/src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,44 @@ 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<JwtVerified> {
const result = await verifyJWT(jwt, options)

if (issuer && result.payload.iss !== issuer) {
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
}