From 49f4043308cba4d1e7c1530df6954cefbee1dd01 Mon Sep 17 00:00:00 2001 From: Jon Meyers Date: Thu, 7 Sep 2023 08:04:06 +1000 Subject: [PATCH] Feature: Add `ssr` package to configure cookies for browser or server clients (#630) * move client and server primitives to new ssr package * remove unused isSecureEnvironment function * Update with default cookie options * Fix types for cookie options * remove type narrowing for cookie options * Move cookie method types to the types file * Update cookie storage for browser client * Update cookie properties for browser client * Move cookie and ramda to dependencies --------- Co-authored-by: Andrew Smith --- .changeset/config.json | 8 +- .changeset/swift-drinks-hunt.md | 5 + package.json | 1 + packages/ssr/README.md | 3 + packages/ssr/package.json | 49 +++++++++ packages/ssr/src/ambient.d.ts | 3 + packages/ssr/src/createBrowserClient.ts | 139 ++++++++++++++++++++++++ packages/ssr/src/createServerClient.ts | 70 ++++++++++++ packages/ssr/src/index.ts | 4 + packages/ssr/src/types.ts | 14 +++ packages/ssr/src/utils/constants.ts | 7 ++ packages/ssr/src/utils/helpers.ts | 5 + packages/ssr/src/utils/index.ts | 2 + packages/ssr/tsconfig.json | 5 + packages/ssr/tsup.config.ts | 20 ++++ pnpm-lock.yaml | 48 +++++++- 16 files changed, 378 insertions(+), 5 deletions(-) create mode 100644 .changeset/swift-drinks-hunt.md create mode 100644 packages/ssr/README.md create mode 100644 packages/ssr/package.json create mode 100644 packages/ssr/src/ambient.d.ts create mode 100644 packages/ssr/src/createBrowserClient.ts create mode 100644 packages/ssr/src/createServerClient.ts create mode 100644 packages/ssr/src/index.ts create mode 100644 packages/ssr/src/types.ts create mode 100644 packages/ssr/src/utils/constants.ts create mode 100644 packages/ssr/src/utils/helpers.ts create mode 100644 packages/ssr/src/utils/index.ts create mode 100644 packages/ssr/tsconfig.json create mode 100644 packages/ssr/tsup.config.ts diff --git a/.changeset/config.json b/.changeset/config.json index 8947aa48..cad3fb15 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -5,7 +5,9 @@ "fixed": [], "linked": [], "access": "restricted", - "baseBranch": "next", + "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["@example/*"] -} + "ignore": [ + "@example/*" + ] +} \ No newline at end of file diff --git a/.changeset/swift-drinks-hunt.md b/.changeset/swift-drinks-hunt.md new file mode 100644 index 00000000..ec058efe --- /dev/null +++ b/.changeset/swift-drinks-hunt.md @@ -0,0 +1,5 @@ +--- +'@supabase/ssr': patch +--- + +The successor to the auth-helpers packages with sane defaults diff --git a/package.json b/package.json index f1f59914..4ba1ddac 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build:react": "turbo run build --filter=@supabase/auth-helpers-react", "build:remix": "turbo run build --filter=@supabase/auth-helpers-remix", "build:shared": "turbo run build --filter=@supabase/auth-helpers-shared", + "build:ssr": "turbo run build --filter=@supabase/ssr", "dev": "turbo run dev --parallel", "lint": "turbo run lint --filter=!@example/*", "check": "prettier --check .", diff --git a/packages/ssr/README.md b/packages/ssr/README.md new file mode 100644 index 00000000..9b7c7a0e --- /dev/null +++ b/packages/ssr/README.md @@ -0,0 +1,3 @@ +# @supabase/ssr (BETA) + +This submodule provides convenience helpers for implementing user authentication in your favorite framework. diff --git a/packages/ssr/package.json b/packages/ssr/package.json new file mode 100644 index 00000000..ef2457aa --- /dev/null +++ b/packages/ssr/package.json @@ -0,0 +1,49 @@ +{ + "name": "@supabase/ssr", + "version": "0.0.0", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "scripts": { + "lint": "tsc", + "build": "tsup" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/supabase/auth-helpers.git" + }, + "keywords": [ + "Supabase", + "Auth", + "Next.js", + "Svelte Kit", + "Remix", + "Express" + ], + "author": "Supabase", + "license": "MIT", + "bugs": { + "url": "https://github.com/supabase/auth-helpers/issues" + }, + "homepage": "https://github.com/supabase/auth-helpers#readme", + "dependencies": { + "cookie": "^0.5.0", + "ramda": "^0.29.0" + }, + "devDependencies": { + "@supabase/supabase-js": "2.33.1", + "@types/cookie": "^0.5.1", + "@types/ramda": "^0.29.3", + "tsconfig": "workspace:*", + "tsup": "^6.7.0" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.33.1" + } +} diff --git a/packages/ssr/src/ambient.d.ts b/packages/ssr/src/ambient.d.ts new file mode 100644 index 00000000..1c305add --- /dev/null +++ b/packages/ssr/src/ambient.d.ts @@ -0,0 +1,3 @@ +// these variables are defined by tsup +declare const PACKAGE_NAME: string; +declare const PACKAGE_VERSION: string; diff --git a/packages/ssr/src/createBrowserClient.ts b/packages/ssr/src/createBrowserClient.ts new file mode 100644 index 00000000..47b85cf3 --- /dev/null +++ b/packages/ssr/src/createBrowserClient.ts @@ -0,0 +1,139 @@ +import { createClient } from '@supabase/supabase-js'; +import { mergeDeepRight } from 'ramda'; +import { DEFAULT_COOKIE_OPTIONS, isBrowser } from './utils'; +import { parse, serialize } from 'cookie'; + +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { + GenericSchema, + SupabaseClientOptions +} from '@supabase/supabase-js/dist/module/lib/types'; +import type { BrowserCookieMethods, CookieOptionsWithName } from './types'; + +let cachedBrowserClient: SupabaseClient | undefined; + +export function createBrowserClient< + Database = any, + SchemaName extends string & keyof Database = 'public' extends keyof Database + ? 'public' + : string & keyof Database, + Schema extends GenericSchema = Database[SchemaName] extends GenericSchema + ? Database[SchemaName] + : any +>( + supabaseUrl: string, + supabaseKey: string, + options?: SupabaseClientOptions & { + cookies: BrowserCookieMethods; + cookieOptions?: CookieOptionsWithName; + isSingleton?: boolean; + } +) { + if (!supabaseUrl || !supabaseKey) { + throw new Error( + `Your project's URL and Key are required to create a Supabase client!\n\nCheck your Supabase project's API settings to find these values\n\nhttps://supabase.com/dashboard/project/_/settings/api` + ); + } + + let cookies: BrowserCookieMethods = {}; + let isSingleton = true; + let cookieOptions: CookieOptionsWithName | undefined; + let userDefinedClientOptions; + + if (options) { + ({ cookies, isSingleton = true, cookieOptions, ...userDefinedClientOptions } = options); + } + + const cookieClientOptions = { + global: { + headers: { + 'X-Client-Info': `${PACKAGE_NAME}@${PACKAGE_VERSION}` + } + }, + auth: { + flowType: 'pkce', + autoRefreshToken: isBrowser(), + detectSessionInUrl: isBrowser(), + persistSession: true, + storage: { + getItem: async (key: string) => { + if (typeof cookies.get === 'function') { + return (await cookies.get(key)) ?? null; + } + + if (isBrowser()) { + const cookie = parse(document.cookie); + return cookie[key]; + } + }, + setItem: async (key: string, value: string) => { + if (typeof cookies.set === 'function') { + return await cookies.set(key, value, { + ...DEFAULT_COOKIE_OPTIONS, + ...cookieOptions + }); + } + + if (isBrowser()) { + document.cookie = serialize(key, value, { + ...DEFAULT_COOKIE_OPTIONS, + ...cookieOptions + }); + } + }, + removeItem: async (key: string) => { + if (typeof cookies.remove === 'function') { + return await cookies.remove(key, { + ...DEFAULT_COOKIE_OPTIONS, + maxAge: 0, + ...cookieOptions + }); + } + + if (isBrowser()) { + document.cookie = serialize(key, '', { + ...DEFAULT_COOKIE_OPTIONS, + maxAge: 0, + ...cookieOptions + }); + } + } + } + } + }; + + // Overwrites default client config with any user defined options + const clientOptions = mergeDeepRight( + cookieClientOptions, + userDefinedClientOptions + ) as SupabaseClientOptions; + + if (isSingleton) { + // The `Singleton` pattern is the default to simplify the instantiation + // of a Supabase client in the browser - there must only be one + + const browser = isBrowser(); + + if (browser && cachedBrowserClient) { + return cachedBrowserClient as SupabaseClient; + } + + const client = createClient( + supabaseUrl, + supabaseKey, + clientOptions + ); + + if (browser) { + // The client should only be cached in the browser + cachedBrowserClient = client; + } + + return client; + } + + // This allows for multiple Supabase clients, which may be required when using + // multiple schemas. The user will be responsible for ensuring a single + // instance of Supabase is used for each schema in the browser. + return createClient(supabaseUrl, supabaseKey, clientOptions); +} diff --git a/packages/ssr/src/createServerClient.ts b/packages/ssr/src/createServerClient.ts new file mode 100644 index 00000000..76827007 --- /dev/null +++ b/packages/ssr/src/createServerClient.ts @@ -0,0 +1,70 @@ +import { createClient } from '@supabase/supabase-js'; +import { mergeDeepRight } from 'ramda'; +import { DEFAULT_COOKIE_OPTIONS, isBrowser } from './utils'; + +import type { + GenericSchema, + SupabaseClientOptions +} from '@supabase/supabase-js/dist/module/lib/types'; +import type { CookieOptionsWithName, ServerCookieMethods } from './types'; + +export function createServerClient< + Database = any, + SchemaName extends string & keyof Database = 'public' extends keyof Database + ? 'public' + : string & keyof Database, + Schema extends GenericSchema = Database[SchemaName] extends GenericSchema + ? Database[SchemaName] + : any +>( + supabaseUrl: string, + supabaseKey: string, + options: SupabaseClientOptions & { + cookies: ServerCookieMethods; + cookieOptions?: CookieOptionsWithName; + } +) { + if (!supabaseUrl || !supabaseKey) { + throw new Error( + `Your project's URL and Key are required to create a Supabase client!\n\nCheck your Supabase project's API settings to find these values\n\nhttps://supabase.com/dashboard/project/_/settings/api` + ); + } + + const { cookies, cookieOptions, ...userDefinedClientOptions } = options; + + if (!cookies.get || !cookies.set || !cookies.remove) { + // todo: point to helpful docs in error message, once they have been written! 😏 + throw new Error( + 'The Supabase client requires functions to get, set, and remove cookies in your specific framework!' + ); + } + + const cookieClientOptions = { + global: { + headers: { + 'X-Client-Info': `${PACKAGE_NAME}@${PACKAGE_VERSION}` + } + }, + auth: { + flowType: 'pkce', + autoRefreshToken: isBrowser(), + detectSessionInUrl: isBrowser(), + persistSession: true, + storage: { + getItem: async (key: string) => (await cookies.get(key)) ?? null, + setItem: async (key: string, value: string) => + await cookies.set(key, value, { ...DEFAULT_COOKIE_OPTIONS, ...cookieOptions }), + removeItem: async (key: string) => + await cookies.remove(key, { ...DEFAULT_COOKIE_OPTIONS, maxAge: 0, ...cookieOptions }) + } + } + }; + + // Overwrites default client config with any user defined options + const clientOptions = mergeDeepRight( + cookieClientOptions, + userDefinedClientOptions + ) as SupabaseClientOptions; + + return createClient(supabaseUrl, supabaseKey, clientOptions); +} diff --git a/packages/ssr/src/index.ts b/packages/ssr/src/index.ts new file mode 100644 index 00000000..3c20c3b1 --- /dev/null +++ b/packages/ssr/src/index.ts @@ -0,0 +1,4 @@ +export * from './createBrowserClient'; +export * from './createServerClient'; +export * from './types'; +export * from './utils'; diff --git a/packages/ssr/src/types.ts b/packages/ssr/src/types.ts new file mode 100644 index 00000000..82f793bb --- /dev/null +++ b/packages/ssr/src/types.ts @@ -0,0 +1,14 @@ +import type { CookieSerializeOptions } from 'cookie'; + +export type CookieOptions = Partial; +export type CookieOptionsWithName = { name?: string } & CookieOptions; +export type ServerCookieMethods = { + get: (key: string) => Promise | string | null | undefined; + set: (key: string, value: string, options?: CookieOptions) => Promise | void; + remove: (key: string, options?: CookieOptions) => Promise | void; +}; +export type BrowserCookieMethods = { + get?: (key: string) => Promise | string | null | undefined; + set?: (key: string, value: string, options?: CookieOptions) => Promise | void; + remove?: (key: string, options?: CookieOptions) => Promise | void; +}; diff --git a/packages/ssr/src/utils/constants.ts b/packages/ssr/src/utils/constants.ts new file mode 100644 index 00000000..d9d26755 --- /dev/null +++ b/packages/ssr/src/utils/constants.ts @@ -0,0 +1,7 @@ +import { CookieOptions } from '../types'; + +export const DEFAULT_COOKIE_OPTIONS: CookieOptions = { + path: '/', + maxAge: 60 * 60 * 24 * 365 * 1000, + httpOnly: false +}; diff --git a/packages/ssr/src/utils/helpers.ts b/packages/ssr/src/utils/helpers.ts new file mode 100644 index 00000000..b76fce14 --- /dev/null +++ b/packages/ssr/src/utils/helpers.ts @@ -0,0 +1,5 @@ +export { parse, serialize } from 'cookie'; + +export function isBrowser() { + return typeof window !== 'undefined' && typeof window.document !== 'undefined'; +} diff --git a/packages/ssr/src/utils/index.ts b/packages/ssr/src/utils/index.ts new file mode 100644 index 00000000..3e30adb0 --- /dev/null +++ b/packages/ssr/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './helpers'; +export * from './constants'; diff --git a/packages/ssr/tsconfig.json b/packages/ssr/tsconfig.json new file mode 100644 index 00000000..49c855a5 --- /dev/null +++ b/packages/ssr/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "tsconfig/base.json", + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/ssr/tsup.config.ts b/packages/ssr/tsup.config.ts new file mode 100644 index 00000000..18731db5 --- /dev/null +++ b/packages/ssr/tsup.config.ts @@ -0,0 +1,20 @@ +import type { Options } from 'tsup'; +import pkg from './package.json'; + +export const tsup: Options = { + dts: true, + entryPoints: ['src/index.ts'], + external: ['react', 'next', /^@supabase\//], + format: ['cjs', 'esm'], + // inject: ['src/react-shim.js'], + // ! .cjs/.mjs doesn't work with Angular's webpack4 config by default! + legacyOutput: false, + sourcemap: true, + splitting: false, + bundle: true, + clean: true, + define: { + PACKAGE_NAME: JSON.stringify(pkg.name), + PACKAGE_VERSION: JSON.stringify(pkg.version) + } +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b11f1c55..810471d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -406,6 +406,31 @@ importers: specifier: ^6.7.0 version: 6.7.0(typescript@4.9.5) + packages/ssr: + dependencies: + cookie: + specifier: ^0.5.0 + version: 0.5.0 + ramda: + specifier: ^0.29.0 + version: 0.29.0 + devDependencies: + '@supabase/supabase-js': + specifier: 2.33.1 + version: 2.33.1 + '@types/cookie': + specifier: ^0.5.1 + version: 0.5.1 + '@types/ramda': + specifier: ^0.29.3 + version: 0.29.3 + tsconfig: + specifier: workspace:* + version: link:../tsconfig + tsup: + specifier: ^6.7.0 + version: 6.7.0(typescript@4.9.5) + packages/sveltekit: dependencies: '@supabase/auth-helpers-shared': @@ -3292,7 +3317,6 @@ packages: transitivePeerDependencies: - encoding - supports-color - dev: false /@sveltejs/adapter-auto@2.1.0(@sveltejs/kit@1.23.0): resolution: {integrity: sha512-o2pZCfATFtA/Gw/BB0Xm7k4EYaekXxaPGER3xGSY3FvzFJGTlJlZjBseaXwYSM94lZ0HniOjTokN3cWaLX6fow==} @@ -3557,6 +3581,12 @@ packages: resolution: {integrity: sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==} dev: true + /@types/ramda@0.29.3: + resolution: {integrity: sha512-Yh/RHkjN0ru6LVhSQtTkCRo6HXkfL9trot/2elzM/yXLJmbLm2v6kJc8yftTnwv1zvUob6TEtqI2cYjdqG3U0Q==} + dependencies: + types-ramda: 0.29.4 + dev: true + /@types/react-dom@17.0.20: resolution: {integrity: sha512-4pzIjSxDueZZ90F52mU3aPoogkHIoSIDG+oQ+wQK7Cy2B9S+MvOqY0uEA/qawKz381qrEDkvpwyt8Bm31I8sbA==} dependencies: @@ -6500,7 +6530,7 @@ packages: '@types/glob': 7.2.0 array-union: 2.1.0 dir-glob: 3.0.1 - fast-glob: 3.2.11 + fast-glob: 3.3.1 glob: 7.2.3 ignore: 5.2.4 merge2: 1.4.1 @@ -8898,6 +8928,10 @@ packages: engines: {node: '>=10'} dev: true + /ramda@0.29.0: + resolution: {integrity: sha512-BBea6L67bYLtdbOqfp8f58fPMqEwx0doL+pAi8TZyp2YWz8R9G8z9x75CZI8W+ftqhFHCpEX2cRnUUXK130iKA==} + dev: false + /range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -10011,6 +10045,10 @@ packages: /ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + /ts-toolbelt@9.6.0: + resolution: {integrity: sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==} + dev: true + /tsconfig-paths@3.14.2: resolution: {integrity: sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==} dependencies: @@ -10268,6 +10306,12 @@ packages: typescript: 4.9.5 dev: true + /types-ramda@0.29.4: + resolution: {integrity: sha512-XO/820iRsCDwqLjE8XE+b57cVGPyk1h+U9lBGpDWvbEky+NQChvHVwaKM05WnW1c5z3EVQh8NhXFmh2E/1YazQ==} + dependencies: + ts-toolbelt: 9.6.0 + dev: true + /typescript@4.9.5: resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} engines: {node: '>=4.2.0'}