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
31 changes: 16 additions & 15 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -521,21 +521,22 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like

## Exports

| Export | What's in it |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `@supabase/server` | `withSupabase`, `createSupabaseContext` |
| `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` |
| `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) |
| `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) |
| `@supabase/server/adapters/elysia` | `withSupabase` (Elysia plugin) |
| `@supabase/server/adapters/nestjs` | `withSupabase` (NestJS guard), `SupabaseCtx` (param decorator) |
| `@supabase/server/middleware/client` | `withSupabaseClient` (RLS-scoped `ctx.supabase` client) |
| `@supabase/server/middleware/admin-client` | `withSupabaseAdminClient` (`ctx.supabaseAdmin`, bypasses RLS) |
| `@supabase/server/middleware/claims` | `withClaims` (JWKS-verified `ctx.jwtClaims`) |
| `@supabase/server/middleware/postgres` | `withPostgresClient` (RLS-scoped `ctx.postgres` client) |
| `@supabase/server/middleware/postgres-admin` | `withPostgresAdminClient` (`ctx.postgresAdmin`, bypasses RLS) |
| `@supabase/server/oauth-protected-resource` | `withOAuthProtectedResource`, `fromSupabaseUrl`, `resourceMetadataResponse`, `unauthorizedResponse` |
| `@supabase/server/peer/supabase-js` | Re-exported `supabase-js` types (`SupabaseClient`, `PostgrestError`, …) |
| Export | What's in it |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `@supabase/server` | `withSupabase`, `createSupabaseContext` |
| `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` |
| `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) |
| `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) |
| `@supabase/server/adapters/elysia` | `withSupabase` (Elysia plugin) |
| `@supabase/server/adapters/nestjs` | `withSupabase` (NestJS guard), `SupabaseCtx` (param decorator) |
| `@supabase/server/middleware/client` | `withSupabaseClient` (RLS-scoped `ctx.supabase` client) |
| `@supabase/server/middleware/admin-client` | `withSupabaseAdminClient` (`ctx.supabaseAdmin`, bypasses RLS) |
| `@supabase/server/middleware/claims` | `withClaims` (JWKS-verified `ctx.jwtClaims`) |
| `@supabase/server/middleware/required-claims` | `withRequiredClaims` (user-mode auth gate, non-null `ctx.jwtClaims`) |
| `@supabase/server/middleware/postgres` | `withPostgresClient` (RLS-scoped `ctx.postgres` client) |
| `@supabase/server/middleware/postgres-admin` | `withPostgresAdminClient` (`ctx.postgresAdmin`, bypasses RLS) |
| `@supabase/server/oauth-protected-resource` | `withOAuthProtectedResource`, `fromSupabaseUrl`, `resourceMetadataResponse`, `unauthorizedResponse` |
| `@supabase/server/peer/supabase-js` | Re-exported `supabase-js` types (`SupabaseClient`, `PostgrestError`, …) |

## Documentation

Expand Down
56 changes: 53 additions & 3 deletions docs/api-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -187,17 +187,67 @@ Behavior:
- Token present but invalid: short-circuits with a 401 and `{ message, code: 'INVALID_CREDENTIALS' }`.
- Token present but no JWKS configured: short-circuits with a 500 and `{ message, code: 'ENV_ERROR' }`. Verification is required; the middleware has no decode-only mode.

`withClaims` is not an auth gate. It never rejects a request that has no token, so `[withClaims(), withSupabaseClient()]` is not the composable form of `withSupabase({ auth: 'user' })` and accepts anonymous callers. To require an authenticated caller, gate with `withSupabase({ auth: 'user' })` and compose further middleware through its `middleware` option. A host that takes an entries array can wrap it as the sole entry:
`withClaims` is not an auth gate. It never rejects a request that has no token, so `[withClaims(), withSupabaseClient()]` is not the composable form of `withSupabase({ auth: 'user' })` and accepts anonymous callers. To require an authenticated caller, compose `withRequiredClaims` (`@supabase/server/middleware/required-claims`) instead. The two entries share the `jwtClaims` key, so a pipeline picks "claims if present" or "claims required"; composing both is a compile-time conflict.

### WithClaimsConfig

```ts
interface WithClaimsConfig {
jwks?: JSONWebKeySet | URL
}
```

Defaults to `SUPABASE_JWKS` (inline JSON) or `SUPABASE_JWKS_URL` (https endpoint) from the environment.

---

## @supabase/server/middleware/required-claims

### withRequiredClaims

```ts
const withRequiredClaims: Middleware<
'jwtClaims',
WithRequiredClaimsConfig | void,
Record<never, never>,
JWTClaims
>
```

The user-mode auth gate. Verifies the caller's Bearer token against the project JWKS and contributes **non-null** `ctx.jwtClaims`. This is the same verification core `withSupabase` uses for its `user` auth mode.

Behavior:

- No `Authorization: Bearer` token, or an `sb_*` API key in that position: short-circuits with a 401 and `{ message, code: 'INVALID_CREDENTIALS' }`. The handler never runs.
- Token present but invalid: the same 401.
- Token present but no JWKS configured: short-circuits with a 500 and `{ message, code: 'ENV_ERROR' }`. Verification is required; the middleware has no decode-only mode.

`withRequiredClaims` is the required-caller counterpart to `withClaims`: "claims required" rather than "claims if present". The two share the `jwtClaims` key, so composing both in one pipeline is a compile-time conflict.

Because the contribution is non-null, gated handlers read `ctx.jwtClaims` directly, and entries declaring a `jwtClaims` prerequisite, such as `withPostgresClient`, compose with no further verification:

```ts
pipeline([withRequiredClaims(), withPostgresClient()], async (req, ctx) => {
const rows = await ctx.postgres.query`select id, title from posts`
return Response.json({ rows, caller: ctx.jwtClaims.sub })
})
```

The gate's 401 and 500 short-circuits carry no CORS headers, and a bare pipeline answers no `OPTIONS` preflight. For browser callers, compose `withCors` (`@supabase/middleware/cors`) ahead of the gate: it answers preflight before the gate runs and stamps `Access-Control-*` headers on the gate's short-circuit responses.

Inside `withSupabase` the context already carries verified `jwtClaims`, so composing the gate through the `middleware` option is a compile-time conflict. Use `withSupabase({ auth: 'user' })` to gate that path.

The gate contributes `jwtClaims` and nothing else. A handler that needs the full `SupabaseContext` behind an auth gate (for example `ctx.userClaims` or `ctx.authMode`, which no composable entry contributes) uses `withSupabase({ auth: 'user' })` directly. A host that takes an entries array can wrap it as the sole entry. `cors: 'disabled'` leaves CORS handling to the host:

```ts
const entry = (h: (req: Request, ctx: object) => Promise<Response>) =>
withSupabase({ auth: 'user', cors: 'disabled' }, h)
```

### WithClaimsConfig
### WithRequiredClaimsConfig

```ts
interface WithClaimsConfig {
interface WithRequiredClaimsConfig {
jwks?: JSONWebKeySet | URL
}
```
Expand Down
2 changes: 1 addition & 1 deletion docs/postgres.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,7 +158,7 @@ Order matters. `withPostgresClient` before `withClaims` is a compile-time error:
middleware-prereq: key 'jwtClaims' is not yet on the context (check ordering)
```

`withClaims` is not an auth gate. It contributes claims when a token is present, and `null` when one is not. The standalone pipeline above therefore also serves anonymous callers, whose queries run as `anon`. To require an authenticated caller, use the `withSupabase` form: `auth: 'user'` rejects token-less requests with a 401 before the handler runs.
`withClaims` is not an auth gate. It contributes claims when a token is present, and `null` when one is not. The standalone pipeline above therefore also serves anonymous callers, whose queries run as `anon`. To require an authenticated caller, swap in [`withRequiredClaims`](../src/middleware/required-claims/index.ts): it rejects token-less requests with a 401 before the handler runs and contributes non-null `jwtClaims`, so the handler reads `ctx.jwtClaims.sub` directly. Inside `withSupabase`, `auth: 'user'` provides the same gate.

## Table grants

Expand Down
1 change: 1 addition & 0 deletions jsr.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
"./middleware/postgres": "./src/middleware/postgres/index.ts",
"./middleware/postgres-admin": "./src/middleware/postgres-admin/index.ts",
"./middleware/claims": "./src/middleware/claims/index.ts",
"./middleware/required-claims": "./src/middleware/required-claims/index.ts",
"./oauth-protected-resource": "./src/oauth-protected-resource/index.ts"
},
"publish": {
Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -139,6 +139,16 @@
"default": "./dist/middleware/claims/index.cjs"
}
},
"./middleware/required-claims": {
"import": {
"types": "./dist/middleware/required-claims/index.d.mts",
"default": "./dist/middleware/required-claims/index.mjs"
},
"require": {
"types": "./dist/middleware/required-claims/index.d.cts",
"default": "./dist/middleware/required-claims/index.cjs"
}
},
"./oauth-protected-resource": {
"import": {
"types": "./dist/oauth-protected-resource/index.d.mts",
Expand Down
13 changes: 4 additions & 9 deletions src/middleware/claims/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,15 +45,10 @@ export interface WithClaimsConfig {
* token. A pipeline like `[withClaims(), withSupabaseClient()]` accepts
* anonymous callers and is not the composable form of
* `withSupabase({ auth: 'user' })`, which rejects token-less requests with
* a 401. To require an authenticated caller, gate with
* `withSupabase({ auth: 'user' })` and compose further middleware through
* its `middleware` option. A host that takes an entries array can wrap it
* as the sole entry:
*
* ```ts
* const entry = (h: (req: Request, ctx: object) => Promise<Response>) =>
* withSupabase({ auth: 'user', cors: 'disabled' }, h)
* ```
* a 401. To require an authenticated caller, compose `withRequiredClaims`
* from `@supabase/server/middleware/required-claims` instead. The two entries
* share the `jwtClaims` key, so a pipeline picks "claims if present" or
* "claims required"; composing both is a compile-time conflict.
*
* @example Standalone pipeline
* ```ts
Expand Down
220 changes: 220 additions & 0 deletions src/middleware/required-claims/index.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
import { pipeline } from '@supabase/middleware'
import { exportJWK, generateKeyPair, generateSecret, SignJWT } from 'jose'
import {
afterEach,
beforeAll,
describe,
expect,
expectTypeOf,
it,
vi,
} from 'vitest'

import type { JSONWebKeySet } from 'jose'

import { EnvGenericError, InvalidCredentialsError } from '../../errors.js'
import { withSupabase } from '../../with-supabase.js'
import { withClaims } from '../claims/index.js'
import { withPostgresClient } from '../postgres/index.js'
import { withRequiredClaims } from './index.js'

import type { JWTClaims } from '../../types.js'

describe('withRequiredClaims', () => {
let jwks: JSONWebKeySet
let rsToken: string
let hsToken: string
let foreignToken: string

beforeAll(async () => {
// Asymmetric JWK
const { privateKey, publicKey } = await generateKeyPair('RS256')
const publicJwk = await exportJWK(publicKey)
publicJwk.alg = 'RS256'
publicJwk.use = 'sig'
publicJwk.kid = 'asymmetric-key-id'

// Symmetric Shared Secret JWK
const jwtSecret = await generateSecret('HS256', { extractable: true })
const symmetricJwk = await exportJWK(jwtSecret)
symmetricJwk.alg = 'HS256'
symmetricJwk.kid = 'symmetric-shared-secret-key-id'

jwks = { keys: [publicJwk, symmetricJwk] }

const signWith = (
key: CryptoKey | Uint8Array<ArrayBufferLike>,
alg: string,
kid: string,
) =>
new SignJWT({ sub: 'user-123', role: 'authenticated' })
.setProtectedHeader({ alg, kid })
.setIssuedAt()
.setExpirationTime('1h')
.sign(key)

rsToken = await signWith(privateKey, 'RS256', publicJwk.kid!)
hsToken = await signWith(jwtSecret, 'HS256', symmetricJwk.kid!)

// Signed by a key that is NOT in the JWKS — verification must fail.
const { privateKey: foreignKey } = await generateKeyPair('RS256')
foreignToken = await signWith(foreignKey, 'RS256', publicJwk.kid!)
})

afterEach(() => {
vi.unstubAllEnvs()
})

function requestWithToken(token?: string): Request {
return new Request('http://localhost', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
}

it('contributes JWKS-verified claims for a valid token', async () => {
for (const token of [() => rsToken, () => hsToken]) {
let seen: unknown
const handler = withRequiredClaims({ jwks }, async (_req, ctx) => {
seen = ctx.jwtClaims
return Response.json({ ok: true })
})

const res = await handler(requestWithToken(token()))
expect(res.status).toBe(200)
expect(seen).toMatchObject({ sub: 'user-123', role: 'authenticated' })
}
})

it('short-circuits 401 when no Authorization header is present', async () => {
let ran = false
const handler = withRequiredClaims({ jwks }, async () => {
ran = true
return Response.json({ ok: true })
})

const res = await handler(requestWithToken())
expect(res.status).toBe(401)
const body = await res.json()
expect(body.code).toBe(InvalidCredentialsError)
expect(ran).toBe(false)
})

it('short-circuits 401 for an sb_* apikey in the Authorization header', async () => {
let ran = false
const handler = withRequiredClaims({ jwks }, async () => {
ran = true
return Response.json({ ok: true })
})

const apikeys = [
'sb_publishable_xyz',
'sb_secret_xyz',
'sb_temp_xyz',
'sb_something',
]

for (const apikey of apikeys) {
const res = await handler(requestWithToken(apikey))
expect(res.status).toBe(401)
const body = await res.json()
expect(body.code).toBe(InvalidCredentialsError)
expect(ran).toBe(false)
}
})

it('short-circuits 401 for a token signed by an unknown key', async () => {
const handler = withRequiredClaims({ jwks }, async () =>
Response.json({ ok: true }),
)

const res = await handler(requestWithToken(foreignToken))
expect(res.status).toBe(401)
const body = await res.json()
expect(body.code).toBe(InvalidCredentialsError)
})

it('short-circuits 401 for a malformed token', async () => {
const handler = withRequiredClaims({ jwks }, async () =>
Response.json({ ok: true }),
)

const res = await handler(requestWithToken('not-a-jwt'))
expect(res.status).toBe(401)
})

it('short-circuits 500 when a token is present but no JWKS is configured', async () => {
vi.stubEnv('SUPABASE_JWKS', '')
vi.stubEnv('SUPABASE_JWKS_URL', '')
const handler = withRequiredClaims(async () => Response.json({ ok: true }))

const res = await handler(requestWithToken(rsToken))
expect(res.status).toBe(500)
const body = await res.json()
expect(body.code).toBe(EnvGenericError)
expect(body.message).toContain('JWKS')
})

it('short-circuits 401 when neither a token nor a JWKS is present', async () => {
// Missing credentials are the caller's problem and are reported before
// missing configuration: the JWKS is never resolved for a request that
// carries nothing to verify.
vi.stubEnv('SUPABASE_JWKS', '')
vi.stubEnv('SUPABASE_JWKS_URL', '')
const handler = withRequiredClaims(async () => Response.json({ ok: true }))

const res = await handler(requestWithToken())
expect(res.status).toBe(401)
const body = await res.json()
expect(body.code).toBe(InvalidCredentialsError)
})
})

describe('withRequiredClaims composition (type-level)', () => {
const baseEnv = {
url: 'https://test.supabase.co',
publishableKeys: { default: 'sb_publishable_xyz' },
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
}

it('satisfies withPostgresClient and the handler sees non-null claims', () => {
const _handler = pipeline(
[withRequiredClaims(), withPostgresClient()],
async (_req, ctx) => {
expectTypeOf(ctx.jwtClaims).toEqualTypeOf<JWTClaims>()
expectTypeOf(ctx.postgres).not.toBeAny()
return Response.json({ ok: true })
},
)
void _handler
})

it('composing with withClaims is a compile-time conflict (gate first)', () => {
const _bad = pipeline(
[withRequiredClaims(), withClaims()],
// @ts-expect-error — Conflict<'jwtClaims'>: both entries contribute the key
async () => Response.json({ ok: true }),
)
void _bad
})

it('composing with withClaims is a compile-time conflict (withClaims first)', () => {
const _bad = pipeline(
[withClaims(), withRequiredClaims()],
// @ts-expect-error — Conflict<'jwtClaims'>: both entries contribute the key
async () => Response.json({ ok: true }),
)
void _bad
})

it('gating inside withSupabase is a compile-time conflict', () => {
// withSupabase already verifies credentials and seeds jwtClaims before
// the middleware array runs, so the gate is redundant there.
// @ts-expect-error — Conflict<'jwtClaims'>: key already on the context
const _bad = withSupabase(
{ auth: 'none', env: baseEnv, middleware: [withRequiredClaims()] },
async () => Response.json({ ok: true }),
)
void _bad
})
})
Loading
Loading