diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts index 8e9a2e67..59b09715 100644 --- a/src/lib/supabase/auth.ts +++ b/src/lib/supabase/auth.ts @@ -47,34 +47,70 @@ function extractBearerAccessToken(request: Request): string | null { return headerToken || null; } -function extractCookieSessionAccessToken(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; - +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; } -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)); + 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 { + 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 +138,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 +180,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..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"; @@ -100,6 +108,60 @@ 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-sjrfecxgysukkwxsowpy-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("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", diff --git a/tests/settings-dialog-actions.dom.test.tsx b/tests/settings-dialog-actions.dom.test.tsx index c326fafd..a0a9d4ee 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,14 @@ function renderDialog(identityOverrides: Record = {}) { return { onSignOut }; } -afterEach(() => { +afterEach(async () => { + cleanup(); + // Sheet cleanup restores focus in requestAnimationFrame and performs one + // 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(); });