fix(generic-oauth): bind id token nonce in redirect flow (#10095)

This commit is contained in:
Gustavo Valverde
2026-06-16 21:20:07 +00:00
committed by GitHub
parent 477044a065
commit 27b5d80227
18 changed files with 494 additions and 15 deletions
+3 -1
View File
@@ -2,7 +2,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.
genericOAuth providers configured with a `discoveryUrl` now verify the provider's `id_token` against its published JWKS (signature, issuer, audience, and advertised algorithms) and bind it to the authorization request with a server-generated OIDC `nonce`. A sign-in whose `id_token` fails verification, or does not echo the expected `nonce`, is rejected.
Set `disableIdTokenNonceBinding: true` on a provider that does not return the `nonce` claim in the authorization-code flow.
These providers also accept client-submitted id_token sign-in through `signIn.social({ idToken })`, which previously returned `ID_TOKEN_NOT_SUPPORTED`.
+1 -1
View File
@@ -18,7 +18,7 @@ Unified escape hatch for customizing the provider authorization URL on a per-req
### Security
- The shared `createAuthorizationURL` helper silently drops any caller-supplied key in `RESERVED_AUTHORIZATION_PARAMS` (`state`, `client_id`, `redirect_uri`, `response_type`, `code_challenge`, `code_challenge_method`, `scope`). The request-body Zod schema rejects the same keys with 400, so misuse is visible at the edge rather than silently overriding security-critical parameters.
- The shared `createAuthorizationURL` helper silently drops any caller-supplied key in `RESERVED_AUTHORIZATION_PARAMS` (`state`, `client_id`, `redirect_uri`, `response_type`, `code_challenge`, `code_challenge_method`, `nonce`, `scope`). The request-body Zod schema rejects the same keys with 400, so misuse is visible at the edge rather than silently overriding security-critical parameters. `nonce` is reserved so a caller cannot replace the OIDC nonce Better Auth generates when binding a discovery provider's `id_token` to the authorization request.
- Providers that use non-standard client identifiers (`wechat``appid`, `tiktok``client_key`) additionally filter those keys so a caller cannot swap the configured OAuth app.
- Provider protocol constants that are required for the integration to function (`atlassian``audience`, `notion``owner`) are merged last so caller-supplied `additionalParams` cannot override them. Configured defaults that represent operator intent (e.g. Google `include_granted_scopes`, Cognito `identityProvider`) remain caller-overridable.
- `signIn.sso` rejects `additionalParams` with 400 when the resolved provider is SAML; the SAML AuthnRequest is signed and cannot carry caller-supplied query parameters, so silently dropping them would mislead integrators.
+4 -2
View File
@@ -297,7 +297,9 @@ 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. 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.
**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. Discovery providers also bind the `id_token` to the authorization request with a server-generated OIDC `nonce` and reject a callback whose `id_token` does not echo it.
**disableIdTokenNonceBinding**: (Optional) Turn off OIDC `nonce` binding for a discovery provider's `id_token`. Binding is on by default and rejects an `id_token` that does not echo the server-generated `nonce` (OIDC Core 1.0 §3.1.3.7). Set this to `true` only for providers that do not return the `nonce` claim in the authorization-code flow; it removes `id_token` replay protection for the provider.
**authorizationUrl**: (Optional) The OAuth provider's authorization endpoint. Not required if using `discoveryUrl`.
@@ -333,7 +335,7 @@ interface GenericOAuthConfig {
**mapProfileToUser**: (Optional) A function to map the provider's user profile to your app's user object. Useful for custom field mapping or transformations.
**authorizationUrlParams**: (Optional) Additional query parameters to add to the authorization URL. These can override default parameters.
**authorizationUrlParams**: (Optional) Additional query parameters to add to the authorization URL. Reserved OAuth keys (`state`, `client_id`, `redirect_uri`, `response_type`, `code_challenge`, `code_challenge_method`, `nonce`, `scope`) are ignored so they cannot replace the values Better Auth manages for the flow; any other key overrides the default.
**tokenUrlParams**: (Optional) Additional query parameters to add to the token URL. Parameters already set by Better Auth are preserved. Configure token endpoint client authentication with `clientId`, `clientSecret`, and `tokenEndpointAuth`.
@@ -630,6 +630,35 @@ describe("account", async () => {
});
});
it("should pass idTokenNonce to providers that require redirect nonce binding", async () => {
const googleProvider = ctx.socialProviders.find((v) => v.id === "google")!;
const previousRequiresIdTokenNonce = googleProvider.requiresIdTokenNonce;
googleProvider.requiresIdTokenNonce = true;
const createAuthorizationURLSpy = vi.spyOn(
googleProvider,
"createAuthorizationURL",
);
try {
const { runWithUser: runWithClient2 } = await signInWithTestUser();
await runWithClient2(async () => {
const linkAccountRes = await client.linkSocial({
provider: "google",
callbackURL: "/callback",
});
expect(linkAccountRes.error).toBeNull();
expect(createAuthorizationURLSpy).toHaveBeenCalledWith(
expect.objectContaining({
idTokenNonce: expect.any(String),
}),
);
});
} finally {
googleProvider.requiresIdTokenNonce = previousRequiresIdTokenNonce;
createAuthorizationURLSpy.mockRestore();
}
});
it("should link second account from the same provider", async () => {
const { runWithUser: runWithClient2 } = await signInWithTestUser();
await runWithClient2(async (headers) => {
@@ -20,7 +20,7 @@ import { parseAccountOutput } from "../../db/schema";
import { missingEmailLogMessage } from "../../oauth2/errors";
import { persistOAuthAccount } from "../../oauth2/persist-account";
import { applyUpdateUserInfoOnLink } from "../../oauth2/resolve-account";
import { generateState } from "../../oauth2/state";
import { generateIdTokenNonce, generateState } from "../../oauth2/state";
import { decryptOAuthToken } from "../../oauth2/token-encryption";
import {
freshSessionMiddleware,
@@ -195,7 +195,7 @@ export const linkSocialAccount = createAuthEndpoint(
/**
* Extra query parameters to append to the provider authorization URL.
* Reserved OAuth keys (state, client_id, redirect_uri, response_type,
* code_challenge, code_challenge_method, scope) are rejected.
* code_challenge, code_challenge_method, nonce, scope) are rejected.
*/
additionalParams: additionalAuthorizationParamsSchema,
/**
@@ -383,9 +383,11 @@ export const linkSocialAccount = createAuthEndpoint(
// Handle OAuth flow
const stateNonce = generateRandomString(32);
const codeVerifier = generateRandomString(128);
const idTokenNonce = generateIdTokenNonce(provider);
const { url, requestedScopes } = await provider.createAuthorizationURL({
state: stateNonce,
codeVerifier,
idTokenNonce,
redirectURI: `${c.context.baseURL}${provider.callbackPath}`,
scopes: c.body.scopes,
loginHint: c.body.loginHint,
@@ -400,6 +402,7 @@ export const linkSocialAccount = createAuthEndpoint(
requestedScopes,
state: stateNonce,
codeVerifier,
idTokenNonce,
});
if (!c.body.disableRedirect) {
@@ -12,7 +12,11 @@ import {
import { persistOAuthAccount } from "../../oauth2/persist-account";
import { applyUpdateUserInfoOnLink } from "../../oauth2/resolve-account";
import { signInWithOAuthIdentity } from "../../oauth2/sign-in-with-oauth-identity";
import { generateState, parseState } from "../../oauth2/state";
import {
generateIdTokenNonce,
generateState,
parseState,
} from "../../oauth2/state";
import { HIDE_METADATA } from "../../utils/hide-metadata";
import { isAPIError } from "../../utils/is-api-error";
import { assertValidUserInfo } from "../../utils/validate-user-info";
@@ -99,13 +103,20 @@ export const callbackOAuth = createAuthEndpoint(
// bounce-back callback has no scope fallback (RFC 6749 §5.1).
const state = generateRandomString(32);
const codeVerifier = generateRandomString(128);
const idTokenNonce = generateIdTokenNonce(provider);
const { url: authUrl, requestedScopes } =
await provider.createAuthorizationURL({
state,
codeVerifier,
idTokenNonce,
redirectURI: `${c.context.baseURL}${provider.callbackPath}`,
});
await generateState(c, { requestedScopes, state, codeVerifier });
await generateState(c, {
requestedScopes,
state,
codeVerifier,
idTokenNonce,
});
throw c.redirect(authUrl.toString());
}
}
@@ -125,6 +136,7 @@ export const callbackOAuth = createAuthEndpoint(
newUserURL,
requestSignUp,
requestedScopes,
idTokenNonce,
} = await parseState(c);
function redirectOnError(error: string, description?: string | undefined) {
@@ -170,6 +182,19 @@ export const callbackOAuth = createAuthEndpoint(
throw redirectOnError(OAUTH_CALLBACK_ERROR_CODES.ISSUER_MISMATCH);
}
// Fail closed: a provider that requires id_token nonce binding must carry
// an expected nonce recovered from state. If it is absent (a flow minted
// before binding was enabled, or a dropped state field), the redirect
// cannot enforce the binding, so refuse rather than trust an unbound
// id_token.
if (provider.requiresIdTokenNonce && !idTokenNonce) {
c.context.logger.error(
"OAuth id_token nonce binding required but no expected nonce was found in state",
{ providerId: provider.id },
);
throw redirectOnError(OAUTH_CALLBACK_ERROR_CODES.NONCE_BINDING_MISSING);
}
let tokens: OAuth2Tokens | null;
try {
tokens = await provider.validateAuthorizationCode({
@@ -197,6 +222,7 @@ export const callbackOAuth = createAuthEndpoint(
const providerResult = await provider.getUserInfo({
...tokens,
...(idTokenNonce ? { expectedIdTokenNonce: idTokenNonce } : {}),
/**
* The user object from the provider
* This is only available for some providers like Apple
@@ -18,7 +18,7 @@ import {
OAUTH_CALLBACK_ERROR_CODES,
} from "../../oauth2/errors";
import { signInWithOAuthIdentity } from "../../oauth2/sign-in-with-oauth-identity";
import { generateState } from "../../utils";
import { generateIdTokenNonce, generateState } from "../../utils";
import { formCsrfMiddleware } from "../middlewares/origin-check";
import { createEmailVerificationToken } from "./email-verification";
@@ -186,7 +186,7 @@ const socialSignInBodySchema = z.object({
/**
* Extra query parameters to append to the provider authorization URL.
* Reserved OAuth keys (state, client_id, redirect_uri, response_type,
* code_challenge, code_challenge_method, scope) are rejected.
* code_challenge, code_challenge_method, nonce, scope) are rejected.
*/
additionalParams: additionalAuthorizationParamsSchema,
/**
@@ -376,9 +376,11 @@ export const signInSocial = <O extends BetterAuthOptions>() =>
const state = generateRandomString(32);
const codeVerifier = generateRandomString(128);
const idTokenNonce = generateIdTokenNonce(provider);
const { url, requestedScopes } = await provider.createAuthorizationURL({
state,
codeVerifier,
idTokenNonce,
redirectURI: `${c.context.baseURL}${provider.callbackPath}`,
scopes: c.body.scopes,
loginHint: c.body.loginHint,
@@ -389,6 +391,7 @@ export const signInSocial = <O extends BetterAuthOptions>() =>
requestedScopes,
state,
codeVerifier,
idTokenNonce,
});
if (!c.body.disableRedirect) {
@@ -12,6 +12,7 @@ export const OAUTH_CALLBACK_ERROR_CODES = {
ISSUER_MISSING: "issuer_missing",
ISSUER_MISMATCH: "issuer_mismatch",
INVALID_CODE: "invalid_code",
NONCE_BINDING_MISSING: "nonce_binding_missing",
UNABLE_TO_GET_USER_INFO: "unable_to_get_user_info",
NO_CALLBACK_URL: "no_callback_url",
UNABLE_TO_LINK_ACCOUNT: "unable_to_link_account",
+16
View File
@@ -6,6 +6,19 @@ import type { StateData } from "../state";
import { generateGenericState, parseGenericState, StateError } from "../state";
import { redirectOnError } from "./errors";
/**
* Mint the OIDC `nonce` for the redirect flow, or `undefined` when the provider
* does not require ID-token nonce binding. Every redirect entrypoint (social
* sign-in, account linking, IDP-initiated bounce, and the OAuth popup) mints
* through this helper, so the value sent on the authorization URL and the value
* persisted in state are produced one way and cannot drift apart.
*/
export function generateIdTokenNonce(provider: {
requiresIdTokenNonce?: boolean | undefined;
}): string | undefined {
return provider.requiresIdTokenNonce ? generateRandomString(32) : undefined;
}
/**
* Inputs for {@link generateState}. Grouped into one object so call sites read
* by name instead of by position.
@@ -29,6 +42,8 @@ export interface GenerateStateOptions {
state?: string | undefined;
/** The PKCE `codeVerifier` already used to build the authorization URL. Minted when omitted. */
codeVerifier?: string | undefined;
/** The OIDC nonce already sent as the authorization URL `nonce` parameter. */
idTokenNonce?: string | undefined;
}
export async function generateState(
@@ -61,6 +76,7 @@ export async function generateState(
expiresAt: Date.now() + 10 * 60 * 1000,
requestSignUp: c.body?.requestSignUp,
requestedScopes: options?.requestedScopes,
idTokenNonce: options?.idTokenNonce,
};
await setOAuthState(stateData);
@@ -3429,6 +3429,9 @@ describe("oauth2", async () => {
"http://localhost:3000/api/auth/callback/idp-initiated",
);
expect(url.searchParams.get("code")).toBeNull();
// Discovery providers bind the id_token to a nonce, including on the
// server-side bounce restart minted in callback.ts.
expect(url.searchParams.get("nonce")).toBeTruthy();
});
it("should redirect to the error page when a stateless callback arrives for a provider without the flag", async () => {
@@ -3744,6 +3747,343 @@ describe("oauth2", async () => {
}
});
/**
* @see https://github.com/better-auth/better-auth/issues/7571
*/
it("should bind a discovery id_token to the authorization request nonce", async () => {
let authorizationNonce = "";
const { customFetchImpl, cookieSetter } = await getTestInstance({
plugins: [
genericOAuth({
config: [
{
providerId: "nonce-match",
discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`,
clientId,
clientSecret,
pkce: true,
authorizationUrlParams: { nonce: "configured-nonce" },
getToken: async () => ({
accessToken: "nonce-match-access-token",
idToken: await server.issuer.buildToken({
scopesOrTransform: (_header, payload) => {
Object.assign(payload, {
aud: clientId,
sub: "nonce-match-user",
email: "nonce-match@test.com",
email_verified: true,
name: "Nonce Match",
nonce: authorizationNonce,
});
},
}),
tokenType: "bearer",
}),
},
],
}),
],
});
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: { customFetchImpl },
});
const headers = new Headers();
const res = await client.signIn.social({
provider: "nonce-match",
callbackURL: "http://localhost:3000/dashboard",
newUserCallbackURL: "http://localhost:3000/new_user",
fetchOptions: { onSuccess: cookieSetter(headers) },
});
authorizationNonce =
new URL(res.data?.url || "").searchParams.get("nonce") || "";
expect(authorizationNonce).toBeTruthy();
expect(authorizationNonce).not.toBe("configured-nonce");
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("nonce-match@test.com");
});
/**
* @see https://github.com/better-auth/better-auth/issues/7571
*/
it("should reject a discovery id_token with a mismatched nonce", async () => {
const { customFetchImpl, cookieSetter } = await getTestInstance({
plugins: [
genericOAuth({
config: [
{
providerId: "nonce-mismatch",
discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`,
clientId,
clientSecret,
pkce: true,
getToken: async () => ({
accessToken: "nonce-mismatch-access-token",
idToken: await server.issuer.buildToken({
scopesOrTransform: (_header, payload) => {
Object.assign(payload, {
aud: clientId,
sub: "nonce-mismatch-user",
email: "nonce-mismatch@test.com",
email_verified: true,
name: "Nonce Mismatch",
nonce: "different-nonce",
});
},
}),
tokenType: "bearer",
}),
},
],
}),
],
});
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: { customFetchImpl },
});
const headers = new Headers();
const res = await client.signIn.social({
provider: "nonce-mismatch",
callbackURL: "http://localhost:3000/dashboard",
newUserCallbackURL: "http://localhost:3000/new_user",
fetchOptions: { onSuccess: cookieSetter(headers) },
});
expect(
new URL(res.data?.url || "").searchParams.get("nonce"),
).toBeTruthy();
const { callbackURL } = await simulateOAuthFlow(
res.data?.url || "",
headers,
customFetchImpl,
);
expect(callbackURL).toContain("?error=");
});
/**
* @see https://github.com/better-auth/better-auth/issues/7571
*/
it("should reject a discovery id_token that omits the nonce claim", async () => {
const { customFetchImpl, cookieSetter } = await getTestInstance({
plugins: [
genericOAuth({
config: [
{
providerId: "nonce-missing",
discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`,
clientId,
clientSecret,
pkce: true,
getToken: async () => ({
accessToken: "nonce-missing-access-token",
idToken: await server.issuer.buildToken({
scopesOrTransform: (_header, payload) => {
Object.assign(payload, {
aud: clientId,
sub: "nonce-missing-user",
email: "nonce-missing@test.com",
email_verified: true,
name: "Nonce Missing",
});
},
}),
tokenType: "bearer",
}),
},
],
}),
],
});
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: { customFetchImpl },
});
const headers = new Headers();
const res = await client.signIn.social({
provider: "nonce-missing",
callbackURL: "http://localhost:3000/dashboard",
newUserCallbackURL: "http://localhost:3000/new_user",
fetchOptions: { onSuccess: cookieSetter(headers) },
});
expect(
new URL(res.data?.url || "").searchParams.get("nonce"),
).toBeTruthy();
const { callbackURL } = await simulateOAuthFlow(
res.data?.url || "",
headers,
customFetchImpl,
);
expect(callbackURL).toContain("?error=");
});
it("should not bind a nonce when id_token nonce binding is disabled", async () => {
const { customFetchImpl, cookieSetter } = await getTestInstance({
plugins: [
genericOAuth({
config: [
{
providerId: "nonce-disabled",
discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`,
clientId,
clientSecret,
pkce: true,
disableIdTokenNonceBinding: true,
getToken: async () => ({
accessToken: "nonce-disabled-access-token",
idToken: await server.issuer.buildToken({
scopesOrTransform: (_header, payload) => {
Object.assign(payload, {
aud: clientId,
sub: "nonce-disabled-user",
email: "nonce-disabled@test.com",
email_verified: true,
name: "Nonce Disabled",
});
},
}),
tokenType: "bearer",
}),
},
],
}),
],
});
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: { customFetchImpl },
});
const headers = new Headers();
const res = await client.signIn.social({
provider: "nonce-disabled",
callbackURL: "http://localhost:3000/dashboard",
newUserCallbackURL: "http://localhost:3000/new_user",
fetchOptions: { onSuccess: cookieSetter(headers) },
});
expect(new URL(res.data?.url || "").searchParams.get("nonce")).toBeNull();
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("nonce-disabled@test.com");
});
it("should drop a configured nonce param for providers without nonce binding", async () => {
const { customFetchImpl, cookieSetter } = await getTestInstance({
plugins: [
genericOAuth({
config: [
{
providerId: "nonce-static",
authorizationUrl: `http://localhost:${port}/authorize`,
tokenUrl: `http://localhost:${port}/token`,
clientId,
clientSecret,
pkce: true,
authorizationUrlParams: { nonce: "configured-nonce" },
},
],
}),
],
});
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: { customFetchImpl },
});
const headers = new Headers();
const res = await client.signIn.social({
provider: "nonce-static",
callbackURL: "http://localhost:3000/dashboard",
fetchOptions: { onSuccess: cookieSetter(headers) },
});
expect(new URL(res.data?.url || "").searchParams.get("nonce")).toBeNull();
});
/**
* @see https://github.com/better-auth/better-auth/pull/10095#discussion_r3417330694
*/
it("should reject a discovery id_token when state carries no expected nonce", async () => {
const { customFetchImpl, cookieSetter, auth } = await getTestInstance({
plugins: [
genericOAuth({
config: [
{
providerId: "nonce-skew",
discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`,
clientId,
clientSecret,
pkce: true,
getToken: async () => ({
accessToken: "nonce-skew-access-token",
idToken: await server.issuer.buildToken({
scopesOrTransform: (_header, payload) => {
Object.assign(payload, {
aud: clientId,
sub: "nonce-skew-user",
email: "nonce-skew@test.com",
email_verified: true,
name: "Nonce Skew",
});
},
}),
tokenType: "bearer",
}),
},
],
}),
],
});
const ctx = await auth.$context;
const provider = ctx.socialProviders.find((p) => p.id === "nonce-skew")!;
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: { customFetchImpl },
});
// Mint state before binding is required, so it carries no expected nonce.
provider.requiresIdTokenNonce = false;
const headers = new Headers();
const res = await client.signIn.social({
provider: "nonce-skew",
callbackURL: "http://localhost:3000/dashboard",
newUserCallbackURL: "http://localhost:3000/new_user",
fetchOptions: { onSuccess: cookieSetter(headers) },
});
expect(new URL(res.data?.url || "").searchParams.get("nonce")).toBeNull();
// The provider now requires binding, but the in-flight state has none.
provider.requiresIdTokenNonce = true;
const { callbackURL } = await simulateOAuthFlow(
res.data?.url || "",
headers,
customFetchImpl,
);
expect(callbackURL).toContain("error=nonce_binding_missing");
});
it("should sign in with a client-submitted id_token for a discovery provider", async () => {
const token = await server.issuer.buildToken({
scopesOrTransform: (_header, payload) => {
@@ -299,6 +299,9 @@ export const genericOAuth = <const ID extends string>(
callbackPath: `/callback/${c.providerId}`,
issuer,
idToken: idTokenConfig,
requiresIdTokenNonce:
idTokenConfig !== undefined &&
c.disableIdTokenNonceBinding !== true,
allowIdpInitiated: c.allowIdpInitiated,
createAuthorizationURL(data) {
if (!authorizationUrl) {
@@ -329,6 +332,7 @@ export const genericOAuth = <const ID extends string>(
accessType: c.accessType,
responseType: c.responseType,
responseMode: c.responseMode,
nonce: data.idTokenNonce,
additionalParams: {
...(c.authorizationUrlParams ?? {}),
...(data.additionalParams ?? {}),
@@ -370,23 +374,25 @@ export const genericOAuth = <const ID extends string>(
);
},
async getUserInfo(tokens) {
const { expectedIdTokenNonce, ...oauthTokens } = 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) {
if (oauthTokens.idToken && provider.idToken) {
const verified = await verifyProviderIdToken(
provider,
tokens.idToken,
oauthTokens.idToken,
expectedIdTokenNonce,
);
if (!verified) {
ctx.logger.error(
`Provider "${c.providerId}": id_token failed verification against the discovery JWKS`,
`Provider "${c.providerId}": id_token failed verification against the discovery JWKS or expected nonce`,
);
return null;
}
}
const raw = c.getUserInfo
? await c.getUserInfo(tokens)
: await fetchUserInfo(tokens, userInfoUrl);
? await c.getUserInfo(oauthTokens)
: await fetchUserInfo(oauthTokens, userInfoUrl);
if (!raw) {
return null;
}
@@ -215,4 +215,17 @@ export interface GenericOAuthConfig<ID extends string = string> {
* @default false
*/
allowIdpInitiated?: boolean | undefined;
/**
* Disable OIDC `nonce` binding for this provider's `id_token`.
*
* Providers configured with `discoveryUrl` that publish a JWKS bind the
* `id_token` to the authorization request by default: Better Auth sends a
* server-generated `nonce` and rejects a callback whose `id_token` does not
* echo it (OIDC Core 1.0 §3.1.3.7). Set this to `true` only for OIDC
* providers that do not return the `nonce` claim in the authorization-code
* flow; doing so removes `id_token` replay protection for this provider.
*
* @default false
*/
disableIdTokenNonceBinding?: boolean | undefined;
}
@@ -17,6 +17,7 @@ import {
splitSetCookieHeader,
} from "../../cookies";
import { generateRandomString } from "../../crypto";
import { generateIdTokenNonce } from "../../oauth2/state";
import type { StateData } from "../../state";
import { generateGenericState, INTERNAL_STATE_KEYS } from "../../state";
import { HIDE_METADATA } from "../../utils/hide-metadata";
@@ -215,6 +216,7 @@ const oauthPopupStart = createAuthEndpoint(
let url: URL;
try {
const codeVerifier = generateRandomString(128);
const idTokenNonce = generateIdTokenNonce(provider);
const parsedAdditionalData = c.query.additionalData
? (safeJSONParse<Record<string, unknown>>(c.query.additionalData) ?? {})
: {};
@@ -227,6 +229,7 @@ const oauthPopupStart = createAuthEndpoint(
...additionalData,
callbackURL,
codeVerifier,
idTokenNonce,
errorURL: c.query.errorCallbackURL,
newUserURL: c.query.newUserCallbackURL,
requestSignUp: c.query.requestSignUp === "true" ? true : undefined,
@@ -249,6 +252,7 @@ const oauthPopupStart = createAuthEndpoint(
({ url } = await provider.createAuthorizationURL({
state,
codeVerifier,
idTokenNonce,
redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
scopes: c.query.scopes ? c.query.scopes.split(",") : undefined,
}));
+5
View File
@@ -26,6 +26,11 @@ const stateDataSchema = z.looseObject({
})
.optional(),
requestSignUp: z.boolean().optional(),
/**
* OIDC nonce sent as the authorization request `nonce` parameter when the
* provider requires an ID token to be bound to this redirect flow.
*/
idTokenNonce: z.string().optional(),
/**
* The effective set of scopes requested in the authorization URL, captured
* from `createAuthorizationURL`. Persisted so the callback can fall back to
@@ -29,6 +29,7 @@ describe("createAuthorizationURL", () => {
it("silently drops reserved OAuth params supplied via additionalParams", async () => {
const { url } = await createAuthorizationURL({
...baseInput,
nonce: "server-nonce",
additionalParams: {
state: "attacker-controlled",
client_id: "attacker",
@@ -36,6 +37,7 @@ describe("createAuthorizationURL", () => {
response_type: "token",
code_challenge: "malicious",
code_challenge_method: "plain",
nonce: "attacker-nonce",
scope: "admin",
identity_provider: "OktaSSO",
},
@@ -47,6 +49,7 @@ describe("createAuthorizationURL", () => {
);
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("code_challenge")).toBeNull();
expect(url.searchParams.get("nonce")).toBe("server-nonce");
expect(url.searchParams.get("scope")).toBe("openid");
expect(url.searchParams.get("identity_provider")).toBe("OktaSSO");
});
@@ -59,6 +62,7 @@ describe("createAuthorizationURL", () => {
"response_type",
"code_challenge",
"code_challenge_method",
"nonce",
"scope",
]);
});
@@ -15,6 +15,7 @@ export const RESERVED_AUTHORIZATION_PARAMS = [
"response_type",
"code_challenge",
"code_challenge_method",
"nonce",
"scope",
] as const;
@@ -37,6 +38,7 @@ export async function createAuthorizationURL({
responseType,
display,
loginHint,
nonce,
hd,
responseMode,
additionalParams,
@@ -56,6 +58,7 @@ export async function createAuthorizationURL({
responseType?: string | undefined;
display?: string | undefined;
loginHint?: string | undefined;
nonce?: string | undefined;
hd?: string | undefined;
responseMode?: string | undefined;
additionalParams?: Record<string, string> | undefined;
@@ -77,6 +80,7 @@ export async function createAuthorizationURL({
duration && url.searchParams.set("duration", duration);
display && url.searchParams.set("display", display);
loginHint && url.searchParams.set("login_hint", loginHint);
nonce && url.searchParams.set("nonce", nonce);
prompt && url.searchParams.set("prompt", prompt);
hd && url.searchParams.set("hd", hd);
accessType && url.searchParams.set("access_type", accessType);
@@ -142,6 +142,12 @@ export interface UpstreamProvider<
redirectURI: string;
display?: string | undefined;
loginHint?: string | undefined;
/**
* OIDC nonce generated by the redirect initiator and persisted in OAuth
* state. Providers that set `requiresIdTokenNonce` must forward this to
* the authorization URL as the `nonce` parameter.
*/
idTokenNonce?: string | undefined;
/**
* Extra query parameters to append to the authorization URL.
* Providers forward these to the shared `createAuthorizationURL` helper,
@@ -159,6 +165,12 @@ export interface UpstreamProvider<
}) => Promise<OAuth2Tokens | null>;
getUserInfo: (
token: OAuth2Tokens & {
/**
* OIDC nonce recovered from OAuth state. Providers that required an
* ID-token nonce must pass this to `verifyProviderIdToken` before
* trusting ID-token claims.
*/
expectedIdTokenNonce?: string | undefined;
/**
* The user object from the provider
* This is only available for some providers like Apple
@@ -195,6 +207,13 @@ export interface UpstreamProvider<
* against this value to prevent authorization server mix-up attacks.
*/
issuer?: string | undefined;
/**
* Require shared OAuth redirect routes to bind ID-token verification to an
* authorization request nonce. When true, routes generate `idTokenNonce`,
* pass it to `createAuthorizationURL`, persist it in state, and provide it
* back to `getUserInfo` as `expectedIdTokenNonce`.
*/
requiresIdTokenNonce?: boolean | undefined;
/**
* Disable implicit sign up for new users. When set to true for the provider,
* sign-in need to be called with with requestSignUp as true to create new users.
@@ -79,6 +79,8 @@ export async function verifyProviderIdToken(
// Opaque (non-JWS) tokens carry no signature to check. They are accepted only when the
// provider opts in, in which case getUserInfo resolves identity from the access token via
// the provider's userinfo endpoint, which validates it (e.g. Facebook Graph access tokens).
// An expected `nonce` is not enforced here: an opaque token carries no `nonce` claim, and the
// access-token-backed userinfo exchange (not the token itself) is the identity source.
if (token.split(".").length !== 3) {
return config.allowOpaqueToken === true;
}