Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 68 additions & 24 deletions src/lib/supabase/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthenticatedUser | null> {
Expand Down Expand Up @@ -102,7 +138,7 @@ export function unauthorizedResponse(error?: AuthenticationError) {
/**
* Resolve the user from the `@supabase/ssr` cookie session. The
* `sb-<ref>-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.
*/
Expand Down Expand Up @@ -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" };
Comment thread
BigSimmo marked this conversation as resolved.
}

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(
Expand Down
62 changes: 62 additions & 0 deletions tests/auth-tri-state.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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", {
Expand Down
3 changes: 2 additions & 1 deletion tests/private-access-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 9 additions & 2 deletions tests/settings-dialog-actions.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -83,7 +83,14 @@ function renderDialog(identityOverrides: Record<string, unknown> = {}) {
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<void>((resolve) => {
window.requestAnimationFrame(() => window.setTimeout(resolve, 60));
});
vi.clearAllMocks();
});

Expand Down