From 177df6afa9d07fa32a694a3b7503f75bfdfd73a3 Mon Sep 17 00:00:00 2001 From: Taesu Date: Sat, 9 May 2026 23:13:26 +0900 Subject: [PATCH] fix(integrations): migrate cookie integrations to hooks.finally --- .changeset/cookie-drain-via-finally.md | 5 ++ .../src/integrations/next-js.test.ts | 65 ++++++++++++++++++ .../better-auth/src/integrations/next-js.ts | 68 +++++++++---------- .../src/integrations/svelte-kit.ts | 34 +++++----- .../src/integrations/tanstack-start-solid.ts | 36 +++++----- .../src/integrations/tanstack-start.ts | 36 +++++----- 6 files changed, 151 insertions(+), 93 deletions(-) create mode 100644 .changeset/cookie-drain-via-finally.md diff --git a/.changeset/cookie-drain-via-finally.md b/.changeset/cookie-drain-via-finally.md new file mode 100644 index 0000000000..441d58e9e2 --- /dev/null +++ b/.changeset/cookie-drain-via-finally.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Migrate `nextCookies`, `sveltekitCookies`, and `tanstackStartCookies` (React and Solid) cookie integrations to use `hooks.finally`. Cookies set by plugins running later in the array are now forwarded to the framework cookie store regardless of plugin declaration order. Closes #8911. diff --git a/packages/better-auth/src/integrations/next-js.test.ts b/packages/better-auth/src/integrations/next-js.test.ts index db77de3320..d03307f671 100644 --- a/packages/better-auth/src/integrations/next-js.test.ts +++ b/packages/better-auth/src/integrations/next-js.test.ts @@ -160,4 +160,69 @@ describe("next-js integration", () => { expect(session).not.toBeNull(); }); + + /** + * @see https://github.com/better-auth/better-auth/issues/8911 + */ + it("forwards cookies set by plugins ordered after nextCookies()", async () => { + const cookiesSet: Array<{ name: string; value: string }> = []; + const cookiesMock = vi.fn(async () => ({ + set: vi.fn((name: string, value: string) => { + cookiesSet.push({ name, value }); + }), + delete: vi.fn(), + get: vi.fn(), + })); + + vi.doMock("next/headers.js", () => ({ + cookies: cookiesMock, + headers: vi.fn(async () => new Headers()), + })); + + const [{ getTestInstance }, { nextCookies }, { createAuthMiddleware }] = + await Promise.all([ + import("../test-utils/test-instance"), + import("./next-js"), + import("@better-auth/core/api"), + ]); + + const lateCookieName = "late-plugin-cookie"; + const lateCookieValue = "late-value"; + const lateCookiePlugin = { + id: "late-cookie", + hooks: { + after: [ + { + matcher: () => true, + handler: createAuthMiddleware(async (ctx) => { + ctx.setCookie(lateCookieName, lateCookieValue); + }), + }, + ], + }, + }; + + const { auth, testUser } = await getTestInstance({ + plugins: [nextCookies(), lateCookiePlugin], + }); + + const signInRes = await auth.api.signInEmail({ + body: { + email: testUser.email, + password: testUser.password, + }, + returnHeaders: true, + }); + + const setCookieHeaders = signInRes.headers.getSetCookie(); + const lateInResponseHeaders = setCookieHeaders.some((c) => + c.includes(lateCookieName), + ); + expect(lateInResponseHeaders).toBe(true); + + const lateInCookieStore = cookiesSet.some( + (c) => c.name === lateCookieName && c.value === lateCookieValue, + ); + expect(lateInCookieStore).toBe(true); + }); }); diff --git a/packages/better-auth/src/integrations/next-js.ts b/packages/better-auth/src/integrations/next-js.ts index 1fe283fa01..bd85a04167 100644 --- a/packages/better-auth/src/integrations/next-js.ts +++ b/packages/better-auth/src/integrations/next-js.ts @@ -67,50 +67,48 @@ export const nextCookies = () => { }), }, ], - after: [ + finally: [ { - matcher(ctx) { + matcher() { return true; }, handler: createAuthMiddleware(async (ctx) => { - const returned = ctx.context.responseHeaders; if ("_flag" in ctx && ctx._flag === "router") { return; } - if (returned instanceof Headers) { - const setCookies = returned?.get("set-cookie"); - if (!setCookies) return; - const parsed = parseSetCookieHeader(setCookies); - let cookieHelper: Awaited< - ReturnType - >; - try { - const { cookies } = await import("next/headers.js"); - cookieHelper = await cookies(); - } catch (error) { - if ( - error instanceof Error && - (error.message.startsWith( - "`cookies` was called outside a request scope.", - ) || - error.message.includes("Cannot find module")) - ) { - // Monorepo workspaces outside of Next.js hit this path. - // @see https://nextjs.org/docs/messages/next-dynamic-api-wrong-context - return; - } - throw error; + const returned = ctx.context.responseHeaders; + if (!(returned instanceof Headers)) return; + const setCookies = returned.get("set-cookie"); + if (!setCookies) return; + const parsed = parseSetCookieHeader(setCookies); + let cookieHelper: Awaited< + ReturnType + >; + try { + const { cookies } = await import("next/headers.js"); + cookieHelper = await cookies(); + } catch (error) { + if ( + error instanceof Error && + (error.message.startsWith( + "`cookies` was called outside a request scope.", + ) || + error.message.includes("Cannot find module")) + ) { + // Monorepo workspaces outside of Next.js hit this path. + // @see https://nextjs.org/docs/messages/next-dynamic-api-wrong-context + return; } - parsed.forEach((value, key) => { - if (!key) return; - try { - cookieHelper.set(key, value.value, toCookieOptions(value)); - } catch { - // this will fail if the cookie is being set on server component - } - }); - return; + throw error; } + parsed.forEach((value, key) => { + if (!key) return; + try { + cookieHelper.set(key, value.value, toCookieOptions(value)); + } catch { + // this will fail if the cookie is being set on server component + } + }); }), }, ], diff --git a/packages/better-auth/src/integrations/svelte-kit.ts b/packages/better-auth/src/integrations/svelte-kit.ts index 68886acc2f..f269db9828 100644 --- a/packages/better-auth/src/integrations/svelte-kit.ts +++ b/packages/better-auth/src/integrations/svelte-kit.ts @@ -61,32 +61,30 @@ export const sveltekitCookies = ( id: "sveltekit-cookies", version: PACKAGE_VERSION, hooks: { - after: [ + finally: [ { matcher() { return true; }, handler: createAuthMiddleware(async (ctx) => { - const returned = ctx.context.responseHeaders; if ("_flag" in ctx && ctx._flag === "router") { return; } - if (returned instanceof Headers) { - const setCookies = returned?.get("set-cookie"); - if (!setCookies) return; - const event = getRequestEvent(); - if (!event) return; - const parsed = parseSetCookieHeader(setCookies); - - for (const [name, attributes] of parsed) { - try { - event.cookies.set(name, attributes.value, { - ...toCookieOptions(attributes), - path: attributes.path || "/", - }); - } catch { - // this will avoid any issue related to already streamed response - } + const returned = ctx.context.responseHeaders; + if (!(returned instanceof Headers)) return; + const setCookies = returned.get("set-cookie"); + if (!setCookies) return; + const event = getRequestEvent(); + if (!event) return; + const parsed = parseSetCookieHeader(setCookies); + for (const [name, attributes] of parsed) { + try { + event.cookies.set(name, attributes.value, { + ...toCookieOptions(attributes), + path: attributes.path || "/", + }); + } catch { + // this will avoid any issue related to already streamed response } } }), diff --git a/packages/better-auth/src/integrations/tanstack-start-solid.ts b/packages/better-auth/src/integrations/tanstack-start-solid.ts index 55fdd9b281..8f70da19e4 100644 --- a/packages/better-auth/src/integrations/tanstack-start-solid.ts +++ b/packages/better-auth/src/integrations/tanstack-start-solid.ts @@ -25,33 +25,29 @@ export const tanstackStartCookies = () => { id: "tanstack-start-cookies-solid", version: PACKAGE_VERSION, hooks: { - after: [ + finally: [ { - matcher(ctx) { + matcher() { return true; }, handler: createAuthMiddleware(async (ctx) => { - const returned = ctx.context.responseHeaders; if ("_flag" in ctx && ctx._flag === "router") { return; } - if (returned instanceof Headers) { - const setCookies = returned?.get("set-cookie"); - if (!setCookies) return; - const parsed = parseSetCookieHeader(setCookies); - const { setCookie } = await import( - "@tanstack/solid-start/server" - ); - parsed.forEach((value, key) => { - if (!key) return; - try { - setCookie(key, value.value, toCookieOptions(value)); - } catch { - // this will fail if the cookie is being set on server component - } - }); - return; - } + const returned = ctx.context.responseHeaders; + if (!(returned instanceof Headers)) return; + const setCookies = returned.get("set-cookie"); + if (!setCookies) return; + const parsed = parseSetCookieHeader(setCookies); + const { setCookie } = await import("@tanstack/solid-start/server"); + parsed.forEach((value, key) => { + if (!key) return; + try { + setCookie(key, value.value, toCookieOptions(value)); + } catch { + // this will fail if the cookie is being set on server component + } + }); }), }, ], diff --git a/packages/better-auth/src/integrations/tanstack-start.ts b/packages/better-auth/src/integrations/tanstack-start.ts index 8c7d708211..6f9a0bcccc 100644 --- a/packages/better-auth/src/integrations/tanstack-start.ts +++ b/packages/better-auth/src/integrations/tanstack-start.ts @@ -25,33 +25,29 @@ export const tanstackStartCookies = () => { id: "tanstack-start-cookies", version: PACKAGE_VERSION, hooks: { - after: [ + finally: [ { - matcher(ctx) { + matcher() { return true; }, handler: createAuthMiddleware(async (ctx) => { - const returned = ctx.context.responseHeaders; if ("_flag" in ctx && ctx._flag === "router") { return; } - if (returned instanceof Headers) { - const setCookies = returned?.get("set-cookie"); - if (!setCookies) return; - const parsed = parseSetCookieHeader(setCookies); - const { setCookie } = await import( - "@tanstack/react-start/server" - ); - parsed.forEach((value, key) => { - if (!key) return; - try { - setCookie(key, value.value, toCookieOptions(value)); - } catch { - // this will fail if the cookie is being set on server component - } - }); - return; - } + const returned = ctx.context.responseHeaders; + if (!(returned instanceof Headers)) return; + const setCookies = returned.get("set-cookie"); + if (!setCookies) return; + const parsed = parseSetCookieHeader(setCookies); + const { setCookie } = await import("@tanstack/react-start/server"); + parsed.forEach((value, key) => { + if (!key) return; + try { + setCookie(key, value.value, toCookieOptions(value)); + } catch { + // this will fail if the cookie is being set on server component + } + }); }), }, ],