diff --git a/.changeset/id-token-missing-audience.md b/.changeset/id-token-missing-audience.md new file mode 100644 index 0000000000..35ecb62303 --- /dev/null +++ b/.changeset/id-token-missing-audience.md @@ -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. diff --git a/packages/core/src/oauth2/verify-id-token.test.ts b/packages/core/src/oauth2/verify-id-token.test.ts index 2d85ea6a3d..dc8aa83764 100644 --- a/packages/core/src/oauth2/verify-id-token.test.ts +++ b/packages/core/src/oauth2/verify-id-token.test.ts @@ -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 = {}, ): 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 diff --git a/packages/core/src/oauth2/verify-id-token.ts b/packages/core/src/oauth2/verify-id-token.ts index 01890954f1..b7ac9b8a17 100644 --- a/packages/core/src/oauth2/verify-id-token.ts +++ b/packages/core/src/oauth2/verify-id-token.ts @@ -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; +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(