fix(sso): validate aud claim in OpenID Connect ID tokens (#7816)

This commit is contained in:
Paola Estefanía de Campos
2026-02-23 23:33:17 +08:00
committed by Alex Yang
parent 68b6b5d1a2
commit 3cd159589f
3 changed files with 258 additions and 33 deletions
@@ -1,6 +1,6 @@
import { base64 } from "@better-auth/utils/base64";
import { betterFetch } from "@better-fetch/fetch";
import { jwtVerify } from "jose";
import { createRemoteJWKSet, jwtVerify } from "jose";
import type { ProviderOptions } from "./index";
import { getOAuth2Tokens } from "./index";
@@ -124,31 +124,18 @@ export async function validateAuthorizationCode({
return tokens;
}
export async function validateToken(token: string, jwksEndpoint: string) {
const { data, error } = await betterFetch<{
keys: {
kid: string;
kty: string;
use: string;
n: string;
e: string;
x5c: string[];
}[];
}>(jwksEndpoint, {
method: "GET",
headers: {
accept: "application/json",
},
export async function validateToken(
token: string,
jwksEndpoint: string,
options?: {
audience?: string | string[];
issuer?: string | string[];
},
) {
const jwks = createRemoteJWKSet(new URL(jwksEndpoint));
const verified = await jwtVerify(token, jwks, {
audience: options?.audience,
issuer: options?.issuer,
});
if (error) {
throw error;
}
const keys = data["keys"];
const header = JSON.parse(atob(token.split(".")[0]!));
const key = keys.find((key) => key.kid === header.kid);
if (!key) {
throw new Error("Key not found");
}
const verified = await jwtVerify(token, key);
return verified;
}
@@ -0,0 +1,241 @@
import type { JWK } from "jose";
import { exportJWK, generateKeyPair, SignJWT } from "jose";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { validateToken } from "./validate-authorization-code";
describe("validateToken", () => {
const originalFetch = globalThis.fetch;
const mockedFetch = vi.fn() as unknown as typeof fetch &
ReturnType<typeof vi.fn>;
beforeAll(() => {
globalThis.fetch = mockedFetch;
});
afterAll(() => {
globalThis.fetch = originalFetch;
});
beforeEach(() => {
mockedFetch.mockReset();
});
async function createTestJWKS(alg: string, crv?: string) {
const { publicKey, privateKey } = await generateKeyPair(alg, {
crv,
extractable: true,
});
const publicJWK = await exportJWK(publicKey);
const privateJWK = await exportJWK(privateKey);
const kid = `test-key-${Date.now()}`;
publicJWK.kid = kid;
publicJWK.alg = alg;
privateJWK.kid = kid;
privateJWK.alg = alg;
return { publicJWK, privateJWK, kid, publicKey, privateKey };
}
async function createSignedToken(
privateKey: CryptoKey,
alg: string,
kid: string,
payload: Record<string, unknown> = {},
) {
return await new SignJWT({
sub: "user-123",
email: "test@example.com",
iss: "https://example.com",
aud: "test-client",
...payload,
})
.setProtectedHeader({ alg, kid })
.setIssuedAt()
.setExpirationTime("1h")
.sign(privateKey);
}
function mockJWKSResponse(...publicJWKs: JWK[]) {
mockedFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ keys: publicJWKs }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
}
it("should verify RS256 signed token", async () => {
const { publicJWK, privateKey, kid } = await createTestJWKS("RS256");
const token = await createSignedToken(privateKey, "RS256", kid);
mockJWKSResponse(publicJWK);
const result = await validateToken(
token,
"https://example.com/.well-known/jwks",
);
expect(result).toBeDefined();
expect(result.payload.sub).toBe("user-123");
expect(result.payload.email).toBe("test@example.com");
});
it("should verify ES256 signed token", async () => {
const { publicJWK, privateKey, kid } = await createTestJWKS("ES256");
const token = await createSignedToken(privateKey, "ES256", kid);
mockJWKSResponse(publicJWK);
const result = await validateToken(
token,
"https://example.com/.well-known/jwks",
);
expect(result).toBeDefined();
expect(result.payload.sub).toBe("user-123");
});
it("should verify EdDSA (Ed25519) signed token", async () => {
const { publicJWK, privateKey, kid } = await createTestJWKS(
"EdDSA",
"Ed25519",
);
const token = await createSignedToken(privateKey, "EdDSA", kid);
mockJWKSResponse(publicJWK);
const result = await validateToken(
token,
"https://example.com/.well-known/jwks",
);
expect(result).toBeDefined();
expect(result.payload.sub).toBe("user-123");
});
it("should throw when kid doesn't match any key", async () => {
const { publicJWK, privateKey } = await createTestJWKS("RS256");
publicJWK.kid = "different-kid";
const token = await createSignedToken(privateKey, "RS256", "original-kid");
mockJWKSResponse(publicJWK);
await expect(
validateToken(token, "https://example.com/.well-known/jwks"),
).rejects.toThrow();
});
it("should find correct key when multiple keys exist", async () => {
const key1 = await createTestJWKS("RS256");
const key2 = await createTestJWKS("RS256");
const key3 = await createTestJWKS("ES256");
const token = await createSignedToken(key2.privateKey, "RS256", key2.kid);
mockJWKSResponse(key1.publicJWK, key2.publicJWK, key3.publicJWK);
const result = await validateToken(
token,
"https://example.com/.well-known/jwks",
);
expect(result).toBeDefined();
expect(result.payload.sub).toBe("user-123");
});
it("should throw when JWKS returns empty keys array", async () => {
const { privateKey, kid } = await createTestJWKS("RS256");
const token = await createSignedToken(privateKey, "RS256", kid);
mockJWKSResponse();
await expect(
validateToken(token, "https://example.com/.well-known/jwks"),
).rejects.toThrow();
});
it("should throw when JWKS fetch fails", async () => {
const { privateKey, kid } = await createTestJWKS("RS256");
const token = await createSignedToken(privateKey, "RS256", kid);
mockedFetch.mockResolvedValueOnce(
new Response("Internal Server Error", { status: 500 }),
);
await expect(
validateToken(token, "https://example.com/.well-known/jwks"),
).rejects.toBeDefined();
});
it("should verify token with matching audience", async () => {
const { publicJWK, privateKey, kid } = await createTestJWKS("RS256");
const token = await createSignedToken(privateKey, "RS256", kid);
mockJWKSResponse(publicJWK);
const result = await validateToken(
token,
"https://example.com/.well-known/jwks",
{ audience: "test-client" },
);
expect(result).toBeDefined();
expect(result.payload.aud).toBe("test-client");
});
it("should reject token with mismatched audience", async () => {
const { publicJWK, privateKey, kid } = await createTestJWKS("RS256");
const token = await createSignedToken(privateKey, "RS256", kid);
mockJWKSResponse(publicJWK);
await expect(
validateToken(token, "https://example.com/.well-known/jwks", {
audience: "wrong-client",
}),
).rejects.toThrow();
});
it("should verify token with matching issuer", async () => {
const { publicJWK, privateKey, kid } = await createTestJWKS("RS256");
const token = await createSignedToken(privateKey, "RS256", kid);
mockJWKSResponse(publicJWK);
const result = await validateToken(
token,
"https://example.com/.well-known/jwks",
{ issuer: "https://example.com" },
);
expect(result).toBeDefined();
expect(result.payload.iss).toBe("https://example.com");
});
it("should reject token with mismatched issuer", async () => {
const { publicJWK, privateKey, kid } = await createTestJWKS("RS256");
const token = await createSignedToken(privateKey, "RS256", kid);
mockJWKSResponse(publicJWK);
await expect(
validateToken(token, "https://example.com/.well-known/jwks", {
issuer: "https://wrong-issuer.com",
}),
).rejects.toThrow();
});
it("should verify token with both audience and issuer", async () => {
const { publicJWK, privateKey, kid } = await createTestJWKS("RS256");
const token = await createSignedToken(privateKey, "RS256", kid);
mockJWKSResponse(publicJWK);
const result = await validateToken(
token,
"https://example.com/.well-known/jwks",
{
audience: "test-client",
issuer: "https://example.com",
},
);
expect(result).toBeDefined();
expect(result.payload.aud).toBe("test-client");
expect(result.payload.iss).toBe("https://example.com");
});
});
+4 -7
View File
@@ -1574,6 +1574,10 @@ export const callbackSSO = (options?: SSOOptions) => {
const verified = await validateToken(
tokenResponse.idToken,
config.jwksEndpoint,
{
audience: config.clientId,
issuer: provider.issuer,
},
).catch((e) => {
ctx.context.logger.error(e);
return null;
@@ -1585,13 +1589,6 @@ export const callbackSSO = (options?: SSOOptions) => {
}?error=invalid_provider&error_description=token_not_verified`,
);
}
if (verified.payload.iss !== provider.issuer) {
throw ctx.redirect(
`${
errorURL || callbackURL
}?error=invalid_provider&error_description=issuer_mismatch`,
);
}
const mapping = config.mapping || {};
userInfo = {