From c50902db3e6971017825af209a998d3587b7dadd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 04:35:38 +0000 Subject: [PATCH 1/2] fix(auth): treat blank session cookies as absent Blank or whitespace-only current and legacy Supabase session cookies were treated as presented credentials, which blocked legacy fallback during migration and forced 401 on anonymous public APIs. Require a non-blank value before counting a cookie as present, and cover the edge cases in auth-tri-state tests. Co-authored-by: BigSimmo --- src/lib/supabase/auth.ts | 19 ++++-- tests/auth-tri-state.test.ts | 109 ++++++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts index 59b09715e..3b1ddbeca 100644 --- a/src/lib/supabase/auth.ts +++ b/src/lib/supabase/auth.ts @@ -88,21 +88,30 @@ function extractLegacyCookieSessionAccessToken(request: Request): string | null return readCookies(request.headers.get("cookie")).get("sb-access-token")?.trim() || null; } +/** Blank or whitespace-only cookie values are leftovers, not presented credentials. */ +function cookieValueIsPresented(value: string | undefined): boolean { + return Boolean(value?.trim()); +} + function hasCurrentSessionCookie(request: Request): boolean { const cookies = readCookies(request.headers.get("cookie")); const cookieName = configuredSessionCookieName(); if (!cookieName) { // Without a configured project, a presented Supabase session cannot be validated and must fail closed. - return [...cookies.keys()].some((name) => /^sb-.+-auth-token(?:\.\d+)?$/.test(name)); + return [...cookies.entries()].some( + ([name, value]) => /^sb-.+-auth-token(?:\.\d+)?$/.test(name) && cookieValueIsPresented(value), + ); } - return [...cookies.keys()].some( - (name) => - name === cookieName || (name.startsWith(`${cookieName}.`) && /^\d+$/.test(name.slice(cookieName.length + 1))), + return [...cookies.entries()].some( + ([name, value]) => + cookieValueIsPresented(value) && + (name === cookieName || + (name.startsWith(`${cookieName}.`) && /^\d+$/.test(name.slice(cookieName.length + 1)))), ); } function hasLegacySessionCookie(request: Request): boolean { - return readCookies(request.headers.get("cookie")).has("sb-access-token"); + return cookieValueIsPresented(readCookies(request.headers.get("cookie")).get("sb-access-token")); } function extractSessionAccessToken(request: Request): string | null { diff --git a/tests/auth-tri-state.test.ts b/tests/auth-tri-state.test.ts index fb4b9505b..d04ac6382 100644 --- a/tests/auth-tri-state.test.ts +++ b/tests/auth-tri-state.test.ts @@ -1,10 +1,20 @@ import { describe, expect, it, vi } from "vitest"; +const { TEST_PROJECT_REF, TEST_SESSION_COOKIE } = vi.hoisted(() => { + const TEST_PROJECT_REF = "testprojectref123"; + return { + TEST_PROJECT_REF, + TEST_SESSION_COOKIE: `sb-${TEST_PROJECT_REF}-auth-token`, + }; +}); + +// Synthetic project identity for cookie-name resolution. Do not use the live +// project ref here — presence helpers derive `sb--auth-token` from this URL. vi.mock("@/lib/env", () => ({ env: { - NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", + NEXT_PUBLIC_SUPABASE_URL: `https://${TEST_PROJECT_REF}.supabase.co`, NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: undefined, - SUPABASE_PROJECT_REF: "sjrfecxgysukkwxsowpy", + SUPABASE_PROJECT_REF: TEST_PROJECT_REF, }, })); @@ -123,7 +133,7 @@ describe("optional authentication", () => { ); const request = new Request("http://localhost/api/search", { headers: { - cookie: `sb-access-token=expired-legacy-token; sb-sjrfecxgysukkwxsowpy-auth-token=${currentSession}`, + cookie: `sb-access-token=expired-legacy-token; ${TEST_SESSION_COOKIE}=${currentSession}`, }, }); @@ -162,6 +172,99 @@ describe("optional authentication", () => { expect(client.auth.getUser).toHaveBeenCalledWith("valid-legacy-token"); }); + + it("falls through blank current session cookies to a valid legacy token", async () => { + const client = { + auth: { + getUser: vi.fn(async (token: string) => + token === "valid-legacy-token" + ? { data: { user: { id: "user-1", app_metadata: {} } }, error: null } + : { data: { user: null }, error: { message: "Invalid token" } }, + ), + }, + }; + const request = new Request("http://localhost/api/search", { + headers: { + cookie: `${TEST_SESSION_COOKIE}=; sb-access-token=valid-legacy-token`, + }, + }); + + await expect(resolveOptionalAuthentication(request, client as never)).resolves.toMatchObject({ + status: "valid", + user: { id: "user-1" }, + }); + expect(client.auth.getUser).toHaveBeenCalledTimes(1); + expect(client.auth.getUser).toHaveBeenCalledWith("valid-legacy-token"); + }); + + it("falls through whitespace-only current chunk cookies to a valid legacy token", async () => { + const client = { + auth: { + getUser: vi.fn(async (token: string) => + token === "valid-legacy-token" + ? { data: { user: { id: "user-1", app_metadata: {} } }, error: null } + : { data: { user: null }, error: { message: "Invalid token" } }, + ), + }, + }; + const request = new Request("http://localhost/api/search", { + headers: { + cookie: `${TEST_SESSION_COOKIE}.0=%20; sb-access-token=valid-legacy-token`, + }, + }); + + await expect(resolveOptionalAuthentication(request, client as never)).resolves.toMatchObject({ + status: "valid", + user: { id: "user-1" }, + }); + expect(client.auth.getUser).toHaveBeenCalledWith("valid-legacy-token"); + }); + + it("treats a blank legacy session cookie as absent credentials", async () => { + const client = authClient({ data: { user: null }, error: null }); + const request = new Request("http://localhost/api/search", { + headers: { cookie: "sb-access-token=" }, + }); + + await expect(resolveOptionalAuthentication(request, client as never)).resolves.toEqual({ status: "absent" }); + expect(client.auth.getUser).not.toHaveBeenCalled(); + await expect(getOptionalAuthenticatedUser(request, client as never)).resolves.toBeNull(); + }); + + it("treats a whitespace-only legacy session cookie as absent credentials", async () => { + const client = authClient({ data: { user: null }, error: null }); + const request = new Request("http://localhost/api/search", { + headers: { cookie: "sb-access-token=%20" }, + }); + + await expect(resolveOptionalAuthentication(request, client as never)).resolves.toEqual({ status: "absent" }); + expect(client.auth.getUser).not.toHaveBeenCalled(); + }); + + it("does not fall back to legacy when a non-empty current session cookie is invalid", async () => { + const client = { + auth: { + getUser: vi.fn(async (token: string) => + token === "valid-legacy-token" + ? { data: { user: { id: "user-1", app_metadata: {} } }, error: null } + : { data: { user: null }, error: { message: "Invalid token" } }, + ), + }, + }; + const expiredCurrent = encodeURIComponent( + JSON.stringify({ access_token: "expired-current-token", refresh_token: "refresh-token", type: "bearer" }), + ); + const request = new Request("http://localhost/api/search", { + headers: { + cookie: `sb-access-token=valid-legacy-token; ${TEST_SESSION_COOKIE}=${expiredCurrent}`, + }, + }); + + await expect(resolveOptionalAuthentication(request, client as never)).resolves.toEqual({ status: "invalid" }); + expect(client.auth.getUser).toHaveBeenCalledTimes(1); + expect(client.auth.getUser).toHaveBeenCalledWith("expired-current-token"); + }); + it("rejects a legacy session cookie that Supabase reports as invalid", async () => { const client = authClient({ data: { user: null }, error: { message: "Invalid token" } }); const request = new Request("http://localhost/api/search", { From 177e9aa92e7f65bc3a5c912a47b03c2ca919f7a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 04:35:54 +0000 Subject: [PATCH 2/2] style: format blank-cookie auth changes Co-authored-by: BigSimmo --- src/lib/supabase/auth.ts | 3 +-- tests/auth-tri-state.test.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts index 3b1ddbeca..e5888390a 100644 --- a/src/lib/supabase/auth.ts +++ b/src/lib/supabase/auth.ts @@ -105,8 +105,7 @@ function hasCurrentSessionCookie(request: Request): boolean { return [...cookies.entries()].some( ([name, value]) => cookieValueIsPresented(value) && - (name === cookieName || - (name.startsWith(`${cookieName}.`) && /^\d+$/.test(name.slice(cookieName.length + 1)))), + (name === cookieName || (name.startsWith(`${cookieName}.`) && /^\d+$/.test(name.slice(cookieName.length + 1)))), ); } diff --git a/tests/auth-tri-state.test.ts b/tests/auth-tri-state.test.ts index d04ac6382..f4d6a15ef 100644 --- a/tests/auth-tri-state.test.ts +++ b/tests/auth-tri-state.test.ts @@ -172,7 +172,6 @@ describe("optional authentication", () => { expect(client.auth.getUser).toHaveBeenCalledWith("valid-legacy-token"); }); - it("falls through blank current session cookies to a valid legacy token", async () => { const client = { auth: {