From ec8a38c08f5cfe2d922be0f8a49f2d0fa84de799 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Tue, 9 Jun 2026 17:34:26 -0700 Subject: [PATCH] feat(generic-oauth): verify discovery `id_tokens` and enable `id_token` sign-in (#9966) --- .changeset/generic-oauth-verify-id-token.md | 9 + docs/content/docs/plugins/generic-oauth.mdx | 17 +- .../generic-oauth/generic-oauth.test.ts | 215 ++++++++++++++++++ .../src/plugins/generic-oauth/index.ts | 60 ++++- 4 files changed, 289 insertions(+), 12 deletions(-) create mode 100644 .changeset/generic-oauth-verify-id-token.md diff --git a/.changeset/generic-oauth-verify-id-token.md b/.changeset/generic-oauth-verify-id-token.md new file mode 100644 index 0000000000..9c1f40ce40 --- /dev/null +++ b/.changeset/generic-oauth-verify-id-token.md @@ -0,0 +1,9 @@ +--- +"better-auth": minor +--- + +genericOAuth providers configured with a `discoveryUrl` now verify the provider's `id_token` against its published JWKS (signature, issuer, audience, and advertised algorithms). A sign-in whose `id_token` fails verification is rejected. + +These providers also accept client-submitted id_token sign-in through `signIn.social({ idToken })`, which previously returned `ID_TOKEN_NOT_SUPPORTED`. + +Providers configured with explicit endpoints instead of `discoveryUrl` are unchanged. diff --git a/docs/content/docs/plugins/generic-oauth.mdx b/docs/content/docs/plugins/generic-oauth.mdx index af9f1d1fb0..6a66401688 100644 --- a/docs/content/docs/plugins/generic-oauth.mdx +++ b/docs/content/docs/plugins/generic-oauth.mdx @@ -75,6 +75,21 @@ The `scopes` parameter is additive: appended to the provider's configured scopes You can also pass `additionalData` (a `Record`) to round-trip client data through the flow; read it back on the callback with `getOAuthState()` and treat it as untrusted. See [Passing Additional Data Through OAuth Flow](/docs/concepts/oauth#passing-additional-data-through-oauth-flow). +### Sign In with an ID Token + +Providers configured with a `discoveryUrl` whose discovery document publishes a `jwks_uri` also accept a client-obtained `id_token` directly, skipping the redirect flow. The token is verified against the provider's JWKS before any claims are trusted: + +```ts +await authClient.signIn.social({ + provider: "provider-id", + idToken: { + token: idTokenFromProvider, + }, +}) +``` + +Providers configured with explicit endpoints instead of `discoveryUrl` do not support this path and return `ID_TOKEN_NOT_SUPPORTED`. + ### Link Account ```ts @@ -282,7 +297,7 @@ interface GenericOAuthConfig { **providerId**: A unique string to identify the OAuth provider configuration. -**discoveryUrl**: (Optional) URL to fetch the provider's OAuth 2.0/OIDC configuration. If provided, endpoints like `authorizationUrl`, `tokenUrl`, and `userInfoUrl` will be auto-discovered at server startup. +**discoveryUrl**: (Optional) URL to fetch the provider's OAuth 2.0/OIDC configuration. If provided, endpoints like `authorizationUrl`, `tokenUrl`, and `userInfoUrl` will be auto-discovered at server startup. When the discovery document publishes a `jwks_uri`, `id_token`s returned by the provider are verified against it (signature, issuer, audience, and advertised signing algorithms) before their claims are used; a token that fails verification rejects the sign-in. **authorizationUrl**: (Optional) The OAuth provider's authorization endpoint. Not required if using `discoveryUrl`. diff --git a/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts b/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts index a17e031c6a..bf729f0642 100644 --- a/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts +++ b/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts @@ -1,7 +1,10 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import type { GenericEndpointContext } from "@better-auth/core"; import { runWithEndpointContext } from "@better-auth/core/context"; import { APIError } from "@better-auth/core/error"; import { betterFetch } from "@better-fetch/fetch"; +import { generateKeyPair, SignJWT } from "jose"; import type { MutableResponse, TokenRequestIncomingMessage, @@ -3104,4 +3107,216 @@ describe("oauth2", async () => { ); }); }); + + describe("id_token verification", () => { + // The forged token carries valid iss/aud/exp claims; only its signing + // key is unknown to the provider JWKS, so a rejection isolates + // signature verification. + async function forgeIdToken() { + const { privateKey } = await generateKeyPair("RS256"); + return new SignJWT({ + email: "forged@test.com", + email_verified: true, + name: "Forged User", + }) + .setProtectedHeader({ alg: "RS256" }) + .setSubject("forged-user") + .setIssuer(`http://localhost:${port}`) + .setAudience(clientId) + .setIssuedAt() + .setExpirationTime("1h") + .sign(privateKey); + } + + it("should reject an id_token not signed by the discovery JWKS", async () => { + const forged = await forgeIdToken(); + const { customFetchImpl, cookieSetter } = await getTestInstance({ + plugins: [ + genericOAuth({ + config: [ + { + providerId: "verify-reject", + discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, + clientId, + clientSecret, + pkce: true, + getToken: async () => ({ + accessToken: "forged-access-token", + idToken: forged, + tokenType: "bearer", + }), + }, + ], + }), + ], + }); + const client = createAuthClient({ + baseURL: "http://localhost:3000", + fetchOptions: { customFetchImpl }, + }); + const headers = new Headers(); + const res = await client.signIn.social({ + provider: "verify-reject", + callbackURL: "http://localhost:3000/dashboard", + fetchOptions: { onSuccess: cookieSetter(headers) }, + }); + const { callbackURL } = await simulateOAuthFlow( + res.data?.url || "", + headers, + customFetchImpl, + ); + expect(callbackURL).toContain("?error="); + }); + + it("should use claims from an id_token verified against the discovery JWKS", async () => { + const addClaims = (token: { payload: Record }) => { + Object.assign(token.payload, { + email: "idtoken-claims@test.com", + email_verified: true, + name: "Id Token Claims", + }); + }; + server.service.on("beforeTokenSigning", addClaims); + try { + const headers = new Headers(); + const res = await authClient.signIn.social({ + provider: providerId, + callbackURL: "http://localhost:3000/dashboard", + newUserCallbackURL: "http://localhost:3000/new_user", + fetchOptions: { onSuccess: cookieSetter(headers) }, + }); + const { callbackURL, headers: sessionHeaders } = + await simulateOAuthFlow(res.data?.url || "", headers); + expect(callbackURL).toBe("http://localhost:3000/new_user"); + const session = await authClient.getSession({ + fetchOptions: { headers: sessionHeaders }, + }); + expect(session.data?.user.email).toBe("idtoken-claims@test.com"); + } finally { + server.service.off("beforeTokenSigning", addClaims); + } + }); + + it("should sign in with a client-submitted id_token for a discovery provider", async () => { + const token = await server.issuer.buildToken({ + scopesOrTransform: (_header, payload) => { + Object.assign(payload, { + aud: clientId, + sub: "front-channel-user", + email: "front-channel@test.com", + email_verified: true, + name: "Front Channel", + }); + }, + }); + const res = await authClient.signIn.social({ + provider: providerId, + idToken: { token }, + }); + expect(res.error).toBeNull(); + expect(res.data).toMatchObject({ token: expect.any(String) }); + }); + + it("should keep the decode posture for providers without discovery", async () => { + const forged = await forgeIdToken(); + const { customFetchImpl, cookieSetter } = await getTestInstance({ + plugins: [ + genericOAuth({ + config: [ + { + providerId: "no-discovery", + authorizationUrl: `http://localhost:${port}/authorize`, + tokenUrl: `http://localhost:${port}/token`, + clientId, + clientSecret, + pkce: true, + getToken: async () => ({ + accessToken: "opaque-access-token", + idToken: forged, + tokenType: "bearer", + }), + }, + ], + }), + ], + }); + const client = createAuthClient({ + baseURL: "http://localhost:3000", + fetchOptions: { customFetchImpl }, + }); + const headers = new Headers(); + const res = await client.signIn.social({ + provider: "no-discovery", + callbackURL: "http://localhost:3000/dashboard", + newUserCallbackURL: "http://localhost:3000/new_user", + fetchOptions: { onSuccess: cookieSetter(headers) }, + }); + const { callbackURL, headers: sessionHeaders } = await simulateOAuthFlow( + res.data?.url || "", + headers, + customFetchImpl, + ); + expect(callbackURL).toBe("http://localhost:3000/new_user"); + const session = await client.getSession({ + fetchOptions: { headers: sessionHeaders }, + }); + expect(session.data?.user.email).toBe("forged@test.com"); + }); + + it("should not break provider registration when jwks_uri is malformed", async () => { + const discoveryServer = createServer((_req, res) => { + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + issuer: `http://localhost:${port}`, + authorization_endpoint: `http://localhost:${port}/authorize`, + token_endpoint: `http://localhost:${port}/token`, + userinfo_endpoint: `http://localhost:${port}/userinfo`, + jwks_uri: "http://[malformed", + id_token_signing_alg_values_supported: ["RS256"], + }), + ); + }); + await new Promise((resolve) => discoveryServer.listen(0, resolve)); + const discoveryPort = (discoveryServer.address() as AddressInfo).port; + try { + const { customFetchImpl, cookieSetter } = await getTestInstance({ + plugins: [ + genericOAuth({ + config: [ + { + providerId: "malformed-jwks", + discoveryUrl: `http://localhost:${discoveryPort}/.well-known/openid-configuration`, + clientId, + clientSecret, + pkce: true, + }, + ], + }), + ], + }); + const client = createAuthClient({ + baseURL: "http://localhost:3000", + fetchOptions: { customFetchImpl }, + }); + const headers = new Headers(); + const res = await client.signIn.social({ + provider: "malformed-jwks", + callbackURL: "http://localhost:3000/dashboard", + newUserCallbackURL: "http://localhost:3000/new_user", + fetchOptions: { onSuccess: cookieSetter(headers) }, + }); + const { callbackURL } = await simulateOAuthFlow( + res.data?.url || "", + headers, + customFetchImpl, + ); + expect(callbackURL).not.toContain("?error="); + } finally { + await new Promise((resolve, reject) => + discoveryServer.close((err) => (err ? reject(err) : resolve())), + ); + } + }); + }); }); diff --git a/packages/better-auth/src/plugins/generic-oauth/index.ts b/packages/better-auth/src/plugins/generic-oauth/index.ts index 2a3fda0748..f6a7e47cf6 100644 --- a/packages/better-auth/src/plugins/generic-oauth/index.ts +++ b/packages/better-auth/src/plugins/generic-oauth/index.ts @@ -3,6 +3,7 @@ import { APIError } from "@better-auth/core/error"; import type { OAuth2Tokens, OAuth2UserInfo, + OAuthIdTokenConfig, UpstreamProvider, } from "@better-auth/core/oauth2"; import { @@ -10,9 +11,10 @@ import { createAuthorizationURL, refreshAccessToken, validateAuthorizationCode, + verifyProviderIdToken, } from "@better-auth/core/oauth2"; import { betterFetch } from "@better-fetch/fetch"; -import { decodeJwt } from "jose"; +import { createRemoteJWKSet, decodeJwt } from "jose"; import { PACKAGE_VERSION } from "../../version"; import { GENERIC_OAUTH_ERROR_CODES } from "./error-codes"; import type { GenericOAuthConfig, GenericOAuthOptions } from "./types"; @@ -50,6 +52,7 @@ interface DiscoveryDocument { token_endpoint?: string; userinfo_endpoint?: string; issuer?: string; + jwks_uri?: string; id_token_signing_alg_values_supported?: string[]; } @@ -97,11 +100,11 @@ async function fetchUserInfo( tokens: OAuth2Tokens, userInfoUrl: string | undefined, ): Promise { - // TODO: verify id_token signature using the provider's JWKS endpoint - // (discoverable from jwks_uri in the OIDC discovery document). Currently - // we only decode without cryptographic verification, which is acceptable - // when the token arrives over a TLS-protected server-to-server channel - // but does not satisfy OIDC Core 1.0 Section 3.1.3.7. + // When the provider declares an `idToken` config (OIDC discovery published + // a jwks_uri), the caller has already verified this token through + // `verifyProviderIdToken`. Without one, decoding without signature + // verification is the OIDC Core 1.0 ยง3.1.3.7 posture for tokens received + // over the TLS-protected token-endpoint channel. if (tokens.idToken) { try { const decoded = decodeJwt(tokens.idToken) as { @@ -194,6 +197,7 @@ export const genericOAuth = ( let issuer: string | undefined; let isOidc = false; + let idTokenConfig: OAuthIdTokenConfig | undefined; if (c.discoveryUrl) { const discovered = await fetchDiscovery( @@ -210,9 +214,27 @@ export const genericOAuth = ( tokenUrl ??= discovered.token_endpoint; userInfoUrl ??= discovered.userinfo_endpoint; issuer = discovered.issuer; - isOidc = - Array.isArray(discovered.id_token_signing_alg_values_supported) && - discovered.id_token_signing_alg_values_supported.length > 0; + const signingAlgs = + discovered.id_token_signing_alg_values_supported; + isOidc = Array.isArray(signingAlgs) && signingAlgs.length > 0; + if (discovered.jwks_uri && discovered.issuer) { + try { + idTokenConfig = { + jwks: createRemoteJWKSet( + new URL(discovered.jwks_uri, c.discoveryUrl), + ), + issuer: discovered.issuer, + audience: c.clientId, + algorithms: isOidc ? signingAlgs : undefined, + }; + } catch (err) { + // A malformed jwks_uri must not break provider registration; + // fall back to the decode posture used by non-discovery providers. + ctx.logger.error( + `Provider "${c.providerId}": invalid jwks_uri in discovery document, skipping id_token verification: ${err}`, + ); + } + } } else if (!authorizationUrl || !tokenUrl) { ctx.logger.error( `Provider "${c.providerId}": discovery returned no data and no explicit endpoints configured. OAuth sign-in will fail for this provider.`, @@ -249,11 +271,12 @@ export const genericOAuth = ( ); } - genericProviders.push({ + const provider: UpstreamProvider = { id: c.providerId, name: c.name ?? c.providerId, callbackPath: `/callback/${c.providerId}`, issuer, + idToken: idTokenConfig, allowIdpInitiated: c.allowIdpInitiated, createAuthorizationURL(data) { if (!authorizationUrl) { @@ -325,6 +348,20 @@ export const genericOAuth = ( ); }, async getUserInfo(tokens) { + // Fail closed: when discovery published a JWKS, an id_token + // that cannot be verified must not become an identity source. + if (tokens.idToken && provider.idToken) { + const verified = await verifyProviderIdToken( + provider, + tokens.idToken, + ); + if (!verified) { + ctx.logger.error( + `Provider "${c.providerId}": id_token failed verification against the discovery JWKS`, + ); + return null; + } + } const raw = c.getUserInfo ? await c.getUserInfo(tokens) : await fetchUserInfo(tokens, userInfoUrl); @@ -381,7 +418,8 @@ export const genericOAuth = ( overrideUserInfoOnSignIn: c.overrideUserInfo, requireEmailVerification: c.requireEmailVerification, }, - } satisfies UpstreamProvider); + }; + genericProviders.push(provider); } const existingIds = new Set(ctx.socialProviders.map((p) => p.id));