fix(oauth-provider): avoid fetch redirect CORS after login (#8519)

This commit is contained in:
Gautam Manchandani
2026-03-10 15:03:39 -07:00
committed by Alex Yang
parent 3f16e9f867
commit c03666a5da
5 changed files with 108 additions and 2 deletions
@@ -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,
@@ -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);
});
});
@@ -0,0 +1,3 @@
export function isBrowserFetchRequest(headers?: Headers | null): boolean {
return headers?.get("sec-fetch-mode") === "cors";
}
+3 -1
View File
@@ -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(),
+72
View File
@@ -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,
}) => {