fix(core): reject ID tokens when the provider has no client ID (#11931)

This commit is contained in:
Taesu
2026-10-20 01:46:32 +00:00
committed by GitHub
parent b18e3b8fdf
commit 4bc78baa83
3 changed files with 50 additions and 3 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@better-auth/core": patch
---
ID token sign-in now rejects tokens when the provider has no client ID, because their audience cannot be checked and a token issued to another app would otherwise be accepted. Set `clientId` on the provider to every client ID your apps sign in with.
@@ -1,7 +1,8 @@
import { createHash } from "node:crypto";
import type { JWK } from "jose";
import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { logger } from "../env";
import { apple } from "../social-providers/apple";
import { cognito } from "../social-providers/cognito";
import { facebook } from "../social-providers/facebook";
@@ -43,7 +44,7 @@ function providerWith(
idToken: OAuthIdTokenConfig | undefined,
options: Record<string, unknown> = {},
): ProviderWithIdTokenConfig {
return { idToken, options };
return { id: "test-provider", idToken, options };
}
const sha256Hex = (value: string) =>
@@ -259,6 +260,35 @@ describe("verifyProviderIdToken", () => {
);
});
describe("without a configured audience", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it.each<[string, string | string[] | undefined]>([
["missing", undefined],
["an empty string", ""],
["an empty list", []],
["a list of empty strings", [""]],
])("rejects a token when the audience is %s", async (_, audience) => {
const error = vi.spyOn(logger, "error").mockImplementation(() => {});
const { jwks, sign } = await makeKeyset();
const provider = providerWith({
jwks,
issuer: ISSUER,
// Providers pass `clientId` through, which is undefined when it is not set.
audience: audience as string | string[],
});
await expect(
verifyProviderIdToken(provider, await sign({ sub: "u1" })),
).resolves.toBe(false);
expect(error).toHaveBeenCalledWith(
expect.stringContaining("no client ID"),
);
});
});
describe("provider regressions", () => {
it("PayPal no longer accepts client id_token sign-in", () => {
// Previously verifyIdToken returned true for any decodable token without
+13 -1
View File
@@ -1,5 +1,6 @@
import type { JWTVerifyOptions } from "jose";
import { jwtVerify } from "jose";
import { logger } from "../env";
import type { GenericEndpointContext } from "../types";
import type { OAuthProvider, ProviderOptions } from "./oauth-provider";
@@ -38,7 +39,10 @@ export function getIdTokenVerifyOptions(options: {
} satisfies JWTVerifyOptions;
}
type ProviderWithIdTokenConfig = Pick<OAuthProvider, "idToken" | "options">;
type ProviderWithIdTokenConfig = Pick<
OAuthProvider,
"id" | "idToken" | "options"
>;
async function sha256Hex(value: string) {
const data = new TextEncoder().encode(value);
@@ -124,6 +128,14 @@ export async function verifyProviderIdToken(
if (token.split(".").length !== 3) {
return config.allowOpaqueToken === true;
}
// An empty audience would skip the audience check entirely.
const hasAudience = [config.audience].flat().some(Boolean);
if (!hasAudience) {
logger.error(
`[better-auth] Rejected an ID token for "${provider.id}" because the provider has no client ID to check its audience against. Set \`clientId\` on the provider.`,
);
return false;
}
// `kid` is optional in JWS: a JWKS resolver can still select a key by algorithm, so
// key selection is left to config.jwks.
const { payload } = await jwtVerify(