fix(oauth-proxy): return multiple Set-Cookie headers instead of a single comma-separated header (#6039)

This commit is contained in:
Shotaro Nakamura
2025-11-18 04:41:04 +09:00
committed by GitHub
parent 9571c96e40
commit 9e41891d10
2 changed files with 96 additions and 6 deletions

View File

@@ -4,7 +4,7 @@ import {
createAuthMiddleware,
} from "@better-auth/core/api";
import { env } from "@better-auth/core/env";
import type { EndpointContext } from "better-call";
import type { CookieOptions, EndpointContext } from "better-call";
import * as z from "zod";
import { originCheck } from "../../api";
import { parseJSON } from "../../client/parser";
@@ -186,12 +186,53 @@ export const oAuthProxy = (opts?: OAuthProxyOptions | undefined) => {
filteredAttrs.push("Secure");
}
return filteredAttrs.length > 0
? `${name}=${value}; ${filteredAttrs.join("; ")}`
: `${name}=${value}`;
// Build options
const options: CookieOptions = {};
for (const attr of filteredAttrs) {
const [attrName, attrValue] = attr.split("=");
if (!attrName) continue;
switch (attrName.toLowerCase()) {
case "path":
options.path = attrValue;
break;
case "expires":
if (!attrValue) break;
options.expires = new Date(attrValue);
break;
case "samesite":
options.sameSite = attrValue as "lax" | "strict" | "none";
break;
case "httponly":
options.httpOnly = true;
break;
case "max-age":
if (!attrValue) break;
options.maxAge = parseInt(attrValue, 10);
break;
case "prefix":
if (!attrValue) break;
options.prefix = attrValue as "host" | "secure";
break;
case "partitioned": {
options.partitioned = true;
break;
}
}
}
return {
name,
value,
options,
};
});
ctx.setHeader("set-cookie", processedCookies.join(", "));
for (const cookie of processedCookies) {
// using `ctx.setHeader` overrides previous Set-Cookie headers
// so use ctx.setCookie helper instead
// https://github.com/Bekacru/better-call/blob/d27ac20e64b329a4851e97adf864098a9bc2a260/src/context.ts#L217
ctx.setCookie(cookie.name, cookie.value, cookie.options);
}
throw ctx.redirect(ctx.query.callbackURL);
},
),

View File

@@ -2,7 +2,7 @@ import type { GoogleProfile } from "@better-auth/core/social-providers";
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { signJWT } from "../../crypto";
import { signJWT, symmetricEncrypt } from "../../crypto";
import { getTestInstance } from "../../test-utils/test-instance";
import { DEFAULT_SECRET } from "../../utils/constants";
import { oAuthProxy } from ".";
@@ -244,4 +244,53 @@ describe("oauth-proxy", async () => {
},
});
});
it("should redirect to proxy url", async () => {
const { client, auth } = await getTestInstance({
plugins: [
oAuthProxy({
currentURL: "http://preview-localhost:3000",
}),
],
socialProviders: {
google: {
clientId: "test",
clientSecret: "test",
},
},
});
const { secret } = await auth.$context;
const mockCookies = {
sessionid: "abcd1234",
state: "statevalue",
};
const mockCookiesString = Object.entries(mockCookies)
.map(([k, v]) => `${k}=${v}`)
.join(", ");
const cookies = await symmetricEncrypt({
key: secret,
data: mockCookiesString,
});
await client.$fetch(
`/oauth-proxy-callback?callbackURL=%2Fdashboard&cookies=${cookies}`,
{
onError(context) {
const headersList = [...context.response.headers];
const parsedCookies: Record<string, string> = {};
for (const [key, value] of headersList) {
if (key.toLowerCase() === "set-cookie") {
const [cookiePair] = value.split(";");
if (!cookiePair) continue;
const [cookieKey, cookieValue] = cookiePair.split("=");
if (cookieKey === undefined || cookieValue === undefined)
continue;
parsedCookies[cookieKey] = cookieValue;
}
}
expect(mockCookies).toEqual(parsedCookies);
},
},
);
});
});