diff --git a/src/cookies.spec.ts b/src/cookies.spec.ts index 85f841d..70598d9 100644 --- a/src/cookies.spec.ts +++ b/src/cookies.spec.ts @@ -280,6 +280,35 @@ describe("createStorageFromOptions for createServerClient", () => { true, ); + it("should retry cache headers when setAll rejects", async () => { + const cacheHeaders = { + "Cache-Control": "no-store", + Expires: "0", + Pragma: "no-cache", + }; + const cookiesToSet = [{ name: "cookie", value: "value", options: {} }]; + const receivedHeaders: Record[] = []; + let setAllCalls = 0; + + const { setAll } = createServerStorageWithSetAll( + async (_setCookies, headers) => { + setAllCalls += 1; + receivedHeaders.push(headers); + + if (setAllCalls === 1) { + throw new Error("setAll failed"); + } + }, + ); + + await expect(setAll(cookiesToSet, cacheHeaders)).rejects.toThrow( + "setAll failed", + ); + await expect(setAll(cookiesToSet, cacheHeaders)).resolves.toBeUndefined(); + + expect(receivedHeaders).toEqual([cacheHeaders, cacheHeaders]); + }); + it("should not call setAll on setItem", async () => { let setAllCalled = false; diff --git a/src/cookies.ts b/src/cookies.ts index 24257a3..625820f 100644 --- a/src/cookies.ts +++ b/src/cookies.ts @@ -413,6 +413,20 @@ export function createStorageFromOptions( }; } + const originalSetAll = setAll; + let hasSentHeaders = false; + + setAll = async (setCookies, headers) => { + const shouldSendHeaders = + !hasSentHeaders && Object.keys(headers).length > 0; + + await originalSetAll(setCookies, shouldSendHeaders ? headers : {}); + + if (shouldSendHeaders) { + hasSentHeaders = true; + } + }; + // This is the server client. It only uses getAll to read the initial // state. Any subsequent changes to the items is persisted in the // setItems and removedItems objects. createServerClient *must* use diff --git a/src/createServerClient.spec.ts b/src/createServerClient.spec.ts index d3b6b0c..ead842b 100644 --- a/src/createServerClient.spec.ts +++ b/src/createServerClient.spec.ts @@ -40,6 +40,67 @@ describe("createServerClient", () => { describe("use cases", () => { const storageKeys = [null, "custom-storage-key"]; + it("should not repeat cache headers across PKCE cookie writes", async () => { + const cookieStore = new Map(); + const responseHeaders = new Map(); + let setAllCalls = 0; + + const supabase = createServerClient( + "https://project-ref.supabase.co", + "anon-key", + { + cookies: { + getAll() { + return [...cookieStore].map(([name, value]) => ({ name, value })); + }, + + setAll(cookiesToSet, headers) { + setAllCalls += 1; + + cookiesToSet.forEach(({ name, value }) => { + if (value) { + cookieStore.set(name, value); + } else { + cookieStore.delete(name); + } + }); + + Object.entries(headers).forEach(([name, value]) => { + const normalizedName = name.toLowerCase(); + + if (responseHeaders.has(normalizedName)) { + throw new Error(`"${name}" header is already set`); + } + + responseHeaders.set(normalizedName, value); + }); + }, + }, + + global: { + fetch: async () => + new Response("{}", { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + }, + }, + ); + + const { error } = await supabase.auth.signInWithOtp({ + email: "user@example.com", + }); + + expect(error).toBeNull(); + expect(setAllCalls).toEqual(3); + expect(Object.fromEntries(responseHeaders)).toEqual({ + "cache-control": + "private, no-cache, no-store, must-revalidate, max-age=0", + expires: "0", + pragma: "no-cache", + }); + }); + storageKeys.forEach((storageKey) => { it(`should set PKCE code verifier correctly (storage key = ${storageKey})`, async () => { let setAllCalls = 0; diff --git a/src/types.ts b/src/types.ts index 27c871d..ba214ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,6 +37,12 @@ export type SetAllCookies = ( * reverse proxies, otherwise one user's session token can be served * to a different user. * + * For a server client, the cache headers are delivered only with the first + * cookie write. A new server client must be created for each request; + * reusing one across requests would leave later responses without the + * required cache headers. This object is empty on later calls from the same + * client. + * * The library passes the following headers when auth cookies are set: * - `Cache-Control: private, no-cache, no-store, must-revalidate, max-age=0` * - `Expires: 0`