From 56a3b0f323ef55d75a8c22a821e84d1ae26b2d45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Barroso?= Date: Wed, 26 Aug 2026 15:25:03 +0200 Subject: [PATCH] feat: `withOAuthProtectedResource` url config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the middleware work off Supabase Edge Functions. Previously it hardcoded the Edge Functions proxy shape into its URL derivation, which is wrong anywhere else, and took no configuration to correct it. Adds `resourceServer` and `authorizationServer`, each a fixed string or a function of the request, plus a `fromSupabaseUrl` adapter. Edge Functions stays zero-config; off it, an unset option throws rather than advertising a request-derived guess. `authorizationServer` falls back to `SUPABASE_PUBLIC_URL` then `SUPABASE_URL` first, both rungs below the Edge derivation so an internal gateway host can never displace the origin the client used. Also uses `SUPABASE_FUNCTION_SLUG` for a canonical `/functions/v1/{slug}`, fixes three header-parsing bugs (comma-joined value, double port, case-sensitive proto), and makes the advertised `resource` always equal the URL the client called, as RFC 9728 §3.3 requires. --- README.md | 2 +- docs/environment-variables.md | 30 +- docs/error-handling.md | 39 +- src/errors.ts | 26 +- src/index.ts | 8 + src/oauth-protected-resource/index.ts | 6 + src/oauth-protected-resource/paths.ts | 24 + src/oauth-protected-resource/runtime.ts | 19 + src/oauth-protected-resource/url.ts | 201 ++++++- .../with-oauth-protected-resource.test.ts | 560 +++++++++++++++++- .../with-oauth-protected-resource.ts | 114 +++- src/with-supabase.test.ts | 1 + 12 files changed, 944 insertions(+), 86 deletions(-) create mode 100644 src/oauth-protected-resource/paths.ts create mode 100644 src/oauth-protected-resource/runtime.ts diff --git a/README.md b/README.md index 7400b2e..c697756 100644 --- a/README.md +++ b/README.md @@ -534,7 +534,7 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like | `@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`, `resourceMetadataResponse`, `unauthorizedResponse` | +| `@supabase/server/oauth-protected-resource` | `withOAuthProtectedResource`, `fromSupabaseUrl`, `resourceMetadataResponse`, `unauthorizedResponse` | | `@supabase/server/peer/supabase-js` | Re-exported `supabase-js` types (`SupabaseClient`, `PostgrestError`, …) | ## Documentation diff --git a/docs/environment-variables.md b/docs/environment-variables.md index ffcd6d5..aae14bd 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -2,25 +2,27 @@ On Supabase Platform and Local Development (CLI), all variables are auto-provisioned — no configuration needed -| Variable | Format | Description | Available in | -| --------------------------- | ---------------------------------- | -------------------------------------------- | --------------------------------- | -| `SUPABASE_URL` | `https://.supabase.co` | Your Supabase project URL | All | -| `SUPABASE_PUBLISHABLE_KEYS` | `{"default":"sb_publishable_..."}` | Named publishable keys as JSON object | All | -| `SUPABASE_SECRET_KEYS` | `{"default":"sb_secret_..."}` | Named secret keys as JSON object | All | -| `SUPABASE_JWKS` | `{"keys":[...]}` or `[...]` | Inline JSON Web Key Set for JWT verification | All | -| `SUPABASE_PUBLISHABLE_KEY` | `sb_publishable_...` | Single publishable key (fallback) | Self-hosted, if manually exported | -| `SUPABASE_SECRET_KEY` | `sb_secret_...` | Single secret key (fallback) | Self-hosted, if manually exported | +| Variable | Format | Description | Available in | +| --------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| `SUPABASE_URL` | `https://.supabase.co` | Your Supabase project URL | All | +| `SUPABASE_PUBLISHABLE_KEYS` | `{"default":"sb_publishable_..."}` | Named publishable keys as JSON object | All | +| `SUPABASE_SECRET_KEYS` | `{"default":"sb_secret_..."}` | Named secret keys as JSON object | All | +| `SUPABASE_JWKS` | `{"keys":[...]}` or `[...]` | Inline JSON Web Key Set for JWT verification | All | +| `SUPABASE_PUBLISHABLE_KEY` | `sb_publishable_...` | Single publishable key (fallback) | Self-hosted, if manually exported | +| `SUPABASE_SECRET_KEY` | `sb_secret_...` | Single secret key (fallback) | Self-hosted, if manually exported | +| `SUPABASE_PUBLIC_URL` | `https://.supabase.co` | Externally-visible URL of the Supabase stack. Preferred origin for OAuth protected resource metadata | Self-hosted | +| `SUPABASE_FUNCTION_SLUG` | `my-function` | The running function's slug. Yields a canonical `/functions/v1/{slug}` resource identifier with no path parsing | Edge Functions | ## Non-Supabase environments (Node.js, Bun, Cloudflare, self-hosted) Set these based on which auth modes your app uses: -| 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) | +| Variable | Required when | +| -------------------------------------- | ----------------------------------------------------------------------------------- | +| `SUPABASE_URL` | Always. Also the last-resort `authorizationServer` for `withOAuthProtectedResource` | +| `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 diff --git a/docs/error-handling.md b/docs/error-handling.md index 7b7190d..646512c 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -8,14 +8,16 @@ The SDK has two error classes, both with `status` (HTTP code) and `code` (machin Thrown when a required environment variable is missing or malformed. Always `status: 500` — these are server configuration issues, not client errors. -| Code | Meaning | -| --------------------------------- | -------------------------------------------------------------- | -| `MISSING_SUPABASE_URL` | `SUPABASE_URL` is not set | -| `MISSING_PUBLISHABLE_KEY` | Named publishable key not found in `SUPABASE_PUBLISHABLE_KEYS` | -| `MISSING_DEFAULT_PUBLISHABLE_KEY` | No default publishable key found | -| `MISSING_SECRET_KEY` | Named secret key not found in `SUPABASE_SECRET_KEYS` | -| `MISSING_DEFAULT_SECRET_KEY` | No default secret key found | -| `ENV_ERROR` | Generic environment error | +| Code | Meaning | +| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `MISSING_SUPABASE_URL` | `SUPABASE_URL` is not set | +| `MISSING_PUBLISHABLE_KEY` | Named publishable key not found in `SUPABASE_PUBLISHABLE_KEYS` | +| `MISSING_DEFAULT_PUBLISHABLE_KEY` | No default publishable key found | +| `MISSING_SECRET_KEY` | Named secret key not found in `SUPABASE_SECRET_KEYS` | +| `MISSING_DEFAULT_SECRET_KEY` | No default secret key found | +| `MISSING_RESOURCE_SERVER` | `withOAuthProtectedResource` has no `resourceServer` and is not on Edge Functions | +| `MISSING_AUTHORIZATION_SERVER` | `withOAuthProtectedResource` has no `authorizationServer`, and neither `SUPABASE_PUBLIC_URL` nor `SUPABASE_URL` is set | +| `ENV_ERROR` | Generic environment error | ### AuthError @@ -31,16 +33,17 @@ Thrown when authentication or authorization fails. Status is `401` for invalid c Different layers of the SDK handle errors differently. Understanding which pattern each function uses prevents surprises. -| Function | Pattern | What happens on error | -| ------------------------- | ------------- | ------------------------------------------------------------------------ | -| `withSupabase()` | Auto-response | Returns `Response.json({ message, code }, { status })` with CORS headers | -| `createSupabaseContext()` | Result tuple | Returns `{ data: null, error: AuthError }` | -| `verifyAuth()` | Result tuple | Returns `{ data: null, error: AuthError }` | -| `verifyCredentials()` | Result tuple | Returns `{ data: null, error: AuthError }` | -| `resolveEnv()` | Result tuple | Returns `{ data: null, error: EnvError }` | -| `createContextClient()` | **Throws** | Throws `EnvError` | -| `createAdminClient()` | **Throws** | Throws `EnvError` | -| Hono `withSupabase()` | HTTPException | Throws `HTTPException` with `cause: AuthError` | +| Function | Pattern | What happens on error | +| ------------------------------ | ------------- | ------------------------------------------------------------------------ | +| `withSupabase()` | Auto-response | Returns `Response.json({ message, code }, { status })` with CORS headers | +| `createSupabaseContext()` | Result tuple | Returns `{ data: null, error: AuthError }` | +| `verifyAuth()` | Result tuple | Returns `{ data: null, error: AuthError }` | +| `verifyCredentials()` | Result tuple | Returns `{ data: null, error: AuthError }` | +| `resolveEnv()` | Result tuple | Returns `{ data: null, error: EnvError }` | +| `createContextClient()` | **Throws** | Throws `EnvError` | +| `createAdminClient()` | **Throws** | Throws `EnvError` | +| `withOAuthProtectedResource()` | **Throws** | Throws `EnvError` when required off Edge Functions and unconfigured | +| Hono `withSupabase()` | HTTPException | Throws `HTTPException` with `cause: AuthError` | The two client factory functions (`createContextClient`, `createAdminClient`) are the only ones that throw. Everything else returns a result tuple `{ data, error }`. diff --git a/src/errors.ts b/src/errors.ts index 7da9f07..68df3e1 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -28,7 +28,8 @@ export class EnvError extends Error { * * @see {@link EnvGenericError}, {@link MissingSupabaseURLError}, * {@link MissingPublishableKeyError}, {@link MissingDefaultPublishableKeyError}, - * {@link MissingSecretKeyError}, {@link MissingDefaultSecretKeyError} + * {@link MissingSecretKeyError}, {@link MissingDefaultSecretKeyError}, + * {@link MissingResourceServerError}, {@link MissingAuthorizationServerError} */ readonly code: string @@ -76,6 +77,18 @@ export const MissingSecretKeyError = 'MISSING_SECRET_KEY' */ export const MissingDefaultSecretKeyError = 'MISSING_DEFAULT_SECRET_KEY' +/** + * `withOAuthProtectedResource` has no `resourceServer` and cannot derive one. + * @category Errors + */ +export const MissingResourceServerError = 'MISSING_RESOURCE_SERVER' + +/** + * `withOAuthProtectedResource` has no `authorizationServer` and cannot derive one. + * @category Errors + */ +export const MissingAuthorizationServerError = 'MISSING_AUTHORIZATION_SERVER' + const EnvErrorMap = { [MissingSupabaseURLError]: (): EnvError => new EnvError( @@ -103,6 +116,17 @@ const EnvErrorMap = { 'No default publishable key found. Set SUPABASE_PUBLISHABLE_KEY or include a "default" entry in SUPABASE_PUBLISHABLE_KEYS.', MissingDefaultPublishableKeyError, ), + + [MissingResourceServerError]: (): EnvError => + new EnvError( + "resourceServer is required outside Supabase Edge Functions. Pass it to withOAuthProtectedResource(), e.g. { resourceServer: (req) => new URL(req.url).origin + '/api/mcp' }.", + MissingResourceServerError, + ), + [MissingAuthorizationServerError]: (): EnvError => + new EnvError( + "authorizationServer is required outside Supabase Edge Functions. Pass it to withOAuthProtectedResource() — use fromSupabaseUrl('https://.supabase.co') for Supabase Auth — or set SUPABASE_URL.", + MissingAuthorizationServerError, + ), } /** diff --git a/src/index.ts b/src/index.ts index 7f9ea9d..006479d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,6 +88,12 @@ export { withSupabase } from './with-supabase.js' export { createSupabaseContext } from './create-supabase-context.js' export { withOAuthProtectedResource } from './oauth-protected-resource/with-oauth-protected-resource.js' +export type { + OAuthProtectedResourceConfig, + OAuthProtectedResourceContribution, +} from './oauth-protected-resource/with-oauth-protected-resource.js' +export { fromSupabaseUrl } from './oauth-protected-resource/url.js' +export type { UrlOption } from './oauth-protected-resource/url.js' export { resourceMetadataResponse, unauthorizedResponse, @@ -122,9 +128,11 @@ export { EnvGenericError, Errors, InvalidCredentialsError, + MissingAuthorizationServerError, MissingDefaultPublishableKeyError, MissingDefaultSecretKeyError, MissingPublishableKeyError, + MissingResourceServerError, MissingSecretKeyError, MissingSupabaseURLError, UnsupportedRoleError, diff --git a/src/oauth-protected-resource/index.ts b/src/oauth-protected-resource/index.ts index ac55052..336bb31 100644 --- a/src/oauth-protected-resource/index.ts +++ b/src/oauth-protected-resource/index.ts @@ -5,6 +5,12 @@ */ export { withOAuthProtectedResource } from './with-oauth-protected-resource.js' +export type { + OAuthProtectedResourceConfig, + OAuthProtectedResourceContribution, +} from './with-oauth-protected-resource.js' +export { fromSupabaseUrl } from './url.js' +export type { UrlOption } from './url.js' export { resourceMetadataResponse, unauthorizedResponse } from './responses.js' export type { ResourceMetadataOptions, diff --git a/src/oauth-protected-resource/paths.ts b/src/oauth-protected-resource/paths.ts new file mode 100644 index 0000000..8e32759 --- /dev/null +++ b/src/oauth-protected-resource/paths.ts @@ -0,0 +1,24 @@ +/** + * Supabase platform URL path prefixes used when reconstructing a resource's + * external URLs. Kept in one place so the magic strings have a single source of + * truth across the URL derivation and the middleware. + * + * @module + */ + +/** + * Path prefix the Supabase Edge Functions platform proxy strips from the request + * before invoking the function, and which the Edge Functions default restores + * when reconstructing the resource's external path. + * + * @internal + */ +export const EDGE_FUNCTIONS_PATH_PREFIX = '/functions/v1' + +/** + * Path prefix of the Supabase Auth API, appended to the project's base URL to + * form the OAuth authorization server URL advertised in the resource metadata. + * + * @internal + */ +export const AUTH_PATH_PREFIX = '/auth/v1' diff --git a/src/oauth-protected-resource/runtime.ts b/src/oauth-protected-resource/runtime.ts new file mode 100644 index 0000000..351e6a5 --- /dev/null +++ b/src/oauth-protected-resource/runtime.ts @@ -0,0 +1,19 @@ +import { getEnv, runtimeName } from '@supabase/middleware' + +/** + * Whether this request is being served by Supabase Edge Functions. + * + * Gates the request-derived URL defaults. The Edge Functions gateway sets + * `X-Forwarded-*` to the project's externally-visible origin and strips the + * `/functions/v1` prefix; off platform those headers describe the app's own + * origin, which is unrelated to the Supabase project. + * + * True when `SUPABASE_FUNCTION_SLUG` is set, or when the host runtime is Deno. + * A plain Deno server or Deno Deploy therefore reads as Edge Functions. + * + * @internal + */ +export function isEdgeFunctions(): boolean { + if (getEnv('SUPABASE_FUNCTION_SLUG')) return true + return runtimeName === 'deno' +} diff --git a/src/oauth-protected-resource/url.ts b/src/oauth-protected-resource/url.ts index beac25e..988642d 100644 --- a/src/oauth-protected-resource/url.ts +++ b/src/oauth-protected-resource/url.ts @@ -1,63 +1,212 @@ +import { getEnv } from '@supabase/middleware' + +import { + Errors, + MissingAuthorizationServerError, + MissingResourceServerError, +} from '../errors.js' +import { AUTH_PATH_PREFIX, EDGE_FUNCTIONS_PATH_PREFIX } from './paths.js' +import { isEdgeFunctions } from './runtime.js' + +/** + * A configured URL: either a fixed value, or derived per request. + * + * @category Types + */ +export type UrlOption = string | ((req: Request) => string) + +/** + * Trailing path segment where a resource serves its OAuth Protected Resource + * Metadata document (RFC 9728), as a stripping pattern. + * + * @internal + */ +const METADATA_SUFFIX_PATTERN = /\/oauth-protected-resource$/ + +/** Strips a trailing slash so URL concatenation doesn't produce `//`. @internal */ +export function trimTrailingSlash(value: string): string { + return value.endsWith('/') ? value.slice(0, -1) : value +} + /** - * Constructs the external-facing base URL from the request, - * considering the `X-Forwarded-*` headers set by the Supabase Edge Functions proxy. + * The externally-visible origin of a Supabase Edge Function. + * + * `SUPABASE_PUBLIC_URL` wins when set. Otherwise the origin is assembled from + * `X-Forwarded-Host`, `-Proto` and `-Port`, each falling back to the request + * URL: + * + * - The port comes from `X-Forwarded-Port` only when the host does not already + * carry one. + * - A port that is standard for the scheme (443 on https, 80 on http) is + * omitted. + * - The scheme is lowercased. + * - Header values are used as-is, never comma-split. + * + * `SUPABASE_URL` is not consulted — see {@link defaultAuthorizationServer}. * * @internal */ -export function getBaseUrl(req: Request): string { +function edgeOrigin(req: Request): string { + const publicUrl = getEnv('SUPABASE_PUBLIC_URL') + if (publicUrl) return trimTrailingSlash(publicUrl) + const url = new URL(req.url) const host = req.headers.get('X-Forwarded-Host') ?? url.hostname - const proto = + const proto = ( req.headers.get('X-Forwarded-Proto') ?? url.protocol.replace(':', '') - const port = req.headers.get('X-Forwarded-Port') ?? url.port + ).toLowerCase() + const port = host.includes(':') + ? '' + : (req.headers.get('X-Forwarded-Port') ?? url.port) const isStandardPort = (proto === 'https' && port === '443') || (proto === 'http' && port === '80') - const portSuffix = port && !isStandardPort ? `:${port}` : '' + return `${proto}://${host}${port && !isStandardPort ? `:${port}` : ''}` +} - return `${proto}://${host}${portSuffix}` +/** + * The external path of a Supabase Edge Function. + * + * With `SUPABASE_FUNCTION_SLUG` set, `/functions/v1/{slug}` — canonical, and + * independent of the path the request arrived on. Without it, the received path + * minus the metadata suffix, with the `/functions/v1` prefix restored (every + * gateway fronting Edge Functions strips that prefix). + * + * The two forms differ only for a request at a sub-path of the function: the + * canonical one reports the function, the reconstructed one the sub-path. + * + * @internal + */ +function edgeResourcePath(req: Request): string { + const slug = getEnv('SUPABASE_FUNCTION_SLUG') + if (slug) return `${EDGE_FUNCTIONS_PATH_PREFIX}/${slug}` + + const received = new URL(req.url).pathname.replace( + METADATA_SUFFIX_PATTERN, + '', + ) + return `${EDGE_FUNCTIONS_PATH_PREFIX}${received}` } /** - * Detects the edge function name from the request path. - * The Supabase proxy strips `/functions/v1` but keeps the function name - * as the first path segment (e.g. `/my-fn/...` -> function name is `"my-fn"`). + * The resource identifier to advertise when none was configured: on Edge + * Functions, the external origin plus the external path. + * + * There is no environment fallback — `SUPABASE_URL` names the Supabase project, + * not this endpoint — so off Edge Functions it throws. + * + * @throws {EnvError} `MISSING_RESOURCE_SERVER` off Edge Functions. * * @internal */ -export function inferFunctionName(req: Request): string | undefined { - const url = new URL(req.url) - const segments = url.pathname.split('/').filter(Boolean) - return segments[0] +export function defaultResourceServer(req: Request): string { + if (!isEdgeFunctions()) throw Errors[MissingResourceServerError]() + return `${edgeOrigin(req)}${edgeResourcePath(req)}` +} + +/** + * The Supabase Auth issuer to advertise when none was configured. + * + * On Edge Functions, the request's external origin plus the Auth path. Off Edge + * Functions the app's origin is unrelated to the project's, so the environment + * answers instead: `SUPABASE_PUBLIC_URL`, then `SUPABASE_URL`, each with the + * Auth path appended. + * + * Both environment rungs sit below the Edge derivation, so on Edge Functions + * neither can displace the origin the client used. + * + * @throws {EnvError} `MISSING_AUTHORIZATION_SERVER` off Edge Functions with + * neither variable set. + * + * @internal + */ +export function defaultAuthorizationServer(req: Request): string { + if (isEdgeFunctions()) return `${edgeOrigin(req)}${AUTH_PATH_PREFIX}` + + const publicUrl = getEnv('SUPABASE_PUBLIC_URL') + if (publicUrl) return fromSupabaseUrl(publicUrl) + + const supabaseUrl = getEnv('SUPABASE_URL') + if (supabaseUrl) return fromSupabaseUrl(supabaseUrl) + + throw Errors[MissingAuthorizationServerError]() +} + +/** + * Points `authorizationServer` at a Supabase project's Auth issuer. + * + * Use this off Supabase Edge Functions, where the app's own origin is unrelated + * to the Supabase project's, so the issuer cannot be derived from the request. + * + * @param supabaseUrl - The project URL, e.g. `https://.supabase.co` (the + * same value passed to `createClient()`). + * + * @category Middleware + * + * @example + * ```ts + * import { withOAuthProtectedResource, fromSupabaseUrl } from '@supabase/server' + * + * withOAuthProtectedResource( + * { + * resourceServer: (req) => new URL(req.url).origin + '/api/mcp', + * authorizationServer: fromSupabaseUrl('https://abc123.supabase.co'), + * }, + * handler, + * ) + * ``` + */ +export function fromSupabaseUrl(supabaseUrl: string): string { + const base = trimTrailingSlash(supabaseUrl) + // Tolerate a value that already carries the Auth path. + return base.endsWith(AUTH_PATH_PREFIX) ? base : `${base}${AUTH_PATH_PREFIX}` +} + +/** Resolves a {@link UrlOption} against a request. @internal */ +export function resolveUrlOption( + option: UrlOption | undefined, + req: Request, + fallback: (req: Request) => string, +): string { + const value = typeof option === 'function' ? option(req) : option + return trimTrailingSlash(value ?? fallback(req)) } /** - * Constructs the external-facing URL of the protected resource (the edge function itself). - * Restores the `/functions/v1` prefix stripped by the Supabase proxy. + * Constructs the external-facing URL of the protected resource, falling back to + * {@link defaultResourceServer} when `resourceServer` is unset. * * @internal */ -export function getResourceUrl(req: Request): string { - const fn = inferFunctionName(req) ?? '' - return `${getBaseUrl(req)}/functions/v1/${fn}` +export function getResourceUrl( + req: Request, + resourceServer?: UrlOption, +): string { + return resolveUrlOption(resourceServer, req, defaultResourceServer) } /** - * Constructs the external-facing URL for the OAuth Protected Resource - * Metadata endpoint (RFC 9728). + * Constructs the external-facing URL for the OAuth Protected Resource Metadata + * endpoint (RFC 9728). * * @internal */ -export function getResourceMetadataUrl(req: Request): string { - return `${getResourceUrl(req)}/oauth-protected-resource` +export function getResourceMetadataUrl( + req: Request, + resourceServer?: UrlOption, +): string { + return `${getResourceUrl(req, resourceServer)}/oauth-protected-resource` } /** - * Constructs the external-facing Supabase Auth URL. + * Constructs the authorization server (Supabase Auth issuer) to advertise. * * @internal */ -export function getAuthUrl(req: Request): string { - return `${getBaseUrl(req)}/auth/v1` +export function getAuthUrl( + req: Request, + authorizationServer?: UrlOption, +): string { + return resolveUrlOption(authorizationServer, req, defaultAuthorizationServer) } diff --git a/src/oauth-protected-resource/with-oauth-protected-resource.test.ts b/src/oauth-protected-resource/with-oauth-protected-resource.test.ts index bcc81fc..3f4f6e4 100644 --- a/src/oauth-protected-resource/with-oauth-protected-resource.test.ts +++ b/src/oauth-protected-resource/with-oauth-protected-resource.test.ts @@ -1,8 +1,31 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { pipeline } from '@supabase/middleware' +import { + EnvError, + MissingAuthorizationServerError, + MissingResourceServerError, +} from '../errors.js' import { resourceMetadataResponse, unauthorizedResponse } from './responses.js' +import { isEdgeFunctions } from './runtime.js' +import { fromSupabaseUrl } from './url.js' import { withOAuthProtectedResource } from './with-oauth-protected-resource.js' +// Defaults to Edge Functions; `offEdgeFunctions()` flips it per test. Mocking +// the predicate rather than setting `SUPABASE_FUNCTION_SLUG` keeps the slug out +// of `edgeResourcePath`, whose no-slug branch the path tests exercise. +vi.mock('./runtime.js', () => ({ isEdgeFunctions: vi.fn(() => true) })) + +const edgeFunctionsCheck = vi.mocked(isEdgeFunctions) + +function offEdgeFunctions() { + edgeFunctionsCheck.mockReturnValue(false) +} + +afterEach(() => { + edgeFunctionsCheck.mockReturnValue(true) +}) + const req = (method: string, path: string, headers?: Record) => new Request(`http://localhost${path}`, { method, headers }) @@ -10,6 +33,24 @@ const passthrough = async () => new Response('ok', { status: 200 }) const returns401 = async () => new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) +/** Set env vars for one test, restoring prior values afterward. */ +const testEnv = ( + globalThis as { process?: { env: Record } } +).process!.env +const envCleanup: Array<() => void> = [] +function setEnv(name: string, value: string | undefined) { + const prior = testEnv[name] + if (value === undefined) delete testEnv[name] + else testEnv[name] = value + envCleanup.push(() => { + if (prior === undefined) delete testEnv[name] + else testEnv[name] = prior + }) +} +afterEach(() => { + while (envCleanup.length) envCleanup.pop()!() +}) + describe('withOAuthProtectedResource - metadata route', () => { it('serves RFC 9728 JSON on GET /fn/oauth-protected-resource', async () => { const res = await withOAuthProtectedResource(passthrough)( @@ -81,7 +122,7 @@ describe('withOAuthProtectedResource - path routing', () => { expect(res.status).toBe(200) }) - it('infers function name from first path segment', async () => { + it('includes the function path segment in the advertised resource', async () => { const res = await withOAuthProtectedResource(passthrough)( req('GET', '/my-function/oauth-protected-resource'), ) @@ -208,6 +249,327 @@ describe('unauthorizedResponse', () => { }) }) +describe('withOAuthProtectedResource - resourceServer / authorizationServer', () => { + const offEdge = { + resourceServer: (r: Request) => `${new URL(r.url).origin}/api/mcp`, + authorizationServer: fromSupabaseUrl('https://ref.supabase.co'), + } + + it('a request-derived resourceServer ignores X-Forwarded-* entirely', async () => { + const res = await withOAuthProtectedResource( + offEdge, + passthrough, + )( + req('GET', '/api/mcp/oauth-protected-resource', { + 'X-Forwarded-Host': 'evil.example.com', + 'X-Forwarded-Proto': 'https', + }), + ) + const body = await res.json() + expect(body.resource).toBe('http://localhost/api/mcp') + expect(body.resource).not.toContain('evil.example.com') + expect(body.resource).not.toContain('/functions/v1') + }) + + it('accepts a static string resourceServer', async () => { + const res = await withOAuthProtectedResource( + { resourceServer: 'https://api.example.com/mcp', ...{} }, + passthrough, + )(req('GET', '/api/mcp/oauth-protected-resource')) + const body = await res.json() + expect(body.resource).toBe('https://api.example.com/mcp') + }) + + it('accepts any non-Supabase authorization server', async () => { + const res = await withOAuthProtectedResource( + { + resourceServer: 'https://api.example.com/mcp', + authorizationServer: 'https://example.clerk.accounts.dev', + }, + passthrough, + )(req('GET', '/api/mcp/oauth-protected-resource')) + const body = await res.json() + expect(body.authorization_servers).toEqual([ + 'https://example.clerk.accounts.dev', + ]) + }) + + it('fromSupabaseUrl appends the Auth path, and tolerates it already being there', () => { + expect(fromSupabaseUrl('https://ref.supabase.co')).toBe( + 'https://ref.supabase.co/auth/v1', + ) + expect(fromSupabaseUrl('https://ref.supabase.co/')).toBe( + 'https://ref.supabase.co/auth/v1', + ) + expect(fromSupabaseUrl('https://ref.supabase.co/auth/v1')).toBe( + 'https://ref.supabase.co/auth/v1', + ) + }) + + it('nested and pipeline forms produce identical metadata (Config union guard)', async () => { + const nested = await withOAuthProtectedResource( + offEdge, + passthrough, + )(req('GET', '/api/mcp/oauth-protected-resource')) + const piped = await pipeline( + [withOAuthProtectedResource(offEdge)], + passthrough, + )(req('GET', '/api/mcp/oauth-protected-resource')) + expect(await nested.json()).toEqual(await piped.json()) + }) + + it('config is optional — all three call forms still work', async () => { + const res = await withOAuthProtectedResource(passthrough)( + req('GET', '/my-fn/oauth-protected-resource'), + ) + expect(res.status).toBe(200) + const piped = await pipeline( + [withOAuthProtectedResource()], + passthrough, + )(req('GET', '/my-fn/oauth-protected-resource')) + expect(piped.status).toBe(200) + }) +}) + +describe('withOAuthProtectedResource - metadata URL matches the URL the client used', () => { + // RFC 9728 §3.3: when metadata is fetched from a `WWW-Authenticate` + // `resource_metadata` URL, the returned `resource` MUST be identical to the + // URL the client used, or the client MUST NOT use the response. So + // `resource + '/oauth-protected-resource'` has to equal the request URL for + // every path the metadata route answers on. + const invariantHolds = async (path: string) => { + const res = await withOAuthProtectedResource(passthrough)( + req('GET', path, { 'X-Forwarded-Host': 'app.example.com' }), + ) + const body = await res.json() + return `${body.resource}/oauth-protected-resource` + } + + it('holds at the function root (edge default)', async () => { + expect(await invariantHolds('/my-fn/oauth-protected-resource')).toBe( + 'http://app.example.com/functions/v1/my-fn/oauth-protected-resource', + ) + }) + + it('holds at a nested path (edge default)', async () => { + // Regression: first-path-segment inference reported the top-level function + // here, so resource + suffix != the URL the client used. + expect(await invariantHolds('/my-fn/nested/oauth-protected-resource')).toBe( + 'http://app.example.com/functions/v1/my-fn/nested/oauth-protected-resource', + ) + }) + + it('holds at a nested path with a request-derived resourceServer', async () => { + const res = await withOAuthProtectedResource( + { + resourceServer: (r) => + new URL(r.url).origin + + new URL(r.url).pathname.replace(/\/oauth-protected-resource$/, ''), + }, + passthrough, + )(req('GET', '/api/deep/mcp/oauth-protected-resource')) + const body = await res.json() + expect(`${body.resource}/oauth-protected-resource`).toBe( + 'http://localhost/api/deep/mcp/oauth-protected-resource', + ) + }) + + it('SUPABASE_FUNCTION_SLUG yields a canonical identifier, whatever the path', async () => { + setEnv('SUPABASE_PUBLIC_URL', undefined) + setEnv('SUPABASE_FUNCTION_SLUG', 'my-fn') + const canonical = + 'http://app.example.com/functions/v1/my-fn/oauth-protected-resource' + // Stripped prefix, unstripped prefix, and a sub-path all agree. + expect(await invariantHolds('/my-fn/oauth-protected-resource')).toBe( + canonical, + ) + expect( + await invariantHolds('/functions/v1/my-fn/oauth-protected-resource'), + ).toBe(canonical) + expect(await invariantHolds('/my-fn/nested/oauth-protected-resource')).toBe( + canonical, + ) + }) + + it('without the slug, a sub-path reports the sub-path', async () => { + // The one real behavioral difference: path reconstruction reports whatever + // path the request arrived on, where the canonical form reports the function. + // Does not arise for MCP (Streamable HTTP is a single endpoint). + setEnv('SUPABASE_PUBLIC_URL', undefined) + setEnv('SUPABASE_FUNCTION_SLUG', undefined) + expect(await invariantHolds('/my-fn/nested/oauth-protected-resource')).toBe( + 'http://app.example.com/functions/v1/my-fn/nested/oauth-protected-resource', + ) + }) + + it('holds for a function actually named "functions"', async () => { + // Every gateway strips `/functions/v1`, so the prefix is restored + // unconditionally. That keeps this case correct: sniffing for an existing + // prefix would mistake the function's own name for the stripped prefix and + // advertise `/functions/v1`, which the client would reject. + setEnv('SUPABASE_PUBLIC_URL', undefined) + expect(await invariantHolds('/functions/v1/oauth-protected-resource')).toBe( + 'http://app.example.com/functions/v1/functions/v1/oauth-protected-resource', + ) + }) +}) + +describe('withOAuthProtectedResource - authorization server resolution', () => { + it('explicit authorizationServer wins over the derived default', async () => { + const res = await withOAuthProtectedResource( + { authorizationServer: fromSupabaseUrl('https://explicit.supabase.co') }, + passthrough, + )(req('GET', '/my-fn/oauth-protected-resource')) + const body = await res.json() + expect(body.authorization_servers).toEqual([ + 'https://explicit.supabase.co/auth/v1', + ]) + }) + + it('SUPABASE_URL never overrides the Edge-derived origin (internal-hostname regression)', async () => { + // Self-hosted, SUPABASE_URL is the internal gateway host; on hosted with a + // custom domain it is pinned to the ref domain. Neither must ever leak into + // the advertised issuer — the forwarded headers are the source of truth. + setEnv('SUPABASE_URL', 'http://kong:8000') + setEnv('SUPABASE_PUBLIC_URL', undefined) + const res = await withOAuthProtectedResource(passthrough)( + req('GET', '/my-fn/oauth-protected-resource', { + 'X-Forwarded-Host': 'app.example.com', + 'X-Forwarded-Proto': 'https', + }), + ) + const body = await res.json() + expect(body.authorization_servers).toEqual([ + 'https://app.example.com/auth/v1', + ]) + expect(String(body.authorization_servers[0])).not.toContain('kong') + }) + + it('falls back to SUPABASE_URL off Edge Functions, where no origin can be derived', async () => { + offEdgeFunctions() + setEnv('SUPABASE_URL', 'https://ref.supabase.co') + setEnv('SUPABASE_PUBLIC_URL', undefined) + const res = await withOAuthProtectedResource( + { resourceServer: 'https://api.example.com/mcp' }, + passthrough, + )( + req('GET', '/api/mcp/oauth-protected-resource', { + 'X-Forwarded-Host': 'app.vercel.app', + 'X-Forwarded-Proto': 'https', + }), + ) + const body = await res.json() + expect(body.authorization_servers).toEqual([ + 'https://ref.supabase.co/auth/v1', + ]) + expect(String(body.authorization_servers[0])).not.toContain('vercel') + }) + + it('derives the issuer from SUPABASE_PUBLIC_URL when set', async () => { + setEnv('SUPABASE_PUBLIC_URL', 'https://public.example.com') + const res = await withOAuthProtectedResource(passthrough)( + req('GET', '/my-fn/oauth-protected-resource'), + ) + const body = await res.json() + expect(body.authorization_servers).toEqual([ + 'https://public.example.com/auth/v1', + ]) + }) + + it('supports a per-request authorizationServer function', async () => { + const res = await withOAuthProtectedResource( + { authorizationServer: (r) => `${new URL(r.url).origin}/auth/v1` }, + passthrough, + )(req('GET', '/my-fn/oauth-protected-resource')) + const body = await res.json() + expect(body.authorization_servers).toEqual(['http://localhost/auth/v1']) + }) +}) + +describe('edge default - SUPABASE_PUBLIC_URL + header bug fixes', () => { + it('SUPABASE_PUBLIC_URL takes precedence over X-Forwarded-* headers', async () => { + setEnv('SUPABASE_PUBLIC_URL', 'https://public.example.com') + const res = await withOAuthProtectedResource(passthrough)( + req('GET', '/my-fn/oauth-protected-resource', { + 'X-Forwarded-Host': 'header.example.com', + 'X-Forwarded-Proto': 'https', + }), + ) + const body = await res.json() + expect(body.resource).toBe('https://public.example.com/functions/v1/my-fn') + }) + + it('falls back to header inference when SUPABASE_PUBLIC_URL is unset', async () => { + setEnv('SUPABASE_PUBLIC_URL', undefined) + const res = await withOAuthProtectedResource(passthrough)( + req('GET', '/my-fn/oauth-protected-resource', { + 'X-Forwarded-Host': 'header.example.com', + 'X-Forwarded-Proto': 'https', + }), + ) + const body = await res.json() + expect(body.resource).toBe('https://header.example.com/functions/v1/my-fn') + }) + + it('uses X-Forwarded-Host as-is (no gateway comma-joins it)', async () => { + // Kong overwrites all three forwarded headers outright, the hosted relay + // sets a single value, and Envoy sets one value per route — so a comma-joined + // value means an unknown proxy. We use the raw value rather than guessing a + // hop; the resulting origin fails RFC 9728 §3.3 visibly. + setEnv('SUPABASE_PUBLIC_URL', undefined) + const res = await withOAuthProtectedResource(passthrough)( + req('GET', '/my-fn/oauth-protected-resource', { + 'X-Forwarded-Host': 'app.example.com', + 'X-Forwarded-Proto': 'https', + }), + ) + const body = await res.json() + expect(body.resource).toBe('https://app.example.com/functions/v1/my-fn') + }) + + it('keeps a separately-forwarded port (Kong sends a bare host)', async () => { + setEnv('SUPABASE_PUBLIC_URL', undefined) + const res = await withOAuthProtectedResource(passthrough)( + req('GET', '/my-fn/oauth-protected-resource', { + 'X-Forwarded-Host': '127.0.0.1', + 'X-Forwarded-Port': '54321', + 'X-Forwarded-Proto': 'http', + }), + ) + const body = await res.json() + expect(body.resource).toBe('http://127.0.0.1:54321/functions/v1/my-fn') + }) + + it('does not append a second port when the forwarded host already has one', async () => { + setEnv('SUPABASE_PUBLIC_URL', undefined) + const res = await withOAuthProtectedResource(passthrough)( + req('GET', '/my-fn/oauth-protected-resource', { + 'X-Forwarded-Host': 'app.example.com:8443', + 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Port': '443', + }), + ) + const body = await res.json() + expect(body.resource).toBe( + 'https://app.example.com:8443/functions/v1/my-fn', + ) + }) + + it('treats an uppercase X-Forwarded-Proto case-insensitively for standard-port handling', async () => { + setEnv('SUPABASE_PUBLIC_URL', undefined) + const res = await withOAuthProtectedResource(passthrough)( + req('GET', '/my-fn/oauth-protected-resource', { + 'X-Forwarded-Host': 'app.example.com', + 'X-Forwarded-Proto': 'HTTPS', + 'X-Forwarded-Port': '443', + }), + ) + const body = await res.json() + // 443 is standard for https → no port suffix, and proto lowercased. + expect(body.resource).toBe('https://app.example.com/functions/v1/my-fn') + }) +}) + describe('withOAuthProtectedResource - platform argument', () => { it('no longer forwards the raw platform argument — the inner handler receives ctx instead', async () => { let seen: unknown @@ -220,3 +582,197 @@ describe('withOAuthProtectedResource - platform argument', () => { expect(seen).not.toBe(env) }) }) + +describe('withOAuthProtectedResource - off-platform 401 discovery flow', () => { + const staticConfig = { resourceServer: 'https://api.example.com/mcp' } + + it('enriches a 401 with the configured resourceServer, not a derived one', async () => { + offEdgeFunctions() + const res = await withOAuthProtectedResource( + staticConfig, + returns401, + )( + req('POST', '/api/mcp', { + 'X-Forwarded-Host': 'app.vercel.app', + 'X-Forwarded-Proto': 'https', + }), + ) + expect(res.status).toBe(401) + expect(res.headers.get('WWW-Authenticate')).toBe( + 'Bearer resource_metadata="https://api.example.com/mcp/oauth-protected-resource"', + ) + expect(res.headers.get('WWW-Authenticate')).not.toContain('vercel') + expect(res.headers.get('WWW-Authenticate')).not.toContain('/functions/v1') + }) + + it('enriches a 401 with a request-derived resourceServer', async () => { + offEdgeFunctions() + const res = await withOAuthProtectedResource( + { resourceServer: (r) => `${new URL(r.url).origin}/api/mcp` }, + returns401, + )(req('POST', '/api/mcp')) + expect(res.headers.get('WWW-Authenticate')).toBe( + 'Bearer resource_metadata="http://localhost/api/mcp/oauth-protected-resource"', + ) + }) + + it("preserves the handler's own WWW-Authenticate off platform", async () => { + offEdgeFunctions() + const handlerSetsHeader = async () => + new Response(null, { + status: 401, + headers: { 'WWW-Authenticate': 'Bearer error="invalid_token"' }, + }) + const res = await withOAuthProtectedResource( + staticConfig, + handlerSetsHeader, + )(req('POST', '/api/mcp')) + expect(res.headers.get('WWW-Authenticate')).toBe( + 'Bearer error="invalid_token"', + ) + }) + + it('preserves the 401 body and headers while enriching', async () => { + offEdgeFunctions() + const handler = async () => + new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json', 'X-Trace': 'abc' }, + }) + const res = await withOAuthProtectedResource( + staticConfig, + handler, + )(req('POST', '/api/mcp')) + expect(res.headers.get('X-Trace')).toBe('abc') + expect(await res.json()).toEqual({ error: 'Unauthorized' }) + }) + + it('round trip: the metadata at the advertised URL satisfies RFC 9728 §3.3', async () => { + offEdgeFunctions() + setEnv('SUPABASE_URL', 'https://ref.supabase.co') + const app = withOAuthProtectedResource(staticConfig, returns401) + + const unauthorized = await app(req('POST', '/api/mcp')) + const advertised = /resource_metadata="([^"]+)"/.exec( + unauthorized.headers.get('WWW-Authenticate') ?? '', + )?.[1] + expect(advertised).toBe( + 'https://api.example.com/mcp/oauth-protected-resource', + ) + + // Fetch exactly what was advertised, as a client would. + const metadata = await app( + new Request(advertised!, { method: 'GET' }), + ).then((r) => r.json()) + expect(`${metadata.resource}/oauth-protected-resource`).toBe(advertised) + expect(metadata.authorization_servers).toEqual([ + 'https://ref.supabase.co/auth/v1', + ]) + }) +}) + +describe('withOAuthProtectedResource - off-platform defaults fail loudly', () => { + const vercelHeaders = { + 'X-Forwarded-Host': 'app.vercel.app', + 'X-Forwarded-Proto': 'https', + } + + const clearEnv = () => { + setEnv('SUPABASE_URL', undefined) + setEnv('SUPABASE_PUBLIC_URL', undefined) + } + + it('throws MISSING_RESOURCE_SERVER when resourceServer is absent', async () => { + offEdgeFunctions() + clearEnv() + await expect( + withOAuthProtectedResource(passthrough)( + req('GET', '/api/mcp/oauth-protected-resource', vercelHeaders), + ), + ).rejects.toMatchObject({ + constructor: EnvError, + code: MissingResourceServerError, + status: 500, + }) + }) + + it('throws on every request, not just the metadata route', async () => { + // getResourceUrl also backs the ctx contribution and the 401 header. + offEdgeFunctions() + clearEnv() + await expect( + withOAuthProtectedResource(passthrough)(req('POST', '/api/mcp')), + ).rejects.toBeInstanceOf(EnvError) + }) + + it('throws MISSING_AUTHORIZATION_SERVER when only resourceServer is set', async () => { + offEdgeFunctions() + clearEnv() + await expect( + withOAuthProtectedResource( + { resourceServer: 'https://api.example.com/mcp' }, + passthrough, + )(req('GET', '/api/mcp/oauth-protected-resource', vercelHeaders)), + ).rejects.toMatchObject({ + code: MissingAuthorizationServerError, + status: 500, + }) + }) + + it('the error names the option to set', async () => { + offEdgeFunctions() + clearEnv() + const call = withOAuthProtectedResource(passthrough)( + req('GET', '/api/mcp/oauth-protected-resource'), + ) + await expect(call).rejects.toThrow(/resourceServer/) + await expect(call).rejects.toThrow(/withOAuthProtectedResource\(\)/) + }) + + it('a fully configured stack never reaches the env at all', async () => { + offEdgeFunctions() + clearEnv() + const res = await withOAuthProtectedResource( + { + resourceServer: 'https://api.example.com/mcp', + authorizationServer: 'https://example.clerk.accounts.dev', + }, + passthrough, + )(req('GET', '/api/mcp/oauth-protected-resource', vercelHeaders)) + expect(await res.json()).toMatchObject({ + resource: 'https://api.example.com/mcp', + authorization_servers: ['https://example.clerk.accounts.dev'], + }) + }) + + it('SUPABASE_PUBLIC_URL outranks SUPABASE_URL for the issuer', async () => { + offEdgeFunctions() + setEnv('SUPABASE_PUBLIC_URL', 'https://public.example.com') + setEnv('SUPABASE_URL', 'https://ref.supabase.co') + const res = await withOAuthProtectedResource( + { resourceServer: 'https://api.example.com/mcp' }, + passthrough, + )(req('GET', '/api/mcp/oauth-protected-resource')) + const body = await res.json() + expect(body.authorization_servers).toEqual([ + 'https://public.example.com/auth/v1', + ]) + }) + + it('an explicit authorizationServer outranks both env rungs', async () => { + offEdgeFunctions() + setEnv('SUPABASE_PUBLIC_URL', 'https://public.example.com') + setEnv('SUPABASE_URL', 'https://ref.supabase.co') + const res = await withOAuthProtectedResource( + { + resourceServer: 'https://api.example.com/mcp', + authorizationServer: fromSupabaseUrl('https://explicit.supabase.co'), + }, + passthrough, + )(req('GET', '/api/mcp/oauth-protected-resource')) + const body = await res.json() + expect(body.authorization_servers).toEqual([ + 'https://explicit.supabase.co/auth/v1', + ]) + }) +}) diff --git a/src/oauth-protected-resource/with-oauth-protected-resource.ts b/src/oauth-protected-resource/with-oauth-protected-resource.ts index 46219be..158c341 100644 --- a/src/oauth-protected-resource/with-oauth-protected-resource.ts +++ b/src/oauth-protected-resource/with-oauth-protected-resource.ts @@ -2,7 +2,8 @@ import { defineMiddleware } from '@supabase/middleware' import type { Middleware } from '@supabase/middleware' import { resourceMetadataResponse } from './responses.js' -import { getResourceMetadataUrl, inferFunctionName } from './url.js' +import { getAuthUrl, getResourceMetadataUrl, getResourceUrl } from './url.js' +import type { UrlOption } from './url.js' /** Shape contributed at `ctx.oauthProtectedResource`. */ export interface OAuthProtectedResourceContribution { @@ -11,16 +12,55 @@ export interface OAuthProtectedResourceContribution { } /** - * Wraps a request handler with OAuth 2.1 Protected Resource behavior (RFC 9728) - * for Supabase Edge Functions. + * Configuration for {@link withOAuthProtectedResource}. * - * - Serves OAuth Protected Resource Metadata at `GET /{fn}/oauth-protected-resource` + * Both options accept a fixed string or a function of the request. Both default + * to values derived from the request as it arrives through the Supabase Edge + * Functions proxy, so no configuration is needed there. + * + * @category Types + */ +export interface OAuthProtectedResourceConfig { + /** + * The resource identifier to advertise — this endpoint's externally-visible + * URL, which RFC 9728 §3.3 requires to equal the URL the client called. + * + * Defaults to the Edge Functions derivation. Required on any other backend, + * usually from the request — `(req) => new URL(req.url).origin + '/api/mcp'` + * — and throws `EnvError` (`MISSING_RESOURCE_SERVER`) if unset there. + */ + resourceServer?: UrlOption + /** + * The OAuth 2.1 authorization server to advertise, as an issuer identifier. + * + * Defaults to the project's Supabase Auth on Edge Functions. Elsewhere it + * falls back to `SUPABASE_PUBLIC_URL`, then `SUPABASE_URL`, each with + * `/auth/v1` appended, and throws `EnvError` (`MISSING_AUTHORIZATION_SERVER`) + * if neither is set. Pass {@link fromSupabaseUrl} for a specific project, or + * any other issuer directly. + */ + authorizationServer?: UrlOption +} + +/** + * Wraps a request handler with OAuth 2.1 Protected Resource behavior (RFC 9728). + * + * - Serves OAuth Protected Resource Metadata at `GET {resource}/oauth-protected-resource` * (with permissive CORS, including the `OPTIONS` preflight, so browser-based clients can read it) * - Enriches a `401` from the inner handler with `WWW-Authenticate: Bearer resource_metadata="..."`, * unless the handler already set a `WWW-Authenticate` header (its value wins) * - Passes any other path through to the inner handler unchanged (composition, * not routing, decides what happens to it) * + * The metadata route is matched on the path *suffix*, so **any** `GET` or + * `OPTIONS` ending in `/oauth-protected-resource` is answered here and never + * reaches the inner handler, at any depth. Other methods pass through. + * + * Zero-config on Supabase Edge Functions. Elsewhere + * {@link OAuthProtectedResourceConfig.resourceServer} is required and + * {@link OAuthProtectedResourceConfig.authorizationServer} falls back to + * `SUPABASE_URL`; each throws an `EnvError` when it cannot be resolved. + * * Contributes `ctx.oauthProtectedResource` (the resolved metadata URL) to the * downstream context. Nested under `withSupabase`, the key is typed on the * handler's `ctx` when the outermost call is anchored with @@ -28,7 +68,7 @@ export interface OAuthProtectedResourceContribution { * * @category Middleware * - * @example + * @example Supabase Edge Functions — zero config * ```ts * import { withOAuthProtectedResource, withSupabase } from '@supabase/server' * @@ -42,41 +82,64 @@ export interface OAuthProtectedResourceContribution { * ), * ) * ``` + * + * @example Any other backend + * ```ts + * import { withOAuthProtectedResource, fromSupabaseUrl } from '@supabase/server' + * + * export default { + * fetch: withOAuthProtectedResource( + * { + * resourceServer: (req) => new URL(req.url).origin + '/api/mcp', + * authorizationServer: fromSupabaseUrl('https://abc123.supabase.co'), + * }, + * handler, + * ), + * } + * ``` + * + * @example A non-Supabase authorization server + * ```ts + * withOAuthProtectedResource( + * { + * resourceServer: 'https://api.example.com/mcp', + * authorizationServer: 'https://example.clerk.accounts.dev', + * }, + * handler, + * ) + * ``` */ export const withOAuthProtectedResource: Middleware< 'oauthProtectedResource', - undefined, + OAuthProtectedResourceConfig | undefined, Record, OAuthProtectedResourceContribution > = defineMiddleware< 'oauthProtectedResource', - undefined, + OAuthProtectedResourceConfig | undefined, Record, OAuthProtectedResourceContribution >({ key: 'oauthProtectedResource', - run: () => + run: (config) => async function* (req) { const url = new URL(req.url) - const fn = inferFunctionName(req) - const metadataPath = fn ? `/${fn}/oauth-protected-resource` : undefined + // The metadata document lives at `{resource}/oauth-protected-resource`. + // Matching on the suffix keeps this working wherever the endpoint is + // mounted, without assuming the Edge Functions path convention. + const isMetadataRoute = url.pathname.endsWith('/oauth-protected-resource') // RFC 9728 — OAuth Protected Resource Metadata - if ( - metadataPath && - req.method === 'GET' && - url.pathname === metadataPath - ) { - return resourceMetadataResponse(req) + if (isMetadataRoute && req.method === 'GET') { + return resourceMetadataResponse(req, { + resource: getResourceUrl(req, config?.resourceServer), + authorizationServers: [getAuthUrl(req, config?.authorizationServer)], + }) } - // CORS preflight for the metadata route — browser-based clients (e.g. - // MCP Inspector) fetch the discovery document cross-origin. - if ( - metadataPath && - req.method === 'OPTIONS' && - url.pathname === metadataPath - ) { + // CORS preflight for the metadata route — browser-based clients fetch the + // discovery document cross-origin. + if (isMetadataRoute && req.method === 'OPTIONS') { return new Response(null, { status: 204, headers: { @@ -88,7 +151,10 @@ export const withOAuthProtectedResource: Middleware< }) } - const resourceMetadataUrl = getResourceMetadataUrl(req) + const resourceMetadataUrl = getResourceMetadataUrl( + req, + config?.resourceServer, + ) const response = yield { oauthProtectedResource: { resourceMetadataUrl }, } diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index f91915d..15bb45b 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -369,6 +369,7 @@ describe('withSupabase', () => { let seenBinding: string | undefined const composed = withOAuthProtectedResource( + { resourceServer: 'http://localhost/my-fn' }, withSupabase({ auth: 'none', env: baseEnv }, async (_req, ctx) => { // The `satisfies FetchHandler` anchor below is what lets `Base` flow // in from the outer middleware. This line is the type test for that