Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/auth-modes.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -209,5 +209,5 @@ withSupabase({ auth: ['user', 'publishable:web'] }, async (_req, ctx) => {
1. `extractCredentials(request)` reads `Authorization: Bearer <token>` and `apikey` from headers
2. Each mode in `auth` is tried in order against the extracted credentials
3. First match wins — returns an `AuthResult` with `authMode`, `token`, `userClaims`, `jwtClaims`, and `keyName`. A mode falls through to the next only when its credential is absent; a credential that is present but invalid terminates the chain with `InvalidCredentialsError`.
4. The auth result is used to create scoped clients (`supabase` with the user's token, `supabaseAdmin` with the secret key)
4. The auth result is used to create scoped clients (`supabase` with the user's token, `supabaseAdmin` with the secret key — constructed on its first property access)
5. Everything is bundled into a `SupabaseContext` and passed to your handler
12 changes: 6 additions & 6 deletions docs/environment-variables.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,12 +15,12 @@ On Supabase Platform and Local Development (CLI), all variables are auto-provisi

Set these based on which auth modes your app uses:

| Variable | Required when |
| -------------------------------------- | ----------------------------------------- |
| `SUPABASE_URL` | Always |
| `SUPABASE_SECRET_KEY` | `auth: 'secret'` or using `supabaseAdmin` |
| `SUPABASE_PUBLISHABLE_KEY` | `auth: 'publishable'` |
| `SUPABASE_JWKS` or `SUPABASE_JWKS_URL` | `auth: 'user'` (JWT verification) |
| Variable | Required when |
| -------------------------------------- | -------------------------------------------------------------- |
| `SUPABASE_URL` | Always |
| `SUPABASE_SECRET_KEY` | `auth: 'secret'`, or when the handler accesses `supabaseAdmin` |
| `SUPABASE_PUBLISHABLE_KEY` | `auth: 'publishable'` |
| `SUPABASE_JWKS` or `SUPABASE_JWKS_URL` | `auth: 'user'` (JWT verification) |

### Minimal `.env` example

Expand Down
79 changes: 79 additions & 0 deletions src/core/lazy-client.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'

import { lazyClient } from './lazy-client.js'

class Thing {
#secret = 42
label = 'thing'

reveal(): number {
return this.#secret
}

get computed(): number {
return this.#secret + 1
}
}

function counted(): { proxy: Thing; calls: () => number; last: () => Thing } {
let calls = 0
let instance: Thing | undefined
const proxy = lazyClient<Thing>(() => {
calls++
instance = new Thing()
return instance
})
return { proxy, calls: () => calls, last: () => instance! }
}

describe('lazyClient', () => {
it('defers construction until the first property access', () => {
const { proxy, calls } = counted()
expect(calls()).toBe(0)
expect(proxy.label).toBe('thing')
expect(calls()).toBe(1)
})

it('constructs at most once across traps', () => {
const { proxy, calls } = counted()
expect(proxy.label).toBe('thing')
expect('reveal' in proxy).toBe(true)
expect(Object.keys(proxy)).toContain('label')
expect(proxy instanceof Thing).toBe(true)
expect(calls()).toBe(1)
})

it('binds methods so private fields resolve against the instance', () => {
const { proxy } = counted()
const reveal = proxy.reveal
expect(reveal()).toBe(42)
})

it('runs prototype getters against the instance', () => {
const { proxy } = counted()
expect(proxy.computed).toBe(43)
})

it('surfaces a factory throw at the access point and retries', () => {
let calls = 0
const proxy = lazyClient<Thing>(() => {
calls++
throw new Error(`boom ${calls}`)
})
expect(() => proxy.label).toThrow('boom 1')
expect(() => proxy.label).toThrow('boom 2')
expect(calls).toBe(2)
})

it('forwards writes to the underlying instance', () => {
const { proxy, last } = counted()
proxy.label = 'renamed'
expect(last().label).toBe('renamed')
expect(proxy.label).toBe('renamed')
})

it('spreads the instance own keys', () => {
const { proxy } = counted()
expect({ ...proxy }).toEqual({ label: 'thing' })
})
})
35 changes: 35 additions & 0 deletions src/core/lazy-client.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
/**
* Wraps a client factory in a Proxy that defers construction to the first
* interaction with the client — a property read or write, an `in` check,
* `instanceof`, or key enumeration. Construction happens at most once: every
* trap delegates to the same memoized instance. A factory throw surfaces at
* the interaction that triggered it, and the factory runs again on the next
* interaction.
*
* Function-valued properties are bound to the real instance, so class
* internals (private fields, prototype getters) always see the instance
* itself rather than the proxy.
*
* Inspecting an unconstructed proxy (`console.log`, test diff printers)
* enumerates its keys and therefore triggers construction — in a
* misconfigured environment, that inspection throws.
*/
export function lazyClient<T extends object>(build: () => T): T {
let client: T | undefined
const instance = () => (client ??= build())
return new Proxy({} as T, {
get(_target, prop) {
const c = instance()
const value = Reflect.get(c, prop) as unknown
return typeof value === 'function'
? (value as (...args: never[]) => unknown).bind(c)
: value
},
set: (_target, prop, value) => Reflect.set(instance(), prop, value),
has: (_target, prop) => Reflect.has(instance(), prop),
getPrototypeOf: () => Reflect.getPrototypeOf(instance()),
ownKeys: () => Reflect.ownKeys(instance()),
getOwnPropertyDescriptor: (_target, prop) =>
Reflect.getOwnPropertyDescriptor(instance(), prop),
})
}
64 changes: 60 additions & 4 deletions src/middleware/admin-client/index.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { pipeline } from '@supabase/middleware'
import { describe, expect, it } from 'vitest'

import type { SupabaseClient } from '@supabase/supabase-js'
import { SupabaseClient } from '@supabase/supabase-js'

import {
EnvError,
Expand DownExpand Up@@ -35,8 +35,12 @@ describe('withSupabaseAdminClient', () => {
})

it("selects the matched secret key from an upstream withSupabase context's authKeyName", async () => {
const handler = pipeline([withSupabaseAdminClient({ env: baseEnv })], () =>
Promise.resolve(Response.json({ ok: true })),
const handler = pipeline(
[withSupabaseAdminClient({ env: baseEnv })],
async (_req, ctx) => {
ctx.supabaseAdmin.from('t')
return Response.json({ ok: true })
},
)

// authKeyName 'internal' is not in the key set — the throw proves the
Expand All@@ -50,17 +54,69 @@ describe('withSupabaseAdminClient', () => {
).rejects.toMatchObject({ code: MissingSecretKeyError })
})

it('throws EnvError when no secret key exists', async () => {
it('succeeds without a secret key when the handler never accesses supabaseAdmin', async () => {
const handler = pipeline(
[withSupabaseAdminClient({ env: { ...baseEnv, secretKeys: {} } })],
async () => Response.json({ ok: true }),
)

const res = await handler(new Request('http://localhost'))
expect(res.status).toBe(200)
})

it('throws EnvError at the first supabaseAdmin access when no secret key exists', async () => {
const handler = pipeline(
[withSupabaseAdminClient({ env: { ...baseEnv, secretKeys: {} } })],
async (_req, ctx) => {
ctx.supabaseAdmin.from('t')
return Response.json({ ok: true })
},
)

await expect(
handler(new Request('http://localhost')),
).rejects.toMatchObject({ code: MissingDefaultSecretKeyError })
await expect(
handler(new Request('http://localhost')),
).rejects.toBeInstanceOf(EnvError)
})

it('constructs the client once per request across accesses', async () => {
let first: unknown
let second: unknown
const handler = pipeline(
[withSupabaseAdminClient({ env: baseEnv })],
async (_req, ctx) => {
// `auth` is a constructor-assigned data property — identity across
// accesses proves a single underlying client. (`functions` is a
// getter that mints a new client per read, so it can't prove this.)
first = ctx.supabaseAdmin.auth
second = ctx.supabaseAdmin.auth
return Response.json({ ok: true })
},
)

const res = await handler(new Request('http://localhost'))
expect(res.status).toBe(200)
expect(first).toBeDefined()
expect(first).toBe(second)
})

it('exposes a working SupabaseClient through the proxy', async () => {
const handler = pipeline(
[withSupabaseAdminClient({ env: baseEnv })],
async (_req, ctx) => {
return Response.json({
isClient: ctx.supabaseAdmin instanceof SupabaseClient,
hasSelect: typeof ctx.supabaseAdmin.from('t').select === 'function',
})
},
)

const res = await handler(new Request('http://localhost'))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.isClient).toBe(true)
expect(body.hasSelect).toBe(true)
})
})
33 changes: 21 additions & 12 deletions src/middleware/admin-client/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import type { Entry } from '@supabase/middleware'
import type { SupabaseClient } from '@supabase/supabase-js'

import { createAdminClient } from '../../core/create-admin-client.js'
import { lazyClient } from '../../core/lazy-client.js'
import { readUpstreamAuth } from '../../core/read-upstream-auth.js'
import { CreateSupabaseClientError, EnvError, Errors } from '../../errors.js'
import type { CreateAdminClientOptions } from '../../types.js'
Expand DownExpand Up@@ -33,16 +34,20 @@ const base = defineMiddleware<
const keyName =
upstream.authMode === 'secret' ? upstream.authKeyName : undefined

let supabaseAdmin: SupabaseClient
try {
supabaseAdmin = createAdminClient({
auth: { keyName },
env: config?.env,
supabaseOptions: config?.supabaseOptions,
})
} catch (e) {
throw e instanceof EnvError ? e : Errors[CreateSupabaseClientError]()
}
// Constructed on the first property access and memoized for the request —
// a handler that never touches ctx.supabaseAdmin never resolves the
// secret key.
const supabaseAdmin = lazyClient<SupabaseClient>(() => {
try {
return createAdminClient({
auth: { keyName },
env: config?.env,
supabaseOptions: config?.supabaseOptions,
})
} catch (e) {
throw e instanceof EnvError ? e : Errors[CreateSupabaseClientError]()
}
})
return { supabaseAdmin }
},
})
Expand All@@ -52,9 +57,13 @@ const base = defineMiddleware<
* Row-Level Security, authenticated with a secret key. This is the same
* middleware `withSupabase` composes internally to build its context.
*
* The client is constructed on the first property access of
* `ctx.supabaseAdmin` and memoized for the request. A handler that never
* accesses it requires no secret key.
*
* @throws {@link index.EnvError} When `SUPABASE_URL` or the secret key is
* missing — composing wrappers (like `withSupabase`) map this to a 500
* response; standalone pipelines see it as a thrown error.
* missing — thrown at the first `ctx.supabaseAdmin` property access, inside
* the handler or downstream middleware that performs it.
*
* @example Standalone pipeline
* ```ts
Expand Down
37 changes: 36 additions & 1 deletion src/with-supabase.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@ import { defineMiddleware, getEnv } from '@supabase/middleware'
import type { FetchHandler } from '@supabase/middleware'

import { _resetAllowDeprecationWarned } from './core/utils/deprecation.js'
import { EnvError } from './errors.js'
import { EnvError, MissingDefaultSecretKeyError } from './errors.js'
import { withOAuthProtectedResource } from './oauth-protected-resource/with-oauth-protected-resource.js'
import { withSupabase } from './with-supabase.js'

Expand DownExpand Up@@ -413,6 +413,41 @@ describe('withSupabase', () => {
'handler-level env failure',
)
})

it('serves requests without a secret key when the handler never accesses supabaseAdmin', async () => {
let ran = false
const handler = withSupabase(
{
auth: 'none',
env: { ...baseEnv, secretKeys: {} },
},
async () => {
ran = true
return Response.json({ ok: true })
},
)

const res = await handler(new Request('http://localhost'))
expect(res.status).toBe(200)
expect(ran).toBe(true)
})

it('propagates the missing-secret-key EnvError at the supabaseAdmin access point', async () => {
const handler = withSupabase(
{
auth: 'none',
env: { ...baseEnv, secretKeys: {} },
},
async (_req, ctx) => {
ctx.supabaseAdmin.from('t')
return Response.json({ ok: true })
},
)

await expect(
handler(new Request('http://localhost')),
).rejects.toMatchObject({ code: MissingDefaultSecretKeyError })
})
})

describe('allow → auth deprecation', () => {
Expand Down
15 changes: 8 additions & 7 deletions src/with-supabase.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -187,10 +187,11 @@ export function withSupabase<Database = unknown>(
)
}

// Track whether the request has moved past client construction: only
// failures from the two client entries map to the historical JSON error
// responses — user middleware and handler throws propagate unchanged,
// exactly as before the rewrite.
// Track whether the request has moved past the client entries: only
// client-phase construction failures (the `supabase` entry) map to JSON
// error responses. User middleware and handler throws propagate
// unchanged — including the EnvError a lazily constructed
// `supabaseAdmin` throws at its first property access.
let inClientPhase = true
const markUserPhase: AnyHandler = (r, ctx) => {
inClientPhase = false
Expand DownExpand Up@@ -223,10 +224,10 @@ export function withSupabase<Database = unknown>(
...upstreamAuth,
})
} catch (e) {
// Client construction failures keep their historical response shape:
// Client-phase construction failures map to JSON error responses:
// EnvError (missing URL / keys) and the client middleware's
// CreateSupabaseClientError map to the same JSON errors
// createSupabaseContext produced.
// CreateSupabaseClientError take the same shape as the JSON errors
// createSupabaseContext produces.
const mapped = !inClientPhase
? null
: e instanceof EnvError
Expand Down
Loading