fix(oauth-proxy): run the standard callback checks on proxied callbacks (#11930)

This commit is contained in:
Taesu
2026-10-20 10:07:35 +09:00
committed by GitHub
parent fb48619083
commit b18e3b8fdf
5 changed files with 282 additions and 157 deletions
@@ -0,0 +1,5 @@
---
"better-auth": patch
---
OAuth callbacks completed through the OAuth proxy now run the same checks as the standard OAuth callback, including issuer and ID token nonce validation.
+11 -87
View File
@@ -1,15 +1,9 @@
import { createAuthEndpoint } from "@better-auth/core/api";
import type { AccountKey } from "@better-auth/core/db";
import type { OAuth2Tokens } from "@better-auth/core/oauth2";
import { safeJSONParse } from "@better-auth/core/utils/json";
import { appendQueryParams } from "@better-auth/core/utils/url";
import * as z from "zod";
import { getAwaitableValue } from "../../context/helpers";
import { setSessionCookie } from "../../cookies";
import {
resolveOAuthAccountKey,
toOAuthProfileRecord,
} from "../../oauth2/account-key";
import { exchangeOAuthCallback } from "../../oauth2/callback";
import {
missingEmailLogMessage,
OAUTH_CALLBACK_ERROR_CODES,
@@ -171,88 +165,18 @@ export const callbackOAuth = createAuthEndpoint(
throw redirectOnError(OAUTH_CALLBACK_ERROR_CODES.PROVIDER_NOT_FOUND);
}
// RFC 9207: validate authorization server issuer identifier.
// Only validated when the provider sends the iss parameter;
// older OAuth servers that don't support RFC 9207 omit it.
if (iss && provider.issuer && iss !== provider.issuer) {
c.context.logger.error("OAuth issuer mismatch", {
expected: provider.issuer,
received: iss,
});
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({
code: code,
codeVerifier,
deviceId: device_id,
redirectURI: `${c.context.baseURL}${getOAuthCallbackPath(provider)}`,
});
} catch (e) {
c.context.logger.error("", e);
throw redirectOnError(OAUTH_CALLBACK_ERROR_CODES.INVALID_CODE);
}
if (!tokens) {
throw redirectOnError(OAUTH_CALLBACK_ERROR_CODES.INVALID_CODE);
}
const parsedUserData = userData
? safeJSONParse<{
name?: {
firstName?: string;
lastName?: string;
};
email?: string;
}>(userData)
: null;
const providerResult = await provider.getUserInfo({
...tokens,
...(idTokenNonce ? { expectedIdTokenNonce: idTokenNonce } : {}),
/**
* The user object from the provider
* This is only available for some providers like Apple
*/
user: parsedUserData ?? undefined,
const exchanged = await exchangeOAuthCallback(c, provider, {
code,
codeVerifier,
iss,
deviceId: device_id,
user: userData,
idTokenNonce,
});
if (!providerResult?.user) {
c.context.logger.error("Unable to get user info");
return redirectOnError(
OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_GET_USER_INFO,
);
}
const userInfo = providerResult.user;
const providerProfile = toOAuthProfileRecord(providerResult.data);
let accountKey: AccountKey;
try {
accountKey = await resolveOAuthAccountKey(
provider,
tokens,
providerResult.data,
);
} catch (error) {
c.context.logger.error("Unable to derive provider account identity", {
providerId: provider.id,
error,
});
return redirectOnError(
OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_GET_USER_INFO,
);
if (exchanged.error) {
throw redirectOnError(exchanged.error);
}
const { tokens, userInfo, providerProfile, accountKey } = exchanged;
const { accountId } = accountKey;
if (!callbackURL) {
+107
View File
@@ -0,0 +1,107 @@
import type { GenericEndpointContext } from "@better-auth/core";
import type { OAuth2Tokens, OAuthProvider } from "@better-auth/core/oauth2";
import { safeJSONParse } from "@better-auth/core/utils/json";
import { resolveOAuthAccountKey, toOAuthProfileRecord } from "./account-key";
import { OAUTH_CALLBACK_ERROR_CODES } from "./errors";
import { getOAuthCallbackPath } from "./utils";
interface OAuthCallbackInput {
code: string;
codeVerifier: string;
iss?: string | undefined;
deviceId?: string | undefined;
user?: string | undefined;
idTokenNonce?: string | undefined;
}
/**
* Turns a provider callback into a verified identity. It checks that the
* callback answers this authorization request, exchanges the code, and
* resolves the user and account key.
*
* Every route that completes a provider callback goes through here, so the
* routes run the same checks.
*/
export async function exchangeOAuthCallback(
ctx: GenericEndpointContext,
provider: OAuthProvider,
callback: OAuthCallbackInput,
) {
// RFC 9207: validate authorization server issuer identifier.
// Only validated when the provider sends the iss parameter;
// older OAuth servers that don't support RFC 9207 omit it.
if (callback.iss && provider.issuer && callback.iss !== provider.issuer) {
ctx.context.logger.error("OAuth issuer mismatch", {
expected: provider.issuer,
received: callback.iss,
});
return { error: 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 && !callback.idTokenNonce) {
ctx.context.logger.error(
"OAuth id_token nonce binding required but no expected nonce was found in state",
{ providerId: provider.id },
);
return { error: OAUTH_CALLBACK_ERROR_CODES.NONCE_BINDING_MISSING };
}
// A custom provider can throw before returning a promise, so await inside
// `try` to catch both.
let tokens: OAuth2Tokens | null;
try {
tokens = await provider.validateAuthorizationCode({
code: callback.code,
codeVerifier: callback.codeVerifier,
deviceId: callback.deviceId,
redirectURI: `${ctx.context.baseURL}${getOAuthCallbackPath(provider)}`,
});
} catch (error) {
ctx.context.logger.error("Failed to validate authorization code", error);
return { error: OAUTH_CALLBACK_ERROR_CODES.INVALID_CODE };
}
if (!tokens) {
return { error: OAUTH_CALLBACK_ERROR_CODES.INVALID_CODE };
}
const result = await provider.getUserInfo({
...tokens,
expectedIdTokenNonce: callback.idTokenNonce,
/**
* The user object from the provider
* This is only available for some providers like Apple
*/
user: safeJSONParse(callback.user) ?? undefined,
});
if (!result?.user) {
ctx.context.logger.error("Unable to get user info");
return { error: OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_GET_USER_INFO };
}
const accountKey = await resolveOAuthAccountKey(
provider,
tokens,
result.data,
).catch((error) => {
ctx.context.logger.error("Unable to derive provider account identity", {
providerId: provider.id,
error,
});
return null;
});
if (!accountKey) {
return { error: OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_GET_USER_INFO };
}
return {
tokens,
userInfo: result.user,
providerProfile: toOAuthProfileRecord(result.data),
accountKey,
};
}
@@ -7,10 +7,7 @@ import {
createAuthEndpoint,
createAuthMiddleware,
} from "@better-auth/core/api";
import type { AccountKey } from "@better-auth/core/db";
import { accountSchema, userSchema } from "@better-auth/core/db";
import type { OAuth2Tokens } from "@better-auth/core/oauth2";
import { safeJSONParse } from "@better-auth/core/utils/json";
import { defu } from "defu";
import * as z from "zod";
import { originCheck } from "../../api";
@@ -21,16 +18,12 @@ import { parseSetCookieHeader } from "../../cookies/cookie-utils";
import { symmetricDecrypt, symmetricEncrypt } from "../../crypto";
import type { EncryptionPurpose } from "../../crypto/purpose";
import { derivePurposeKey } from "../../crypto/purpose";
import {
resolveOAuthAccountKey,
toOAuthProfileRecord,
} from "../../oauth2/account-key";
import { exchangeOAuthCallback } from "../../oauth2/callback";
import { redirectOnError } from "../../oauth2/errors";
import {
handleOAuthUserInfo,
linkOAuthAccount,
} from "../../oauth2/link-account";
import { getOAuthCallbackPath } from "../../oauth2/utils";
import type { StateData } from "../../state";
import {
getAuthStateVerificationIdentifier,
@@ -165,6 +158,8 @@ const oauthCallbackQuerySchema = z.object({
code: z.string().optional(),
error: z.string().optional(),
user: z.string().optional(),
iss: z.string().optional(),
device_id: z.string().optional(),
});
export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
@@ -455,7 +450,13 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
);
return;
}
const { code, error, user: userData } = query.data;
const {
code,
error,
user: userData,
iss,
device_id: deviceId,
} = query.data;
// Decrypt state to get codeVerifier and callbackURL
let stateData: StateData;
@@ -509,74 +510,23 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
throw redirectOnError(ctx, errorURL, "oauth_provider_not_found");
}
// Exchange code for tokens
let tokens: OAuth2Tokens | null;
try {
tokens = await provider.validateAuthorizationCode({
code,
codeVerifier: stateData.codeVerifier,
redirectURI: `${ctx.context.baseURL}${getOAuthCallbackPath(provider)}`,
});
} catch (e) {
ctx.context.logger.error(
"Failed to validate authorization code",
e,
);
throw redirectOnError(ctx, errorURL, "invalid_code");
}
if (!tokens) {
throw redirectOnError(ctx, errorURL, "invalid_code");
}
const parsedUserData = userData
? safeJSONParse<{
name?: {
firstName?: string;
lastName?: string;
};
email?: string;
}>(userData)
: null;
// Get user info from provider
const userInfoResult = await provider.getUserInfo({
...tokens,
/**
* The user object from the provider
* This is only available for some providers like Apple
*/
user: parsedUserData ?? undefined,
const exchanged = await exchangeOAuthCallback(ctx, provider, {
code,
codeVerifier: stateData.codeVerifier,
iss,
deviceId,
user: userData,
idTokenNonce: stateData.idTokenNonce,
});
const userInfo = userInfoResult?.user;
if (!userInfo) {
ctx.context.logger.error("Unable to get user info from provider");
throw redirectOnError(ctx, errorURL, "unable_to_get_user_info");
if (exchanged.error) {
throw redirectOnError(ctx, errorURL, exchanged.error);
}
const { tokens, userInfo, providerProfile, accountKey } = exchanged;
if (!userInfo.email) {
ctx.context.logger.error("Provider did not return email");
throw redirectOnError(ctx, errorURL, "email_not_found");
}
let accountKey: AccountKey;
try {
accountKey = await resolveOAuthAccountKey(
provider,
tokens,
userInfoResult.data,
);
} catch (error) {
ctx.context.logger.error(
"Unable to derive provider account identity",
{
providerId: provider.id,
error,
},
);
throw redirectOnError(ctx, errorURL, "unable_to_get_user_info");
}
const providerProfile = toOAuthProfileRecord(userInfoResult.data);
const proxyCallbackURL = new URL(stateData.callbackURL);
const finalCallbackURL =
@@ -2437,3 +2437,142 @@ describe("oauth-proxy current URL trust", () => {
}
});
});
describe("oauth-proxy callback binding", () => {
const providerId = "test-idp";
function createProvider(
options: Partial<
Pick<
OAuthProvider,
"issuer" | "requiresIdTokenNonce" | "validateAuthorizationCode"
>
> = {},
) {
return {
id: providerId,
name: "Test IdP",
issuer: options.issuer,
requiresIdTokenNonce: options.requiresIdTokenNonce,
accountSubject: () => "test-subject",
createAuthorizationURL: vi.fn<OAuthProvider["createAuthorizationURL"]>(
({ state }) =>
new URL(`https://idp.example.com/authorize?state=${state}`),
),
validateAuthorizationCode:
options.validateAuthorizationCode ??
(async () => ({ accessToken: "test-access-token" })),
getUserInfo: vi.fn<OAuthProvider["getUserInfo"]>(async () => ({
user: {
email: "user@idp.example.com",
name: "Test User",
emailVerified: true,
},
data: { sub: "test-subject" },
})),
} satisfies OAuthProvider;
}
function withProvider(provider: OAuthProvider) {
return {
id: "test-idp-provider",
init: (ctx: { socialProviders: OAuthProvider[] }) => ({
context: { socialProviders: [provider, ...ctx.socialProviders] },
}),
};
}
/**
* Starts sign-in on a preview instance and completes the callback on
* production, returning where production redirects.
*/
async function completeProxiedCallback({
preview = createProvider(),
production = createProvider(),
query = "",
}: {
preview?: OAuthProvider;
production?: OAuthProvider;
query?: string;
} = {}) {
const productionInstance = await getTestInstance(
{
baseURL: "http://localhost:3000",
plugins: [oAuthProxy(), withProvider(production)],
},
{ disableTestUser: true },
);
const previewInstance = await getTestInstance(
{
baseURL: "http://preview.example.com",
plugins: [
oAuthProxy({ productionURL: "http://localhost:3000" }),
withProvider(preview),
],
},
{ disableTestUser: true },
);
const { url } = await previewInstance.client.signIn.social(
{ provider: providerId, callbackURL: "/dashboard" },
{ throw: true },
);
const state = new URL(url!).searchParams.get("state");
const redirect: { location: string | null } = { location: null };
await productionInstance.client.$fetch(
`/callback/${providerId}?code=test&state=${state}${query}`,
{
onError(context) {
redirect.location = context.response.headers.get("location");
},
},
);
if (!redirect.location) {
throw new Error("OAuth proxy callback did not redirect");
}
return new URL(redirect.location);
}
it("rejects a callback whose iss does not match the provider issuer", async () => {
const location = await completeProxiedCallback({
production: createProvider({ issuer: "https://idp.example.com" }),
query: "&iss=https://attacker.example",
});
expect(location.searchParams.get("error")).toBe("issuer_mismatch");
});
it("rejects a callback when the provider requires a nonce the state lacks", async () => {
const location = await completeProxiedCallback({
production: createProvider({ requiresIdTokenNonce: true }),
});
expect(location.searchParams.get("error")).toBe("nonce_binding_missing");
});
it("passes the nonce issued at sign-in to the provider's getUserInfo", async () => {
const preview = createProvider({ requiresIdTokenNonce: true });
const production = createProvider({ requiresIdTokenNonce: true });
await completeProxiedCallback({ preview, production });
const issuedNonce =
preview.createAuthorizationURL.mock.calls[0]?.[0].idTokenNonce;
expect(issuedNonce).toEqual(expect.any(String));
expect(production.getUserInfo).toHaveBeenCalledWith(
expect.objectContaining({ expectedIdTokenNonce: issuedNonce }),
);
});
it("redirects with invalid_code when the code exchange throws synchronously", async () => {
const location = await completeProxiedCallback({
production: createProvider({
validateAuthorizationCode: () => {
throw new Error("token endpoint misconfigured");
},
}),
});
expect(location.searchParams.get("error")).toBe("invalid_code");
});
});