diff --git a/README.md b/README.md index 0a962d6..93ac493 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,12 @@ Server-side utilities for Supabase. Handles auth, client creation, and context i ```ts import { withSupabase } from '@supabase/server' -Deno.serve( - withSupabase({ allow: 'user' }, async (req, ctx) => { +export default { + fetch: withSupabase({ allow: 'user' }, async (_req, ctx) => { const { data } = await ctx.supabase.from('todos').select() return Response.json(data) }), -) +} ``` One import. One line of config. Auth is validated, clients are scoped, CORS is handled. Your handler only runs on successful auth. @@ -34,10 +34,8 @@ pnpm add @supabase/server ### Authenticated endpoint ```ts -import { withSupabase } from '@supabase/server' - -Deno.serve( - withSupabase({ allow: 'user' }, async (req, ctx) => { +export default { + fetch: withSupabase({ allow: 'user' }, async (_req, ctx) => { // ctx.supabase — RLS-scoped to the authenticated user // ctx.supabaseAdmin — bypasses RLS (service role) // ctx.userClaims — user identity from JWT (id, email, role) @@ -47,35 +45,35 @@ Deno.serve( const { data } = await ctx.supabase.from('todos').select() return Response.json(data) }), -) +} ``` ### Public endpoint (no auth) ```ts -Deno.serve( - withSupabase({ allow: 'always' }, async (req, ctx) => { +export default { + fetch: withSupabase({ allow: 'always' }, async (_req, _ctx) => { return Response.json({ status: 'ok' }) }), -) +} ``` ### API key protected ```ts -Deno.serve( - withSupabase({ allow: 'secret' }, async (req, ctx) => { +export default { + fetch: withSupabase({ allow: 'secret' }, async (_req, ctx) => { const { data } = await ctx.supabaseAdmin.from('config').select() return Response.json(data) }), -) +} ``` ### Dual auth (user or service) ```ts -Deno.serve( - withSupabase({ allow: ['user', 'secret'] }, async (req, ctx) => { +export default { + fetch: withSupabase({ allow: ['user', 'secret'] }, async (req, ctx) => { const userId = ctx.userClaims?.id ?? (await req.json()).user_id const { data } = await ctx.supabaseAdmin .from('reports') @@ -83,7 +81,7 @@ Deno.serve( .eq('user_id', userId) return Response.json(data) }), -) +} ``` ## Auth Modes @@ -165,7 +163,7 @@ app.get('/todos', withSupabase({ allow: 'user' }), async (c) => { app.get('/health', (c) => c.json({ status: 'ok' })) -Deno.serve(app.fetch) +export default { fetch: app.fetch } ``` The adapter does not handle CORS — use `hono/cors` for that. Per-route auth works naturally by applying the middleware to specific routes. @@ -237,25 +235,27 @@ const { data: env, error } = resolveEnv({ ```ts import { verifyAuth, createContextClient } from '@supabase/server/core' -Deno.serve(async (req) => { - const url = new URL(req.url) +export default { + fetch: async (req) => { + const url = new URL(req.url) - if (url.pathname === '/health') { - return Response.json({ status: 'ok' }) - } + if (url.pathname === '/health') { + return Response.json({ status: 'ok' }) + } - if (url.pathname === '/todos') { - const { data: auth, error } = await verifyAuth(req, { allow: 'user' }) - if (error) - return Response.json({ error: error.message }, { status: error.status }) + if (url.pathname === '/todos') { + const { data: auth, error } = await verifyAuth(req, { allow: 'user' }) + if (error) + return Response.json({ error: error.message }, { status: error.status }) - const supabase = createContextClient(auth.token) - const { data } = await supabase.from('todos').select() - return Response.json(data) - } + const supabase = createContextClient(auth.token) + const { data } = await supabase.from('todos').select() + return Response.json(data) + } - return new Response('Not found', { status: 404 }) -}) + return new Response('Not found', { status: 404 }) + }, +} ``` ## Environment Variables @@ -267,7 +267,7 @@ Automatically available in Supabase Edge Functions: | `SUPABASE_URL` | `https://.supabase.co` | Your project URL | | `SUPABASE_PUBLISHABLE_KEYS` | `{"default":"sb_publishable_...","web":"sb_publishable_..."}` | Publishable API keys (named) | | `SUPABASE_SECRET_KEYS` | `{"default":"sb_secret_...","web":"sb_secret_..."}` | Secret API keys (named) | -| `SUPABASE_JWKS` | `{"keys":[...]}` | JSON Web Key Set for JWT verification | +| `SUPABASE_JWKS` | `{"keys":[...]}` or `[...]` | JSON Web Key Set for JWT verification | Also supported (for local dev, self-hosted, or other runtimes): diff --git a/src/core/create-admin-client.test.ts b/src/core/create-admin-client.test.ts index 357d8c0..0125a08 100644 --- a/src/core/create-admin-client.test.ts +++ b/src/core/create-admin-client.test.ts @@ -27,7 +27,7 @@ describe('createAdminClient', () => { ).toThrow(EnvError) }) - it('throws EnvError when default secret key is missing', () => { + it('throws EnvError when secret keys are empty', () => { expect(() => createAdminClient({ url: 'https://test.supabase.co', @@ -49,4 +49,68 @@ describe('createAdminClient', () => { expect((e as EnvError).code).toBe('MISSING_SECRET_KEY') } }) + + it('uses the named key when keyName is provided', () => { + const env = { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { + default: 'sb_secret_default', + web: 'sb_secret_web', + mobile: 'sb_secret_mobile', + }, + jwks: null, + } + const client = createAdminClient(env, 'web') + expect(client).toBeDefined() + }) + + it('throws when named key does not exist', () => { + expect(() => createAdminClient(validEnv, 'nonexistent')).toThrow(EnvError) + + try { + createAdminClient(validEnv, 'nonexistent') + } catch (e) { + expect(e).toBeInstanceOf(EnvError) + expect((e as EnvError).code).toBe('MISSING_SECRET_KEY') + } + }) + + it('falls back to default key when keyName is null', () => { + const env = { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { + default: 'sb_secret_default', + web: 'sb_secret_web', + }, + jwks: null, + } + const client = createAdminClient(env, null) + expect(client).toBeDefined() + }) + + it('falls back to first available key when no default exists and keyName is null', () => { + const env = { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { + web: 'sb_secret_web', + mobile: 'sb_secret_mobile', + }, + jwks: null, + } + const client = createAdminClient(env, null) + expect(client).toBeDefined() + }) + + it('throws when keyName is null and secret keys are empty', () => { + const env = { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: {}, + jwks: null, + } + expect(() => createAdminClient(env, null)).toThrow(EnvError) + }) }) diff --git a/src/core/create-admin-client.ts b/src/core/create-admin-client.ts index 5465fdd..8eb0ecb 100644 --- a/src/core/create-admin-client.ts +++ b/src/core/create-admin-client.ts @@ -4,16 +4,23 @@ import { EnvError } from '../errors.js' import type { SupabaseEnv } from '../types.js' import { resolveEnv } from './resolve-env.js' -export function createAdminClient(env?: Partial): SupabaseClient { +export function createAdminClient( + env?: Partial, + keyName?: string | null, +): SupabaseClient { const { data: resolved, error } = resolveEnv(env) if (error) throw error - const secretKey = resolved.secretKeys['default'] + const name = keyName ?? 'default' + const keys = resolved.secretKeys + const secretKey = + keys[name] ?? (keyName == null ? Object.values(keys)[0] : undefined) if (!secretKey) { - throw new EnvError( - 'No default secret key found. Set SUPABASE_SECRET_KEY or include a "default" entry in SUPABASE_SECRET_KEYS.', - 'MISSING_SECRET_KEY', - ) + const msg = + name === 'default' + ? 'No default secret key found. Set SUPABASE_SECRET_KEY or include a "default" entry in SUPABASE_SECRET_KEYS.' + : `No "${name}" secret key found. Include a "${name}" entry in SUPABASE_SECRET_KEYS.` + throw new EnvError(msg, 'MISSING_SECRET_KEY') } return createClient(resolved.url, secretKey, { diff --git a/src/core/create-context-client.test.ts b/src/core/create-context-client.test.ts index d1b4021..78c6ad7 100644 --- a/src/core/create-context-client.test.ts +++ b/src/core/create-context-client.test.ts @@ -27,7 +27,7 @@ describe('createContextClient', () => { ).toThrow(EnvError) }) - it('throws EnvError when default publishable key is missing', () => { + it('throws EnvError when publishable keys are empty', () => { expect(() => createContextClient('test-token', { url: 'https://test.supabase.co', @@ -49,4 +49,70 @@ describe('createContextClient', () => { expect((e as EnvError).code).toBe('MISSING_PUBLISHABLE_KEY') } }) + + it('uses the named key when keyName is provided', () => { + const env = { + url: 'https://test.supabase.co', + publishableKeys: { + default: 'sb_publishable_default', + web: 'sb_publishable_web', + mobile: 'sb_publishable_mobile', + }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, + } + const client = createContextClient('test-token', env, 'web') + expect(client).toBeDefined() + }) + + it('throws when named key does not exist', () => { + expect(() => + createContextClient('test-token', validEnv, 'nonexistent'), + ).toThrow(EnvError) + + try { + createContextClient('test-token', validEnv, 'nonexistent') + } catch (e) { + expect(e).toBeInstanceOf(EnvError) + expect((e as EnvError).code).toBe('MISSING_PUBLISHABLE_KEY') + } + }) + + it('falls back to default key when keyName is null', () => { + const env = { + url: 'https://test.supabase.co', + publishableKeys: { + default: 'sb_publishable_default', + web: 'sb_publishable_web', + }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, + } + const client = createContextClient('test-token', env, null) + expect(client).toBeDefined() + }) + + it('falls back to first available key when no default exists and keyName is null', () => { + const env = { + url: 'https://test.supabase.co', + publishableKeys: { + web: 'sb_publishable_web', + mobile: 'sb_publishable_mobile', + }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, + } + const client = createContextClient('test-token', env, null) + expect(client).toBeDefined() + }) + + it('throws when keyName is null and publishable keys are empty', () => { + const env = { + url: 'https://test.supabase.co', + publishableKeys: {}, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, + } + expect(() => createContextClient('test-token', env, null)).toThrow(EnvError) + }) }) diff --git a/src/core/create-context-client.ts b/src/core/create-context-client.ts index 17c30b5..093fdfe 100644 --- a/src/core/create-context-client.ts +++ b/src/core/create-context-client.ts @@ -7,16 +7,21 @@ import { resolveEnv } from './resolve-env.js' export function createContextClient( token?: string | null, env?: Partial, + keyName?: string | null, ): SupabaseClient { const { data: resolved, error } = resolveEnv(env) if (error) throw error - const anonKey = resolved.publishableKeys['default'] + const name = keyName ?? 'default' + const keys = resolved.publishableKeys + const anonKey = + keys[name] ?? (keyName == null ? Object.values(keys)[0] : undefined) if (!anonKey) { - throw new EnvError( - 'No default publishable key found. Set SUPABASE_PUBLISHABLE_KEY or include a "default" entry in SUPABASE_PUBLISHABLE_KEYS.', - 'MISSING_PUBLISHABLE_KEY', - ) + const msg = + name === 'default' + ? 'No default publishable key found. Set SUPABASE_PUBLISHABLE_KEY or include a "default" entry in SUPABASE_PUBLISHABLE_KEYS.' + : `No "${name}" publishable key found. Include a "${name}" entry in SUPABASE_PUBLISHABLE_KEYS.` + throw new EnvError(msg, 'MISSING_PUBLISHABLE_KEY') } return createClient(resolved.url, anonKey, { diff --git a/src/core/verify-credentials.test.ts b/src/core/verify-credentials.test.ts index e73fa40..839c425 100644 --- a/src/core/verify-credentials.test.ts +++ b/src/core/verify-credentials.test.ts @@ -16,7 +16,7 @@ function makeEnv(overrides?: Partial): Partial { describe('verifyCredentials', () => { describe('always mode', () => { - it('succeeds with no credentials', async () => { + it('succeeds with no credentials and keyName is null', async () => { const creds: Credentials = { token: null, apikey: null } const result = await verifyCredentials(creds, { allow: 'always', @@ -24,11 +24,12 @@ describe('verifyCredentials', () => { }) expect(result.error).toBeNull() expect(result.data!.authType).toBe('always') + expect(result.data!.keyName).toBeNull() }) }) describe('public mode', () => { - it('succeeds with valid publishable key', async () => { + it('succeeds with valid publishable key and returns default keyName', async () => { const creds: Credentials = { token: null, apikey: 'sb_publishable_xyz', @@ -39,6 +40,7 @@ describe('verifyCredentials', () => { }) expect(result.error).toBeNull() expect(result.data!.authType).toBe('public') + expect(result.data!.keyName).toBe('default') }) it('fails with invalid key', async () => { @@ -67,7 +69,7 @@ describe('verifyCredentials', () => { expect(result.error!.code).toBe('INVALID_CREDENTIALS') }) - it('matches named key with colon syntax', async () => { + it('matches named key with colon syntax and returns keyName', async () => { const env = makeEnv({ publishableKeys: { web: 'sb_publishable_web', @@ -80,6 +82,7 @@ describe('verifyCredentials', () => { env, }) expect(result.error).toBeNull() + expect(result.data!.keyName).toBe('web') }) it('rejects wrong named key', async () => { @@ -135,10 +138,30 @@ describe('verifyCredentials', () => { expect(result.error).toBeNull() expect(result.data!.authType).toBe('public') }) + + it('wildcard returns correct keyName for non-first key', async () => { + const env = makeEnv({ + publishableKeys: { + default: 'sb_publishable_default', + web: 'sb_publishable_web', + mobile: 'sb_publishable_mobile', + }, + }) + const creds: Credentials = { + token: null, + apikey: 'sb_publishable_mobile', + } + const result = await verifyCredentials(creds, { + allow: 'public:*', + env, + }) + expect(result.error).toBeNull() + expect(result.data!.keyName).toBe('mobile') + }) }) describe('secret mode', () => { - it('succeeds with valid secret key', async () => { + it('succeeds with valid secret key and returns default keyName', async () => { const creds: Credentials = { token: null, apikey: 'sb_secret_xyz' } const result = await verifyCredentials(creds, { allow: 'secret', @@ -146,6 +169,7 @@ describe('verifyCredentials', () => { }) expect(result.error).toBeNull() expect(result.data!.authType).toBe('secret') + expect(result.data!.keyName).toBe('default') }) it('fails with invalid secret key', async () => { @@ -171,7 +195,7 @@ describe('verifyCredentials', () => { expect(result.error!.code).toBe('INVALID_CREDENTIALS') }) - it('matches secret named key with colon syntax', async () => { + it('matches secret named key with colon syntax and returns keyName', async () => { const env = makeEnv({ secretKeys: { web: 'sb_secret_web', mobile: 'sb_secret_mobile' }, }) @@ -181,6 +205,7 @@ describe('verifyCredentials', () => { env, }) expect(result.error).toBeNull() + expect(result.data!.keyName).toBe('web') }) it('rejects wrong secret named key', async () => { @@ -221,6 +246,23 @@ describe('verifyCredentials', () => { expect(result.error).toBeNull() expect(result.data!.authType).toBe('secret') }) + + it('wildcard returns correct keyName for non-first key', async () => { + const env = makeEnv({ + secretKeys: { + default: 'sb_secret_default', + web: 'sb_secret_web', + mobile: 'sb_secret_mobile', + }, + }) + const creds: Credentials = { token: null, apikey: 'sb_secret_mobile' } + const result = await verifyCredentials(creds, { + allow: 'secret:*', + env, + }) + expect(result.error).toBeNull() + expect(result.data!.keyName).toBe('mobile') + }) }) describe('user mode', () => { @@ -253,6 +295,7 @@ describe('verifyCredentials', () => { }) expect(result.error).toBeNull() expect(result.data!.authType).toBe('user') + expect(result.data!.keyName).toBeNull() expect(result.data!.userClaims!.id).toBe('user-123') expect(result.data!.userClaims!.email).toBe('test@example.com') expect(result.data!.claims!.sub).toBe('user-123') @@ -348,7 +391,7 @@ describe('verifyCredentials', () => { }) describe('array allow (first match wins)', () => { - it('matches second mode when first fails', async () => { + it('matches second mode when first fails and returns its keyName', async () => { const creds: Credentials = { token: null, apikey: 'sb_publishable_xyz', @@ -359,6 +402,7 @@ describe('verifyCredentials', () => { }) expect(result.error).toBeNull() expect(result.data!.authType).toBe('public') + expect(result.data!.keyName).toBe('default') }) it('matches first mode when it succeeds', async () => { diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index 3db8c8a..53b9303 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -56,20 +56,27 @@ async function tryMode( switch (base) { case 'always': - return { authType: 'always', token: null, userClaims: null, claims: null } + return { + authType: 'always', + token: null, + userClaims: null, + claims: null, + keyName: null, + } case 'public': { if (!credentials.apikey) return null const keys = env.publishableKeys if (keyName === '*') { - for (const value of Object.values(keys)) { + for (const [name, value] of Object.entries(keys)) { if (await timingSafeEqual(credentials.apikey, value)) { return { authType: 'public', token: null, userClaims: null, claims: null, + keyName: name, } } } @@ -82,6 +89,7 @@ async function tryMode( token: null, userClaims: null, claims: null, + keyName: name, } } } @@ -93,13 +101,14 @@ async function tryMode( const keys = env.secretKeys if (keyName === '*') { - for (const value of Object.values(keys)) { + for (const [name, value] of Object.entries(keys)) { if (await timingSafeEqual(credentials.apikey, value)) { return { authType: 'secret', token: null, userClaims: null, claims: null, + keyName: name, } } } @@ -112,6 +121,7 @@ async function tryMode( token: null, userClaims: null, claims: null, + keyName: name, } } } @@ -133,6 +143,7 @@ async function tryMode( token: credentials.token, userClaims: claimsToUserClaims(claims), claims, + keyName: null, } } catch { return null diff --git a/src/create-supabase-context.ts b/src/create-supabase-context.ts index 604eb2d..3263ee6 100644 --- a/src/create-supabase-context.ts +++ b/src/create-supabase-context.ts @@ -21,8 +21,9 @@ export async function createSupabaseContext( } try { - const supabase = createContextClient(auth.token, options?.env) - const supabaseAdmin = createAdminClient(options?.env) + const supabase = createContextClient(auth.token, options?.env, auth.keyName) + const adminKeyName = auth.authType === 'secret' ? auth.keyName : undefined + const supabaseAdmin = createAdminClient(options?.env, adminKeyName) return { data: { diff --git a/src/types.ts b/src/types.ts index 8e53b43..693e208 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,6 +24,7 @@ export interface AuthResult { token: string | null userClaims: UserClaims | null claims: JWTClaims | null + keyName?: string | null } export interface JWTClaims {