From 068941dd41ef34c0350e1cea5b5f2bf69e5c85c2 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:38:03 +0800 Subject: [PATCH 1/4] fix(auth): prefer current session cookies --- src/lib/supabase/auth.ts | 47 ++++++++++++++++++++++++------------ tests/auth-tri-state.test.ts | 27 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts index 8e9a2e67..ae28d765 100644 --- a/src/lib/supabase/auth.ts +++ b/src/lib/supabase/auth.ts @@ -47,11 +47,8 @@ function extractBearerAccessToken(request: Request): string | null { return headerToken || null; } -function extractCookieSessionAccessToken(request: Request): string | null { +function extractCurrentCookieSessionAccessToken(request: Request): string | null { const cookies = readCookies(request.headers.get("cookie")); - const legacyAccessToken = cookies.get("sb-access-token")?.trim(); - if (legacyAccessToken) return legacyAccessToken; - for (const [name, value] of cookies.entries()) { if (!/^sb-.+-auth-token$/.test(name)) continue; if (!value) continue; @@ -68,13 +65,25 @@ function extractCookieSessionAccessToken(request: Request): string | null { return null; } -function hasSessionCookie(request: Request): boolean { +function extractLegacyCookieSessionAccessToken(request: Request): string | null { + return readCookies(request.headers.get("cookie")).get("sb-access-token")?.trim() || null; +} + +function hasCurrentSessionCookie(request: Request): boolean { const cookies = readCookies(request.headers.get("cookie")); - return [...cookies.keys()].some((name) => name === "sb-access-token" || /^sb-.+-auth-token(?:\.\d+)?$/.test(name)); + return [...cookies.keys()].some((name) => /^sb-.+-auth-token(?:\.\d+)?$/.test(name)); +} + +function hasLegacySessionCookie(request: Request): boolean { + return readCookies(request.headers.get("cookie")).has("sb-access-token"); } function extractSessionAccessToken(request: Request): string | null { - return extractBearerAccessToken(request) ?? extractCookieSessionAccessToken(request); + return ( + extractBearerAccessToken(request) ?? + extractCurrentCookieSessionAccessToken(request) ?? + extractLegacyCookieSessionAccessToken(request) + ); } async function getUserFromAccessToken(supabase: AdminClient, token: string): Promise { @@ -102,7 +111,7 @@ export function unauthorizedResponse(error?: AuthenticationError) { /** * Resolve the user from the `@supabase/ssr` cookie session. The * `sb--auth-token` cookie it writes is base64-encoded (and chunked when - * large), which `extractCookieSessionAccessToken`'s plain-JSON parser cannot read, so + * large), which `extractCurrentCookieSessionAccessToken`'s plain-JSON parser cannot read, so * this uses the ssr server client to decode + validate it. Returns null when * the public env is absent or no `sb-` cookie is present. */ @@ -144,16 +153,24 @@ export async function resolveOptionalAuthentication( return bearerUser ? { status: "valid", user: bearerUser } : { status: "invalid" }; } - const cookieToken = extractCookieSessionAccessToken(request); - if (cookieToken) { - const cookieTokenUser = await getUserFromAccessToken(supabase, cookieToken); - return cookieTokenUser ? { status: "valid", user: cookieTokenUser } : { status: "invalid" }; + const currentCookieToken = extractCurrentCookieSessionAccessToken(request); + if (currentCookieToken) { + const currentCookieUser = await getUserFromAccessToken(supabase, currentCookieToken); + return currentCookieUser ? { status: "valid", user: currentCookieUser } : { status: "invalid" }; + } + + if (hasCurrentSessionCookie(request)) { + const currentCookieUser = await getUserFromRequestCookies(request); + return currentCookieUser ? { status: "valid", user: currentCookieUser } : { status: "invalid" }; } - if (!hasSessionCookie(request)) return { status: "absent" }; + const legacyCookieToken = extractLegacyCookieSessionAccessToken(request); + if (legacyCookieToken) { + const legacyCookieUser = await getUserFromAccessToken(supabase, legacyCookieToken); + return legacyCookieUser ? { status: "valid", user: legacyCookieUser } : { status: "invalid" }; + } - const cookieUser = await getUserFromRequestCookies(request); - return cookieUser ? { status: "valid", user: cookieUser } : { status: "invalid" }; + return hasLegacySessionCookie(request) ? { status: "invalid" } : { status: "absent" }; } export async function requireAuthenticatedUser( diff --git a/tests/auth-tri-state.test.ts b/tests/auth-tri-state.test.ts index 7d08d47c..b53e3c62 100644 --- a/tests/auth-tri-state.test.ts +++ b/tests/auth-tri-state.test.ts @@ -100,6 +100,33 @@ describe("optional authentication", () => { expect(client.auth.getUser).toHaveBeenCalledWith("valid-cookie-token"); }); + it("prefers a current Supabase session cookie over a stale legacy access-token cookie", async () => { + const client = { + auth: { + getUser: vi.fn(async (token: string) => + token === "current-cookie-token" + ? { data: { user: { id: "user-1", app_metadata: {} } }, error: null } + : { data: { user: null }, error: { message: "Invalid token" } }, + ), + }, + }; + const currentSession = encodeURIComponent( + JSON.stringify({ access_token: "current-cookie-token", refresh_token: "refresh-token", type: "bearer" }), + ); + const request = new Request("http://localhost/api/search", { + headers: { + cookie: `sb-access-token=expired-legacy-token; sb-project-ref-auth-token=${currentSession}`, + }, + }); + + 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("current-cookie-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 925ef6f499611ab92cb56037e6d749ddadccbcdf Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:48:20 +0800 Subject: [PATCH 2/4] fix(auth): scope session cookies to project --- src/lib/supabase/auth.ts | 49 ++++++++++++++++++++++------- tests/auth-tri-state.test.ts | 37 +++++++++++++++++++++- tests/private-access-routes.test.ts | 3 +- 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts index ae28d765..59b09715 100644 --- a/src/lib/supabase/auth.ts +++ b/src/lib/supabase/auth.ts @@ -47,21 +47,40 @@ function extractBearerAccessToken(request: Request): string | null { return headerToken || null; } -function extractCurrentCookieSessionAccessToken(request: Request): string | null { - const cookies = readCookies(request.headers.get("cookie")); - for (const [name, value] of cookies.entries()) { - if (!/^sb-.+-auth-token$/.test(name)) continue; - if (!value) continue; - +function configuredSupabaseProjectRef(): string | null { + if (env.NEXT_PUBLIC_SUPABASE_URL) { try { - const parsed = JSON.parse(value); - const accessToken = typeof parsed?.access_token === "string" ? parsed.access_token.trim() : ""; - if (accessToken) return accessToken; + const hostname = new URL(env.NEXT_PUBLIC_SUPABASE_URL).hostname.toLowerCase(); + const match = hostname.match(/^([a-z0-9]+)\.supabase\.co$/); + if (match?.[1]) return match[1]; } catch { - // Optional legacy auth-cookie format; ignore invalid payloads. + // Invalid public URLs are rejected by env parsing; retain the explicit-ref fallback for tests and local tooling. } } + const explicitRef = env.SUPABASE_PROJECT_REF?.trim().toLowerCase(); + return explicitRef && /^[a-z0-9]+$/.test(explicitRef) ? explicitRef : null; +} + +function configuredSessionCookieName(): string | null { + const projectRef = configuredSupabaseProjectRef(); + return projectRef ? `sb-${projectRef}-auth-token` : null; +} + +function extractCurrentCookieSessionAccessToken(request: Request): string | null { + const cookies = readCookies(request.headers.get("cookie")); + const cookieName = configuredSessionCookieName(); + const value = cookieName ? cookies.get(cookieName) : undefined; + if (!value) return null; + + try { + const parsed = JSON.parse(value); + const accessToken = typeof parsed?.access_token === "string" ? parsed.access_token.trim() : ""; + if (accessToken) return accessToken; + } catch { + // The current SSR format can be base64-encoded; the server client validates it below. + } + return null; } @@ -71,7 +90,15 @@ function extractLegacyCookieSessionAccessToken(request: Request): string | null function hasCurrentSessionCookie(request: Request): boolean { const cookies = readCookies(request.headers.get("cookie")); - return [...cookies.keys()].some((name) => /^sb-.+-auth-token(?:\.\d+)?$/.test(name)); + 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.keys()].some( + (name) => + name === cookieName || (name.startsWith(`${cookieName}.`) && /^\d+$/.test(name.slice(cookieName.length + 1))), + ); } function hasLegacySessionCookie(request: Request): boolean { diff --git a/tests/auth-tri-state.test.ts b/tests/auth-tri-state.test.ts index b53e3c62..fb4b9505 100644 --- a/tests/auth-tri-state.test.ts +++ b/tests/auth-tri-state.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it, vi } from "vitest"; +vi.mock("@/lib/env", () => ({ + env: { + NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: undefined, + SUPABASE_PROJECT_REF: "sjrfecxgysukkwxsowpy", + }, +})); + import { publicAccessContext } from "@/lib/public-api-access"; import { AuthenticationError, getOptionalAuthenticatedUser, resolveOptionalAuthentication } from "@/lib/supabase/auth"; @@ -115,7 +123,7 @@ describe("optional authentication", () => { ); const request = new Request("http://localhost/api/search", { headers: { - cookie: `sb-access-token=expired-legacy-token; sb-project-ref-auth-token=${currentSession}`, + cookie: `sb-access-token=expired-legacy-token; sb-sjrfecxgysukkwxsowpy-auth-token=${currentSession}`, }, }); @@ -127,6 +135,33 @@ describe("optional authentication", () => { expect(client.auth.getUser).toHaveBeenCalledWith("current-cookie-token"); }); + it("ignores a stale project's session cookie when the live legacy token is valid", 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 staleSession = encodeURIComponent( + JSON.stringify({ access_token: "stale-project-token", refresh_token: "refresh-token", type: "bearer" }), + ); + const request = new Request("http://localhost/api/search", { + headers: { + cookie: `sb-qjgitjyhxrwxsrydablr-auth-token=${staleSession}; 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("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", { diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 57d3b4c6..7f442b7d 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -317,6 +317,7 @@ function mockRuntime( vi.doUnmock("@/lib/demo-data"); vi.doMock("@/lib/env", () => ({ env: { + NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", MAX_UPLOAD_MB: 150, MAX_CONCURRENT_UPLOADS: options.maxConcurrentUploads ?? 1, MAX_IN_FLIGHT_UPLOAD_MB: options.maxInFlightUploadMb ?? 151, @@ -390,7 +391,7 @@ function authenticatedAuthTokenCookieRequest(path: string, init?: RequestInit) { return request(path, { ...init, headers: { - cookie: `sb-random-project-ref-auth-token=${encodeURIComponent( + cookie: `sb-sjrfecxgysukkwxsowpy-auth-token=${encodeURIComponent( JSON.stringify({ access_token: token, refresh_token: "refresh-token", From f7e6c31e9e39cbfa27aff0c08bb2300ac22196dd Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:55:12 +0800 Subject: [PATCH 3/4] test: drain settings sheet cleanup timers --- tests/settings-dialog-actions.dom.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/settings-dialog-actions.dom.test.tsx b/tests/settings-dialog-actions.dom.test.tsx index c326fafd..c5766ba4 100644 --- a/tests/settings-dialog-actions.dom.test.tsx +++ b/tests/settings-dialog-actions.dom.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; // The settings surface owns the destructive privacy actions (clear recent @@ -83,7 +83,11 @@ function renderDialog(identityOverrides: Record = {}) { return { onSignOut }; } -afterEach(() => { +afterEach(async () => { + cleanup(); + // Sheet cleanup restores focus in requestAnimationFrame and performs one + // defensive retry 50 ms later. Let both finish before jsdom removes document. + await new Promise((resolve) => window.setTimeout(resolve, 75)); vi.clearAllMocks(); }); From f5d00416be2d52ebbf59ef1b0c108021446551a6 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:58:00 +0800 Subject: [PATCH 4/4] test: order settings cleanup drain --- tests/settings-dialog-actions.dom.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/settings-dialog-actions.dom.test.tsx b/tests/settings-dialog-actions.dom.test.tsx index c5766ba4..a0a9d4ee 100644 --- a/tests/settings-dialog-actions.dom.test.tsx +++ b/tests/settings-dialog-actions.dom.test.tsx @@ -86,8 +86,11 @@ function renderDialog(identityOverrides: Record = {}) { afterEach(async () => { cleanup(); // Sheet cleanup restores focus in requestAnimationFrame and performs one - // defensive retry 50 ms later. Let both finish before jsdom removes document. - await new Promise((resolve) => window.setTimeout(resolve, 75)); + // defensive retry 50 ms later. Queue our timer from the following frame so + // it cannot overtake that nested retry when the CI event loop is delayed. + await new Promise((resolve) => { + window.requestAnimationFrame(() => window.setTimeout(resolve, 60)); + }); vi.clearAllMocks(); });