From c03666a5daf871532ba3d5dfd3950292dc004054 Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Tue, 10 Mar 2026 04:20:27 +0530 Subject: [PATCH] fix(oauth-provider): avoid fetch redirect CORS after login (#8519) --- .../src/plugins/oidc-provider/authorize.ts | 3 +- .../core/src/utils/fetch-metadata.test.ts | 28 ++++++++ packages/core/src/utils/fetch-metadata.ts | 3 + packages/oauth-provider/src/authorize.ts | 4 +- packages/oauth-provider/src/oauth.test.ts | 72 +++++++++++++++++++ 5 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/utils/fetch-metadata.test.ts create mode 100644 packages/core/src/utils/fetch-metadata.ts diff --git a/packages/better-auth/src/plugins/oidc-provider/authorize.ts b/packages/better-auth/src/plugins/oidc-provider/authorize.ts index e3f14af468..f0804f4620 100644 --- a/packages/better-auth/src/plugins/oidc-provider/authorize.ts +++ b/packages/better-auth/src/plugins/oidc-provider/authorize.ts @@ -1,5 +1,6 @@ import type { GenericEndpointContext } from "@better-auth/core"; import { APIError } from "@better-auth/core/error"; +import { isBrowserFetchRequest } from "@better-auth/core/utils/fetch-metadata"; import { getSessionFromCtx } from "../../api"; import { generateRandomString } from "../../crypto"; import { getClient } from "./index"; @@ -28,7 +29,7 @@ export async function authorize( options: OIDCOptions, ) { const handleRedirect = (url: string) => { - const fromFetch = ctx.request?.headers.get("sec-fetch-mode") === "cors"; + const fromFetch = isBrowserFetchRequest(ctx.request?.headers); if (fromFetch) { return ctx.json({ redirect: true, diff --git a/packages/core/src/utils/fetch-metadata.test.ts b/packages/core/src/utils/fetch-metadata.test.ts new file mode 100644 index 0000000000..c4295bd5fc --- /dev/null +++ b/packages/core/src/utils/fetch-metadata.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { isBrowserFetchRequest } from "./fetch-metadata"; + +describe("isBrowserFetchRequest", () => { + it("returns true for browser fetch requests", () => { + expect( + isBrowserFetchRequest( + new Headers({ + "Sec-Fetch-Mode": "cors", + }), + ), + ).toBe(true); + }); + + it("returns false for navigation requests", () => { + expect( + isBrowserFetchRequest( + new Headers({ + "Sec-Fetch-Mode": "navigate", + }), + ), + ).toBe(false); + }); + + it("returns false without fetch metadata", () => { + expect(isBrowserFetchRequest()).toBe(false); + }); +}); diff --git a/packages/core/src/utils/fetch-metadata.ts b/packages/core/src/utils/fetch-metadata.ts new file mode 100644 index 0000000000..6c1cfccf94 --- /dev/null +++ b/packages/core/src/utils/fetch-metadata.ts @@ -0,0 +1,3 @@ +export function isBrowserFetchRequest(headers?: Headers | null): boolean { + return headers?.get("sec-fetch-mode") === "cors"; +} diff --git a/packages/oauth-provider/src/authorize.ts b/packages/oauth-provider/src/authorize.ts index 33370609f9..561555b721 100644 --- a/packages/oauth-provider/src/authorize.ts +++ b/packages/oauth-provider/src/authorize.ts @@ -1,4 +1,5 @@ import type { GenericEndpointContext } from "@better-auth/core"; +import { isBrowserFetchRequest } from "@better-auth/core/utils/fetch-metadata"; import { getSessionFromCtx } from "better-auth/api"; import { generateRandomString, makeSignature } from "better-auth/crypto"; import type { Verification } from "better-auth/db"; @@ -40,8 +41,9 @@ export function formatErrorURL( } export const handleRedirect = (ctx: GenericEndpointContext, uri: string) => { + const fromFetch = isBrowserFetchRequest(ctx.request?.headers); const acceptJson = ctx.headers?.get("accept")?.includes("application/json"); - if (acceptJson) { + if (fromFetch || acceptJson) { return { redirect: true, url: uri.toString(), diff --git a/packages/oauth-provider/src/oauth.test.ts b/packages/oauth-provider/src/oauth.test.ts index 70763547fb..3f82848439 100644 --- a/packages/oauth-provider/src/oauth.test.ts +++ b/packages/oauth-provider/src/oauth.test.ts @@ -285,6 +285,78 @@ describe("oauth", async () => { expect(callbackUrl).toContain("/success"); }); + it("should return a JSON redirect after email sign-in in a fetch-based oauth flow", async ({ + onTestFinished, + }) => { + if (!oauthClient?.client_id || !oauthClient?.client_secret) { + throw Error("beforeAll not run properly"); + } + + const { customFetchImpl: customFetchImplRP } = await createTestInstance(); + + const client = createAuthClient({ + plugins: [genericOAuthClient()], + baseURL: rpBaseUrl, + fetchOptions: { + customFetchImpl: customFetchImplRP, + }, + }); + const headers = new Headers(); + const data = await client.signIn.oauth2( + { + providerId, + callbackURL: "/success", + }, + { + throw: true, + onSuccess: cookieSetter(headers), + }, + ); + + let loginRedirectUri = ""; + await authClient.$fetch(data.url, { + method: "GET", + onError(ctx) { + loginRedirectUri = ctx.response.headers.get("Location") || ""; + }, + }); + expect(loginRedirectUri).toContain("/login"); + + vi.stubGlobal("window", { + location: { + href: "", + search: new URL(loginRedirectUri, authServerBaseUrl).search, + }, + }); + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + + let signInLocationHeader = ""; + const signInResponse = await authClient.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + headers: { + "Sec-Fetch-Mode": "cors", + }, + throw: true, + onResponse(ctx) { + signInLocationHeader = ctx.response.headers.get("Location") || ""; + }, + }, + ); + + expect(signInLocationHeader).toBe(""); + expect(signInResponse.redirect).toBe(true); + expect(signInResponse.url).toContain( + `${rpBaseUrl}/api/auth/oauth2/callback/${providerId}`, + ); + expect(signInResponse.url).not.toContain(`${authServerBaseUrl}/login`); + }); + it("should sign in using generic oauth discovery", async ({ onTestFinished, }) => {