diff --git a/README.md b/README.md index d008403..dc8cd71 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,16 @@ Please refer to the [official server-side rendering guides](https://supabase.com For guidance on choosing between `getSession()`, `getUser()`, and `getClaims()`, see the [official server-side rendering guides](https://supabase.com/docs/guides/auth/server-side). +### The `auth.storage` option is ignored + +`createBrowserClient` and `createServerClient` always store the session in +cookies — this is the entire point of the package, since it lets a +server-rendered request read the same session the browser wrote. Passing +`auth.storage` has no effect; a one-time console warning is logged if you do. (`auth.userStorage` is different and is still respected when `cookies.encode` is set to `"tokens-only"`.) If you +don't need server-side access to the session, use `@supabase/supabase-js`'s +`createClient` directly with your own `storage` (e.g. `localStorage`) — +there's no reason to use `@supabase/ssr` in that case. + ### Concurrent requests with the same expired session Supabase refresh tokens are single-use. If two requests arrive simultaneously diff --git a/src/createBrowserClient.spec.ts b/src/createBrowserClient.spec.ts index 16a776f..7031431 100644 --- a/src/createBrowserClient.spec.ts +++ b/src/createBrowserClient.spec.ts @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { MAX_CHUNK_SIZE, stringToBase64URL } from "./utils"; import { CookieOptions } from "./types"; import { createBrowserClient } from "./createBrowserClient"; +import { resetWarnOnceForTesting } from "./warnOnce"; // Spy on createClient to capture auth options passed through const createClientSpy = vi.fn().mockReturnValue({ @@ -157,4 +158,67 @@ describe("createBrowserClient", () => { ).not.toThrow(); }); }); + + describe("storage option", () => { + let warnings: any[][]; + let warnSpy: any; + + beforeEach(() => { + resetWarnOnceForTesting(); + warnings = []; + warnSpy = vi + .spyOn(console, "warn") + .mockImplementation((...args: any[]) => { + warnings.push(args); + }); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("warns when `auth.storage` is passed, since it is always ignored", () => { + createBrowserClient("http://localhost", "anon-key", { + isSingleton: false, + auth: { storage: {} as any }, + }); + + expect(warnings.some((args) => /auth\.storage/.test(args[0]))).toBe(true); + }); + + it("warns only once across multiple calls", () => { + createBrowserClient("http://localhost", "anon-key", { + isSingleton: false, + auth: { storage: {} as any }, + }); + createBrowserClient("http://localhost", "anon-key", { + isSingleton: false, + auth: { storage: {} as any }, + }); + + expect( + warnings.filter((args) => /auth\.storage/.test(args[0])).length, + ).toBe(1); + }); + + it("does not warn when `auth.storage` is not passed", () => { + createBrowserClient("http://localhost", "anon-key", { + isSingleton: false, + }); + + expect(warnings.some((args) => /auth\.storage/.test(args[0]))).toBe( + false, + ); + }); + + it("still uses the cookie-backed storage even when `auth.storage` is passed", () => { + createBrowserClient("http://localhost", "anon-key", { + isSingleton: false, + auth: { storage: { fake: true } as any }, + }); + + const passedOptions = createClientSpy.mock.calls[0][2]; + expect(passedOptions.auth.storage).not.toEqual({ fake: true }); + }); + }); }); diff --git a/src/createBrowserClient.ts b/src/createBrowserClient.ts index f359179..6896ca6 100644 --- a/src/createBrowserClient.ts +++ b/src/createBrowserClient.ts @@ -12,6 +12,7 @@ import type { } from "./types"; import { isBrowser } from "./utils"; import { VERSION } from "./version"; +import { warnOnce } from "./warnOnce"; import { warnIfUsingDeprecatedAuthHelpersPackage } from "./warnDeprecatedPackage"; let cachedBrowserClient: SupabaseClient | undefined; @@ -29,6 +30,14 @@ let cachedBrowserClient: SupabaseClient | undefined; * in difficult to debug authentication issues such as random logouts, early * session termination or problems with inconsistent state. * + * **The `auth.storage` option is ignored.** The session is always persisted via + * cookies so that a server-rendered request can read it. Passing + * `options.auth.storage` has no effect — a one-time console warning is logged + * if you do. (`options.auth.userStorage` is still respected when `cookies.encode` is `"tokens-only"`.) + * If you don't need the session to be readable server-side, use + * `@supabase/supabase-js`'s `createClient` directly with your own `storage` + * instead; `@supabase/ssr` isn't needed in that case. + * * @param supabaseUrl The URL of the Supabase project. * @param supabaseKey The `anon` API key of the Supabase project. * @param options Various configuration options. @@ -107,6 +116,12 @@ export function createBrowserClient< ); } + if (options?.auth?.storage) { + warnOnce( + "@supabase/ssr: createBrowserClient always manages the session via cookies, so the `auth.storage` option you passed is ignored. If you don't need the session to be readable on the server, use @supabase/supabase-js's createClient directly with your own `storage` instead.", + ); + } + const { storage } = createStorageFromOptions( { ...options, diff --git a/src/createServerClient.spec.ts b/src/createServerClient.spec.ts index d3b6b0c..28b4d55 100644 --- a/src/createServerClient.spec.ts +++ b/src/createServerClient.spec.ts @@ -1,8 +1,9 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { MAX_CHUNK_SIZE, stringToBase64URL } from "./utils"; import { CookieOptions } from "./types"; import { createServerClient } from "./createServerClient"; +import { resetWarnOnceForTesting } from "./warnOnce"; describe("createServerClient", () => { describe("validation", () => { @@ -626,4 +627,154 @@ describe("createServerClient", () => { expect(fetchCallCount).toBe(0); }); }); + + describe("storage option", () => { + let warnings: any[][]; + let warnSpy: any; + + beforeEach(() => { + resetWarnOnceForTesting(); + warnings = []; + warnSpy = vi + .spyOn(console, "warn") + .mockImplementation((...args: any[]) => { + warnings.push(args); + }); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("warns when `auth.storage` is passed, since it is always ignored", () => { + createServerClient("https://project-ref.supabase.co", "anon-key", { + cookies: { + getAll() { + return []; + }, + setAll() { + // no-op + }, + }, + auth: { storage: {} as any }, + }); + + expect(warnings.some((args) => /auth\.storage/.test(args[0]))).toBe(true); + }); + + it("warns only once across multiple calls", () => { + const options = { + cookies: { + getAll() { + return []; + }, + setAll() { + // no-op + }, + }, + auth: { storage: {} as any }, + }; + + createServerClient( + "https://project-ref.supabase.co", + "anon-key", + options, + ); + createServerClient( + "https://project-ref.supabase.co", + "anon-key", + options, + ); + + expect( + warnings.filter((args) => /auth\.storage/.test(args[0])).length, + ).toBe(1); + }); + + it("does not warn when `auth.storage` is not passed", () => { + createServerClient("https://project-ref.supabase.co", "anon-key", { + cookies: { + getAll() { + return []; + }, + setAll() { + // no-op + }, + }, + }); + + expect(warnings.some((args) => /auth\.storage/.test(args[0]))).toBe( + false, + ); + }); + + it("still uses the cookie-backed storage even when `auth.storage` is passed", async () => { + const customStorageCalls: string[] = []; + + const supabase = createServerClient( + "https://project-ref.supabase.co", + "anon-key", + { + cookies: { + getAll() { + return [ + { + name: "sb-project-ref-auth-token", + value: + "base64-" + + stringToBase64URL( + JSON.stringify({ + token_type: "bearer", + access_token: "", + refresh_token: "", + expires_at: Math.floor(Date.now() / 1000) + 5 * 60, // expires in 5 mins + expires_in: 5 * 60, + user: { + id: "", + }, + }), + ), + }, + ]; + }, + + setAll() { + // no-op + }, + }, + + auth: { + storage: { + getItem: async (key: string) => { + customStorageCalls.push(key); + return null; + }, + setItem: async (key: string) => { + customStorageCalls.push(key); + }, + removeItem: async (key: string) => { + customStorageCalls.push(key); + }, + }, + }, + + global: { + fetch: async () => { + throw new Error("Should not be called"); + }, + }, + }, + ); + + const { + data: { session }, + error, + } = await supabase.auth.getSession(); + + expect(error).toBeNull(); + expect(session).not.toBeNull(); + expect(session!.user.id).toEqual(""); + expect(customStorageCalls).toEqual([]); + }); + }); }); diff --git a/src/createServerClient.ts b/src/createServerClient.ts index d60d862..8184b85 100644 --- a/src/createServerClient.ts +++ b/src/createServerClient.ts @@ -13,6 +13,7 @@ import type { } from "./types"; import { memoryLocalStorageAdapter } from "./utils/helpers"; import { VERSION } from "./version"; +import { warnOnce } from "./warnOnce"; import { warnIfUsingDeprecatedAuthHelpersPackage } from "./warnDeprecatedPackage"; /** @@ -84,6 +85,13 @@ export function createServerClient< * no explicit JWT is passed). Token refreshes write the updated session back * to cookies via the `setAll` handler. * + * **The `auth.storage` option is ignored.** The session is always persisted via + * cookies. Passing `options.auth.storage` has no effect — a one-time console + * warning is logged if you do. (`options.auth.userStorage` is still respected when `cookies.encode` is `"tokens-only"`.) + * If you want to source the session from somewhere other than the request cookies, + * use `@supabase/supabase-js`'s `createClient` directly with your own `storage` + * instead; `@supabase/ssr` isn't needed in that case. + * * @param supabaseUrl The URL of the Supabase project. * @param supabaseKey The `anon` API key of the Supabase project. * @param options Various configuration options. @@ -129,6 +137,12 @@ export function createServerClient< ); } + if (options?.auth?.storage) { + warnOnce( + "@supabase/ssr: createServerClient always manages the session via cookies, so the `auth.storage` option you passed is ignored. If you want to source the session from somewhere other than the request cookies, use @supabase/supabase-js's createClient directly with your own `storage` instead.", + ); + } + const { storage, getAll, setAll, setItems, removedItems } = createStorageFromOptions( { diff --git a/src/warnOnce.ts b/src/warnOnce.ts new file mode 100644 index 0000000..ff94e32 --- /dev/null +++ b/src/warnOnce.ts @@ -0,0 +1,22 @@ +const warnedMessages = new Set(); + +/** + * Logs a warning to the console only once per process for each distinct + * message. Used for configuration warnings that would otherwise fire on + * every client creation (e.g. once per server request). + */ +export function warnOnce(message: string): void { + if (warnedMessages.has(message)) { + return; + } + + warnedMessages.add(message); + console.warn(message); +} + +/** + * Clears the set of already-logged messages. Only for use in tests. + */ +export function resetWarnOnceForTesting(): void { + warnedMessages.clear(); +}