mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-25 20:15:19 -05:00
feat(generic-oauth): verify discovery id_tokens and enable id_token sign-in (#9966)
This commit is contained in:
@@ -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.
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user