Skip to content
Merged
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/khaki-plants-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@kagii/openauth": minor
---

Improve JWT audience validation in `client.verify()` while preserving existing behavior.

`verify()` now validates token audience against the client `clientID` by default, with optional override via `options.audience` when needed.

This release also adds audience validation regression tests and improves `/userinfo` handling for invalid tokens.
Binary file modified bun.lockb
Binary file not shown.
2 changes: 1 addition & 1 deletion packages/openauth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"@tsconfig/node22": "22.0.0",
"@types/node": "22.10.1",
"arctic": "2.2.2",
"hono": "4.6.9",
"hono": "4.10.5",
"typescript": "5.6.3",
"valibot": "1.0.0-beta.15"
},
Expand Down
12 changes: 11 additions & 1 deletion packages/openauth/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,14 @@ export interface VerifyOptions {
*/
issuer?: string
/**
* @internal
* The expected audience (aud) claim value.
*
* @example
* ```ts
* {
* audience: "api"
* }
* ```
*/
audience?: string
/**
Expand Down Expand Up @@ -701,13 +708,15 @@ export function createClient(input: ClientInput): Client {
options?: VerifyOptions,
): Promise<VerifyResult<T> | VerifyError> {
const jwks = await getJWKS()
const expectedAudience = options?.audience ?? input.clientID
try {
const result = await jwtVerify<{
mode: "access"
type: keyof T
properties: v1.InferInput<T[keyof T]>
}>(token, jwks, {
issuer,
audience: expectedAudience,
})
const validated = await subjects[result.payload.type][
"~standard"
Expand All @@ -733,6 +742,7 @@ export function createClient(input: ClientInput): Client {
{
refresh: refreshed.tokens!.refresh,
issuer,
audience: expectedAudience,
fetch: options?.fetch,
},
)
Expand Down
55 changes: 38 additions & 17 deletions packages/openauth/src/issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1140,26 +1140,47 @@ export function issuer<
)
}

const result = await jwtVerify<{
mode: "access"
type: keyof SubjectSchema
properties: v1.InferInput<SubjectSchema[keyof SubjectSchema]>
}>(token, () => signingKey().then((item) => item.public), {
issuer: issuer(c),
})
try {
const result = await jwtVerify<{
mode: "access"
type: keyof SubjectSchema
properties: v1.InferInput<SubjectSchema[keyof SubjectSchema]>
aud?: string
}>(token, () => signingKey().then((item) => item.public), {
issuer: issuer(c),
})

const validated = await input.subjects[result.payload.type][
"~standard"
].validate(result.payload.properties)
if (!result.payload.aud) {
return c.json(
{
error: "invalid_token",
error_description: "Token missing audience claim",
},
401,
)
}

if (!validated.issues && result.payload.mode === "access") {
return c.json(validated.value as SubjectSchema)
}
const validated = await input.subjects[result.payload.type][
"~standard"
].validate(result.payload.properties)

return c.json({
error: "invalid_token",
error_description: "Invalid token",
})
if (!validated.issues && result.payload.mode === "access") {
return c.json(validated.value as SubjectSchema)
}

return c.json({
error: "invalid_token",
error_description: "Invalid token",
})
} catch {
return c.json(
{
error: "invalid_token",
error_description: "Token verification failed",
},
401,
)
}
})

app.onError(async (err, c) => {
Expand Down
23 changes: 21 additions & 2 deletions packages/openauth/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ describe("verify", () => {

test("success", async () => {
const refreshSpy = spyOn(client, "refresh")
const verified = await client.verify(subjects, tokens.access)
const verified = await client.verify(subjects, tokens.access, {
audience: "123",
})
expect(verified).toStrictEqual({
aud: "123",
subject: {
Expand All @@ -113,10 +115,24 @@ describe("verify", () => {
expect(refreshSpy).not.toBeCalled()
})

test("success without expected audience", async () => {
const verified = await client.verify(subjects, tokens.access)
expect(verified).toStrictEqual({
aud: "123",
subject: {
type: "user",
properties: {
userID: "123",
},
},
})
})

test("success after refresh", async () => {
const refreshSpy = spyOn(client, "refresh")
setSystemTime(Date.now() + 1000 * 6000 + 1000)
const verified = await client.verify(subjects, tokens.access, {
audience: "123",
refresh: tokens.refresh,
})
expect(verified).toStrictEqual({
Expand All @@ -138,7 +154,9 @@ describe("verify", () => {

test("failure with expired access token", async () => {
setSystemTime(Date.now() + 1000 * 6000 + 1000)
const verified = await client.verify(subjects, tokens.access)
const verified = await client.verify(subjects, tokens.access, {
audience: "123",
})
expect(verified).toStrictEqual({
err: expect.any(InvalidAccessTokenError),
})
Expand All @@ -147,6 +165,7 @@ describe("verify", () => {
test("failure with invalid refresh token", async () => {
setSystemTime(Date.now() + 1000 * 6000 + 1000)
const verified = await client.verify(subjects, tokens.access, {
audience: "123",
refresh: "foo",
})
expect(verified).toStrictEqual({
Expand Down
30 changes: 25 additions & 5 deletions packages/openauth/test/issuer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ describe("code flow", () => {
refresh: expectNonEmptyString,
expiresIn: 60,
})
const verified = await client.verify(subjects, tokens.access)
const verified = await client.verify(subjects, tokens.access, {
audience: "123",
})
if (verified.err) throw verified.err
expect(verified.subject).toStrictEqual({
type: "user",
Expand Down Expand Up @@ -251,7 +253,7 @@ describe("client credentials flow", () => {
test("success", async () => {
const client = createClient({
issuer: "https://auth.example.com",
clientID: "123",
clientID: "myuser",
fetch: (a, b) => Promise.resolve(auth.request(a, b)),
})
const response = await auth.request("https://auth.example.com/token", {
Expand All @@ -272,7 +274,9 @@ describe("client credentials flow", () => {
access_token: expectNonEmptyString,
refresh_token: expectNonEmptyString,
})
const verified = await client.verify(subjects, tokens.access_token)
const verified = await client.verify(subjects, tokens.access_token, {
audience: "myuser",
})
expect(verified).toStrictEqual({
aud: "myuser",
subject: {
Expand Down Expand Up @@ -355,7 +359,9 @@ describe("refresh token", () => {
expect(refreshed.access_token).not.toEqual(tokens.access)
expect(refreshed.refresh_token).not.toEqual(tokens.refresh)

const verified = await client.verify(subjects, refreshed.access_token)
const verified = await client.verify(subjects, refreshed.access_token, {
audience: "123",
})
expect(verified).toStrictEqual({
aud: "123",
subject: {
Expand All @@ -382,7 +388,9 @@ describe("refresh token", () => {
expect(refreshed.access_token).not.toEqual(tokens.access)
expect(refreshed.refresh_token).not.toEqual(tokens.refresh)

const verified = await client.verify(subjects, refreshed.access_token)
const verified = await client.verify(subjects, refreshed.access_token, {
audience: "123",
})
expect(verified).toStrictEqual({
aud: "123",
subject: {
Expand Down Expand Up @@ -518,4 +526,16 @@ describe("user info", () => {

expect(userinfo).toStrictEqual({ userID: "123" })
})

test("invalid token", async () => {
const response = await auth.request("https://auth.example.com/userinfo", {
headers: { Authorization: "Bearer invalid.token.here" },
})

expect(response.status).toBe(401)
expect(await response.json()).toStrictEqual({
error: "invalid_token",
error_description: "Token verification failed",
})
})
})
Loading