Skip to content
Draft
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
64 changes: 64 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Wraps a fetch handler with auth, CORS, and client creation. Returns a `(req: Req
- Verifies credentials per `config.auth`
- Returns JSON error response on auth failure
- Adds CORS headers to all responses
- Composes a `middleware: [...]` array of entries after auth and client creation; entries receive `ctx.supabase`, `ctx.jwtClaims`, and the rest already present
- Auth and client-creation error responses pass through the array's response phase, so a generator entry can decorate them (same-status only; drained bodies are rebuilt; a throwing entry is logged and the response returned undecorated). A middleware that must answer unauthenticated requests wraps around `withSupabase` instead — see [Placement](#placement).

### createSupabaseContext

Expand Down Expand Up @@ -392,6 +394,68 @@ Defaults to the `SUPABASE_DB_URL` environment variable.

---

## @supabase/server/oauth-protected-resource

### withOAuthProtectedResource

```ts
const withOAuthProtectedResource: Middleware<
'oauthProtectedResource',
OAuthProtectedResourceConfig | undefined,
Record<never, never>,
OAuthProtectedResourceContribution
>
```

Wraps a handler with OAuth 2.1 Protected Resource behavior (RFC 9728):

- Serves the Protected Resource Metadata document at `GET {resource}/oauth-protected-resource`, including a permissive preflight for that route that allows `mcp-protocol-version`
- Enriches a downstream `401` with `WWW-Authenticate: Bearer resource_metadata="…"` unless the response already carries one
- Passes every other request through unchanged

Contributes `ctx.oauthProtectedResource` (the resolved metadata URL) downstream. Zero-config on Supabase Edge Functions; elsewhere `resourceServer` is required and `authorizationServer` falls back to `SUPABASE_URL`-derived values (each throws an `EnvError` when unresolvable).

### Placement

Discovery and preflight are answered from the request phase, before any authentication, so the middleware wraps **around** `withSupabase` — outside the auth gate:

```ts
withOAuthProtectedResource(config, withSupabase({ auth: 'user' }, handler))
```

or, as a flat pipeline:

```ts
pipeline(
[withOAuthProtectedResource(config)],
withSupabase({ auth: 'user' }, handler),
)
```

Inside `withSupabase`'s `middleware` array the auth gate runs first and answers discovery and preflight itself, so only part of the behavior works there:

| Behavior | Wrap / `pipeline` | Inside `middleware: [...]` |
| ----------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------- |
| `WWW-Authenticate` on `401` responses | Yes | Yes |
| Discovery (`GET …/oauth-protected-resource`) | Yes | No — the auth gate returns `401 MISSING_CREDENTIALS` first |
| Metadata-route preflight `mcp-protocol-version` | Yes | No — the generic preflight answers first; workaround: add the header via `cors: { headers }` |
| Authenticated requests | Yes | Yes |

Array placement emits a once-per-process `console.warn` naming the wrap form.

### OAuthProtectedResourceConfig

```ts
interface OAuthProtectedResourceConfig {
resourceServer?: UrlOption
authorizationServer?: UrlOption
}
```

Both options accept a fixed string or a function of the request. `fromSupabaseUrl(url)` builds an `authorizationServer` for a specific project.

---

## Types

### AuthMode
Expand Down
11 changes: 11 additions & 0 deletions src/core/composition-marker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Marks a context assembled by `withSupabase` for the entries of its
* `middleware` array. A middleware that behaves differently inside that
* composition (a placement warning, for example) tests for this key instead
* of duck-typing on context key names, which an unrelated upstream
* middleware could collide with. Symbol keys survive the engine's context
* spreads.
*
* @internal
*/
export const withSupabaseCtxMarker = Symbol('withSupabase.middlewareArray')
33 changes: 32 additions & 1 deletion src/oauth-protected-resource/with-oauth-protected-resource.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineMiddleware } from '@supabase/middleware'
import type { Middleware } from '@supabase/middleware'

import { withSupabaseCtxMarker } from '../core/composition-marker.js'
import { resourceMetadataResponse } from './responses.js'
import { getAuthUrl, getResourceMetadataUrl, getResourceUrl } from './url.js'
import type { UrlOption } from './url.js'
Expand Down Expand Up @@ -42,6 +43,18 @@ export interface OAuthProtectedResourceConfig {
authorizationServer?: UrlOption
}

let warnedArrayPlacement = false

/**
* Test-only helper to reset the one-shot array-placement warning latch so
* each test can independently observe the warning.
*
* @internal
*/
export function _resetOAuthPlacementWarned(): void {
warnedArrayPlacement = false
}

/**
* Wraps a request handler with OAuth 2.1 Protected Resource behavior (RFC 9728).
*
Expand All @@ -66,6 +79,13 @@ export interface OAuthProtectedResourceConfig {
* handler's `ctx` when the outermost call is anchored with
* `satisfies FetchHandler` — see `withSupabase`'s type note.
*
* Compose the wrap form shown below, or its flat spelling —
* `pipeline([withOAuthProtectedResource(config)], withSupabase(config, handler))`
* — which folds to the same composition. Inside `withSupabase`'s `middleware`
* array the auth gate answers discovery and preflight requests before this
* middleware sees them; only the `WWW-Authenticate` enrichment survives
* there, and a runtime warning is emitted.
*
* @category Middleware
*
* @example Supabase Edge Functions — zero config
Expand Down Expand Up @@ -122,7 +142,18 @@ export const withOAuthProtectedResource: Middleware<
>({
key: 'oauthProtectedResource',
run: (config) =>
async function* (req) {
async function* (req, ctx) {
// The marker identifies a context assembled by `withSupabase` for its
// post-auth array, where the auth gate answers discovery and preflight
// requests before this middleware can serve them. The supported
// placement wraps `withSupabase`.
if (!warnedArrayPlacement && withSupabaseCtxMarker in ctx) {
warnedArrayPlacement = true
console.warn(
'withOAuthProtectedResource is inside a withSupabase middleware array, where OAuth discovery and CORS preflight cannot be served. Wrap it around withSupabase instead: withOAuthProtectedResource(config, withSupabase(config, handler)).',
)
}

const url = new URL(req.url)
// The metadata document lives at `{resource}/oauth-protected-resource`.
// Matching on the suffix keeps this working wherever the endpoint is
Expand Down
Loading
Loading