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