feat(generic-oauth): verify discovery id_tokens and enable id_token sign-in (#9966)

This commit is contained in:
Gustavo Valverde
2026-06-10 00:34:26 +00:00
committed by GitHub
parent af572166a2
commit ec8a38c08f
4 changed files with 289 additions and 12 deletions
@@ -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.
+16 -1
View File
@@ -75,6 +75,21 @@ The `scopes` parameter is additive: appended to the provider's configured scopes
You can also pass `additionalData` (a `Record<string, any>`) 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`.
@@ -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<string, unknown> }) => {
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<void>((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<void>((resolve, reject) =>
discoveryServer.close((err) => (err ? reject(err) : resolve())),
);
}
});
});
});
@@ -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<OAuth2UserInfo | null> {
// 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 = <const ID extends string>(
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 = <const ID extends string>(
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 = <const ID extends string>(
);
}
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 = <const ID extends string>(
);
},
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 = <const ID extends string>(
overrideUserInfoOnSignIn: c.overrideUserInfo,
requireEmailVerification: c.requireEmailVerification,
},
} satisfies UpstreamProvider);
};
genericProviders.push(provider);
}
const existingIds = new Set(ctx.socialProviders.map((p) => p.id));