From 629168d830f5b9c258737260b06d3a09a6d4023b Mon Sep 17 00:00:00 2001 From: Tomas Pozo Date: Wed, 25 Mar 2026 14:02:14 -0500 Subject: [PATCH 1/2] feat: add `supabaseOptions` to `WithSupabaseConfig` for client customization Allow users to pass `SupabaseClientOptions` through to the internal `createClient` calls, enabling custom schemas, fetch, and realtime config while security-critical auth settings remain force-overwritten. --- src/core/create-admin-client.test.ts | 56 ++++++++++------ src/core/create-admin-client.ts | 27 +++++--- src/core/create-context-client.test.ts | 93 ++++++++++++++++++++------ src/core/create-context-client.ts | 38 ++++++----- src/core/index.ts | 6 ++ src/create-supabase-context.test.ts | 14 ++++ src/create-supabase-context.ts | 23 ++++--- src/index.ts | 3 + src/types.ts | 58 +++++++++++++++- 9 files changed, 242 insertions(+), 76 deletions(-) diff --git a/src/core/create-admin-client.test.ts b/src/core/create-admin-client.test.ts index 356f9dd..495cb0c 100644 --- a/src/core/create-admin-client.test.ts +++ b/src/core/create-admin-client.test.ts @@ -16,17 +16,19 @@ const validEnv = { describe('createAdminClient', () => { it('creates client with valid env', () => { - const client = createAdminClient(validEnv) + const client = createAdminClient({ env: validEnv }) expect(client).toBeDefined() }) it('throws EnvError when SUPABASE_URL is missing', () => { expect(() => createAdminClient({ - url: '', - publishableKeys: { default: 'sb_publishable_xyz' }, - secretKeys: { default: 'sb_secret_xyz' }, - jwks: null, + env: { + url: '', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, + }, }), ).toThrow(EnvError) }) @@ -34,19 +36,23 @@ describe('createAdminClient', () => { it('throws EnvError when secret keys are empty', () => { expect(() => createAdminClient({ - url: 'https://test.supabase.co', - publishableKeys: { default: 'sb_publishable_xyz' }, - secretKeys: {}, - jwks: null, + env: { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: {}, + jwks: null, + }, }), ).toThrow(EnvError) try { createAdminClient({ - url: 'https://test.supabase.co', - publishableKeys: { default: 'sb_publishable_xyz' }, - secretKeys: {}, - jwks: null, + env: { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: {}, + jwks: null, + }, }) } catch (e) { expect(e).toBeInstanceOf(EnvError) @@ -65,15 +71,17 @@ describe('createAdminClient', () => { }, jwks: null, } - const client = createAdminClient(env, 'web') + const client = createAdminClient({ auth: { keyName: 'web' }, env }) expect(client).toBeDefined() }) it('throws when named key does not exist', () => { - expect(() => createAdminClient(validEnv, 'nonexistent')).toThrow(EnvError) + expect(() => + createAdminClient({ auth: { keyName: 'nonexistent' }, env: validEnv }), + ).toThrow(EnvError) try { - createAdminClient(validEnv, 'nonexistent') + createAdminClient({ auth: { keyName: 'nonexistent' }, env: validEnv }) } catch (e) { expect(e).toBeInstanceOf(EnvError) expect((e as EnvError).code).toBe(MissingSecretKeyError) @@ -90,7 +98,7 @@ describe('createAdminClient', () => { }, jwks: null, } - const client = createAdminClient(env, null) + const client = createAdminClient({ auth: { keyName: null }, env }) expect(client).toBeDefined() }) @@ -104,7 +112,7 @@ describe('createAdminClient', () => { }, jwks: null, } - const client = createAdminClient(env, null) + const client = createAdminClient({ auth: { keyName: null }, env }) expect(client).toBeDefined() }) @@ -115,6 +123,16 @@ describe('createAdminClient', () => { secretKeys: {}, jwks: null, } - expect(() => createAdminClient(env, null)).toThrow(EnvError) + expect(() => createAdminClient({ auth: { keyName: null }, env })).toThrow( + EnvError, + ) + }) + + it('creates admin client with custom supabaseOptions', () => { + const client = createAdminClient({ + env: validEnv, + supabaseOptions: { db: { schema: 'api' } }, + }) + expect(client).toBeDefined() }) }) diff --git a/src/core/create-admin-client.ts b/src/core/create-admin-client.ts index 833bf16..5caf15d 100644 --- a/src/core/create-admin-client.ts +++ b/src/core/create-admin-client.ts @@ -5,18 +5,15 @@ import { MissingDefaultSecretKeyError, MissingSecretKeyError, } from '../errors.js' -import type { SupabaseEnv } from '../types.js' +import type { CreateAdminClientOptions } from '../types.js' import { resolveEnv } from './resolve-env.js' /** * Creates an admin Supabase client that bypasses Row-Level Security. * * Uses a secret key for authentication, giving full access to all data. - * Session persistence is disabled (stateless, one client per request). + * Stateless — one client per request. * - * @param env - Optional environment overrides (passed through to {@link resolveEnv}). - * @param keyName - Name of the secret key to use. Falls back to `"default"`, then first available. - * @returns A configured {@link SupabaseClient} with admin (service-role) privileges. * @throws {@link EnvError} If `SUPABASE_URL` is missing or the specified secret key is not found. * * @example @@ -26,12 +23,14 @@ import { resolveEnv } from './resolve-env.js' * ``` */ export function createAdminClient( - env?: Partial, - keyName?: string | null, + options?: CreateAdminClientOptions, ): SupabaseClient { - const { data: resolved, error } = resolveEnv(env) + const { data: resolved, error } = resolveEnv(options?.env) if (error) throw error + const keyName = options?.auth?.keyName + const supabaseOptions = options?.supabaseOptions + const name = keyName ?? 'default' const keys = resolved.secretKeys const secretKey = @@ -42,11 +41,19 @@ export function createAdminClient( : Errors[MissingSecretKeyError](name) } - return createClient(resolved.url, secretKey, { + // supabaseOptions uses `string` for schema; createClient expects a narrower type. + return createClient(resolved.url, secretKey, { + ...supabaseOptions, + // Stripped — token injection is managed via the service-role key. + accessToken: undefined, + global: { + ...supabaseOptions?.global, + }, auth: { + ...supabaseOptions?.auth, persistSession: false, autoRefreshToken: false, detectSessionInUrl: false, }, - }) + } as Parameters>[2]) } diff --git a/src/core/create-context-client.test.ts b/src/core/create-context-client.test.ts index 51fc8c2..5e4f648 100644 --- a/src/core/create-context-client.test.ts +++ b/src/core/create-context-client.test.ts @@ -16,37 +16,49 @@ const validEnv = { describe('createContextClient', () => { it('creates client with valid env', () => { - const client = createContextClient('test-token', validEnv) + const client = createContextClient({ + auth: { token: 'test-token' }, + env: validEnv, + }) expect(client).toBeDefined() }) it('throws EnvError when SUPABASE_URL is missing', () => { expect(() => - createContextClient('test-token', { - url: '', - publishableKeys: { default: 'sb_publishable_xyz' }, - secretKeys: { default: 'sb_secret_xyz' }, - jwks: null, + createContextClient({ + auth: { token: 'test-token' }, + env: { + url: '', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, + }, }), ).toThrow(EnvError) }) it('throws EnvError when publishable keys are empty', () => { expect(() => - createContextClient('test-token', { - url: 'https://test.supabase.co', - publishableKeys: {}, - secretKeys: { default: 'sb_secret_xyz' }, - jwks: null, + createContextClient({ + auth: { token: 'test-token' }, + env: { + url: 'https://test.supabase.co', + publishableKeys: {}, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, + }, }), ).toThrow(EnvError) try { - createContextClient('test-token', { - url: 'https://test.supabase.co', - publishableKeys: {}, - secretKeys: { default: 'sb_secret_xyz' }, - jwks: null, + createContextClient({ + auth: { token: 'test-token' }, + env: { + url: 'https://test.supabase.co', + publishableKeys: {}, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, + }, }) } catch (e) { expect(e).toBeInstanceOf(EnvError) @@ -65,17 +77,26 @@ describe('createContextClient', () => { secretKeys: { default: 'sb_secret_xyz' }, jwks: null, } - const client = createContextClient('test-token', env, 'web') + const client = createContextClient({ + auth: { token: 'test-token', keyName: 'web' }, + env, + }) expect(client).toBeDefined() }) it('throws when named key does not exist', () => { expect(() => - createContextClient('test-token', validEnv, 'nonexistent'), + createContextClient({ + auth: { token: 'test-token', keyName: 'nonexistent' }, + env: validEnv, + }), ).toThrow(EnvError) try { - createContextClient('test-token', validEnv, 'nonexistent') + createContextClient({ + auth: { token: 'test-token', keyName: 'nonexistent' }, + env: validEnv, + }) } catch (e) { expect(e).toBeInstanceOf(EnvError) expect((e as EnvError).code).toBe(MissingPublishableKeyError) @@ -92,7 +113,10 @@ describe('createContextClient', () => { secretKeys: { default: 'sb_secret_xyz' }, jwks: null, } - const client = createContextClient('test-token', env, null) + const client = createContextClient({ + auth: { token: 'test-token', keyName: null }, + env, + }) expect(client).toBeDefined() }) @@ -106,7 +130,10 @@ describe('createContextClient', () => { secretKeys: { default: 'sb_secret_xyz' }, jwks: null, } - const client = createContextClient('test-token', env, null) + const client = createContextClient({ + auth: { token: 'test-token', keyName: null }, + env, + }) expect(client).toBeDefined() }) @@ -117,6 +144,28 @@ describe('createContextClient', () => { secretKeys: { default: 'sb_secret_xyz' }, jwks: null, } - expect(() => createContextClient('test-token', env, null)).toThrow(EnvError) + expect(() => + createContextClient({ + auth: { token: 'test-token', keyName: null }, + env, + }), + ).toThrow(EnvError) + }) + + it('creates client with custom supabaseOptions', () => { + const client = createContextClient({ + auth: { token: 'test-token' }, + env: validEnv, + supabaseOptions: { db: { schema: 'api' } }, + }) + expect(client).toBeDefined() + }) + + it('creates client with supabaseOptions without token', () => { + const client = createContextClient({ + env: validEnv, + supabaseOptions: { db: { schema: 'api' } }, + }) + expect(client).toBeDefined() }) }) diff --git a/src/core/create-context-client.ts b/src/core/create-context-client.ts index e09e1d5..9d4d150 100644 --- a/src/core/create-context-client.ts +++ b/src/core/create-context-client.ts @@ -5,37 +5,36 @@ import { MissingDefaultPublishableKeyError, MissingPublishableKeyError, } from '../errors.js' -import type { SupabaseEnv } from '../types.js' +import type { CreateContextClientOptions } from '../types.js' import { resolveEnv } from './resolve-env.js' /** * Creates a Supabase client scoped to the caller's context. * * Configured with a publishable key and (optionally) the caller's JWT, - * so Row-Level Security policies apply. Session persistence is disabled - * (stateless, one client per request). + * so Row-Level Security policies apply. Stateless — one client per request. * - * @param token - The caller's JWT, or `null` for anonymous access. - * @param env - Optional environment overrides (passed through to {@link resolveEnv}). - * @param keyName - Name of the publishable key to use. Falls back to `"default"`, then first available. - * @returns A configured {@link SupabaseClient} with RLS enforced. * @throws {@link EnvError} If `SUPABASE_URL` is missing or the specified publishable key is not found. * * @example * ```ts * const { data: auth } = await verifyAuth(request, { allow: 'user' }) - * const supabase = createContextClient(auth.token) + * const supabase = createContextClient({ + * auth: { token: auth.token, keyName: auth.keyName }, + * }) * const { data } = await supabase.rpc('get_my_items') * ``` */ export function createContextClient( - token?: string | null, - env?: Partial, - keyName?: string | null, + options?: CreateContextClientOptions, ): SupabaseClient { - const { data: resolved, error } = resolveEnv(env) + const { data: resolved, error } = resolveEnv(options?.env) if (error) throw error + const token = options?.auth?.token + const keyName = options?.auth?.keyName + const supabaseOptions = options?.supabaseOptions + const name = keyName ?? 'default' const keys = resolved.publishableKeys const anonKey = @@ -46,14 +45,23 @@ export function createContextClient( : Errors[MissingPublishableKeyError](name) } - return createClient(resolved.url, anonKey, { + // supabaseOptions uses `string` for schema; createClient expects a narrower type. + return createClient(resolved.url, anonKey, { + ...supabaseOptions, + // Stripped — token injection is managed via the Authorization header from verified credentials. + accessToken: undefined, global: { - headers: token ? { Authorization: `Bearer ${token}` } : {}, + ...supabaseOptions?.global, + headers: { + ...supabaseOptions?.global?.headers, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, }, auth: { + ...supabaseOptions?.auth, persistSession: false, autoRefreshToken: false, detectSessionInUrl: false, }, - }) + } as Parameters>[2]) } diff --git a/src/core/index.ts b/src/core/index.ts index 5a0a6c7..14ab1ba 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -9,3 +9,9 @@ export { verifyCredentials } from './verify-credentials.js' export { verifyAuth } from './verify-auth.js' export { createContextClient } from './create-context-client.js' export { createAdminClient } from './create-admin-client.js' + +export type { + ClientAuth, + CreateAdminClientOptions, + CreateContextClientOptions, +} from '../types.js' diff --git a/src/create-supabase-context.test.ts b/src/create-supabase-context.test.ts index 9411f13..ff19d48 100644 --- a/src/create-supabase-context.test.ts +++ b/src/create-supabase-context.test.ts @@ -122,4 +122,18 @@ describe('createSupabaseContext', () => { MissingDefaultSecretKeyError, ]) }) + + it('passes supabaseOptions through to clients', async () => { + const req = new Request('http://localhost') + const result = await createSupabaseContext(req, { + allow: 'always', + env: baseEnv, + supabaseOptions: { db: { schema: 'api' } }, + }) + + expect(result.error).toBeNull() + expect(result.data).not.toBeNull() + expect(result.data!.supabase).toBeDefined() + expect(result.data!.supabaseAdmin).toBeDefined() + }) }) diff --git a/src/create-supabase-context.ts b/src/create-supabase-context.ts index fc41532..fa4dac6 100644 --- a/src/create-supabase-context.ts +++ b/src/create-supabase-context.ts @@ -47,16 +47,21 @@ export async function createSupabaseContext( } try { - const supabase = createContextClient( - auth.token, - options?.env, - auth.keyName, - ) + const config = { + env: options?.env, + supabaseOptions: options?.supabaseOptions, + } + + const supabase = createContextClient({ + auth: { token: auth.token, keyName: auth.keyName }, + ...config, + }) + const adminKeyName = auth.authType === 'secret' ? auth.keyName : undefined - const supabaseAdmin = createAdminClient( - options?.env, - adminKeyName, - ) + const supabaseAdmin = createAdminClient({ + auth: { keyName: adminKeyName }, + ...config, + }) return { data: { diff --git a/src/index.ts b/src/index.ts index 80e1869..3c5c352 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,9 @@ export type { Allow, AllowWithKey, AuthResult, + ClientAuth, + CreateAdminClientOptions, + CreateContextClientOptions, Credentials, JWTClaims, SupabaseContext, diff --git a/src/types.ts b/src/types.ts index 5f2e2db..8f8302f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,7 @@ -import type { SupabaseClient } from '@supabase/supabase-js' +import type { + SupabaseClient, + SupabaseClientOptions, +} from '@supabase/supabase-js' /** * Authentication mode that determines what credentials a request must provide. @@ -234,6 +237,59 @@ export interface WithSupabaseConfig { * @defaultValue `true` */ cors?: boolean | Record + + /** + * Options forwarded to both internal `createClient()` calls. + * + * `accessToken` is stripped, and auth settings (`persistSession`, `autoRefreshToken`, + * `detectSessionInUrl`) are force-overwritten to server-safe values. + * + * @example + * ```ts + * withSupabase({ + * allow: 'user', + * supabaseOptions: { db: { schema: 'api' } }, + * }, handler) + * ``` + */ + supabaseOptions?: SupabaseClientOptions +} + +/** + * Auth identity for client creation functions. + * + * @see {@link verifyAuth}, {@link verifyCredentials} + */ +export interface ClientAuth { + /** The caller's JWT, or `null` for anonymous access. */ + token?: string | null + + /** Name of the API key to use. Falls back to `"default"`, then first available. */ + keyName?: string | null +} + +/** Options for {@link createContextClient}. */ +export interface CreateContextClientOptions { + /** Auth identity — token and key name from the verified request. */ + auth?: ClientAuth + + /** Override auto-detected environment variables. */ + env?: Partial + + /** Options forwarded to `createClient()`. `accessToken` is stripped; auth settings are force-overwritten. */ + supabaseOptions?: SupabaseClientOptions +} + +/** Options for {@link createAdminClient}. */ +export interface CreateAdminClientOptions { + /** Auth identity — key name from the verified request. */ + auth?: Pick + + /** Override auto-detected environment variables. */ + env?: Partial + + /** Options forwarded to `createClient()`. `accessToken` is stripped; auth settings are force-overwritten. */ + supabaseOptions?: SupabaseClientOptions } /** From 3cf17f498291f352e47b3d110ab75ce86b88351f Mon Sep 17 00:00:00 2001 From: Tomas Pozo Date: Wed, 25 Mar 2026 16:11:50 -0500 Subject: [PATCH 2/2] fix: sanitize Authorization and apikey headers from supabaseOptions User-provided supabaseOptions.global.headers could include Authorization or apikey, bypassing verified credentials. Strip both before spreading user headers into the client options. --- src/core/create-admin-client.ts | 6 ++++++ src/core/create-context-client.ts | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/core/create-admin-client.ts b/src/core/create-admin-client.ts index 5caf15d..03972ad 100644 --- a/src/core/create-admin-client.ts +++ b/src/core/create-admin-client.ts @@ -41,6 +41,11 @@ export function createAdminClient( : Errors[MissingSecretKeyError](name) } + // Sanitize auth headers — only the service-role key controls Authorization and apikey. + const safeHeaders = { ...supabaseOptions?.global?.headers } + delete safeHeaders.Authorization + delete safeHeaders.apikey + // supabaseOptions uses `string` for schema; createClient expects a narrower type. return createClient(resolved.url, secretKey, { ...supabaseOptions, @@ -48,6 +53,7 @@ export function createAdminClient( accessToken: undefined, global: { ...supabaseOptions?.global, + headers: safeHeaders, }, auth: { ...supabaseOptions?.auth, diff --git a/src/core/create-context-client.ts b/src/core/create-context-client.ts index 9d4d150..a89f704 100644 --- a/src/core/create-context-client.ts +++ b/src/core/create-context-client.ts @@ -45,6 +45,11 @@ export function createContextClient( : Errors[MissingPublishableKeyError](name) } + // Sanitize auth headers — only verified credentials control Authorization and apikey. + const safeHeaders = { ...supabaseOptions?.global?.headers } + delete safeHeaders.Authorization + delete safeHeaders.apikey + // supabaseOptions uses `string` for schema; createClient expects a narrower type. return createClient(resolved.url, anonKey, { ...supabaseOptions, @@ -53,7 +58,7 @@ export function createContextClient( global: { ...supabaseOptions?.global, headers: { - ...supabaseOptions?.global?.headers, + ...safeHeaders, ...(token ? { Authorization: `Bearer ${token}` } : {}), }, },