diff --git a/jsr.json b/jsr.json index 6bdb95e..4a6c062 100644 --- a/jsr.json +++ b/jsr.json @@ -12,7 +12,8 @@ "./middleware/client": "./src/middleware/client/index.ts", "./middleware/admin-client": "./src/middleware/admin-client/index.ts", "./middleware/postgres": "./src/middleware/postgres/index.ts", - "./middleware/claims": "./src/middleware/claims/index.ts" + "./middleware/claims": "./src/middleware/claims/index.ts", + "./oauth-protected-resource": "./src/oauth-protected-resource/index.ts" }, "publish": { "include": ["src/**/*.ts", "README.md", "LICENSE"], diff --git a/package.json b/package.json index 4cd6eed..1bbe630 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,16 @@ "default": "./dist/middleware/claims/index.cjs" } }, + "./oauth-protected-resource": { + "import": { + "types": "./dist/oauth-protected-resource/index.d.mts", + "default": "./dist/oauth-protected-resource/index.mjs" + }, + "require": { + "types": "./dist/oauth-protected-resource/index.d.cts", + "default": "./dist/oauth-protected-resource/index.cjs" + } + }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", diff --git a/src/index.ts b/src/index.ts index a358c1b..e27a406 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,25 @@ * import { verifyAuth, createContextClient, createAdminClient } from '@supabase/server/core' * ``` * + * ## OAuth 2.1 Protected Resource + * + * `withOAuthProtectedResource` adds RFC 9728 OAuth Protected Resource Metadata + * and `WWW-Authenticate` discovery around any handler — useful for building + * OAuth-protected APIs (e.g. an MCP server) on Supabase Edge Functions: + * + * ```ts + * import { withOAuthProtectedResource, withSupabase } from '@supabase/server' + * + * Deno.serve( + * withOAuthProtectedResource( + * withSupabase({ auth: 'user' }, async (_req, { supabase }) => { + * const { data } = await supabase.from('items').select('*') + * return Response.json(data) + * }), + * ), + * ) + * ``` + * * ## Installation * * ```sh @@ -68,6 +87,16 @@ 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 { + resourceMetadataResponse, + unauthorizedResponse, +} from './oauth-protected-resource/responses.js' +export type { + ResourceMetadataOptions, + UnauthorizedResponseOptions, +} from './oauth-protected-resource/types.js' + export type { Allow, AllowWithKey, diff --git a/src/oauth-protected-resource/index.ts b/src/oauth-protected-resource/index.ts new file mode 100644 index 0000000..ac55052 --- /dev/null +++ b/src/oauth-protected-resource/index.ts @@ -0,0 +1,12 @@ +/** + * OAuth 2.1 Protected Resource middleware (RFC 9728) for Supabase Edge Functions. + * @module + * @packageDocumentation + */ + +export { withOAuthProtectedResource } from './with-oauth-protected-resource.js' +export { resourceMetadataResponse, unauthorizedResponse } from './responses.js' +export type { + ResourceMetadataOptions, + UnauthorizedResponseOptions, +} from './types.js' diff --git a/src/oauth-protected-resource/responses.ts b/src/oauth-protected-resource/responses.ts new file mode 100644 index 0000000..356224d --- /dev/null +++ b/src/oauth-protected-resource/responses.ts @@ -0,0 +1,60 @@ +import { getAuthUrl, getResourceMetadataUrl, getResourceUrl } from './url.js' +import type { + ResourceMetadataOptions, + UnauthorizedResponseOptions, +} from './types.js' + +/** + * `401` response with a `WWW-Authenticate: Bearer resource_metadata="..."` header (RFC 9728). + * Auto-constructs the metadata URL from `X-Forwarded-*` headers. + * Pass `resourceMetadataUrl` to override for custom setups. + * + * @category Middleware + */ +export function unauthorizedResponse( + req: Request, + options?: UnauthorizedResponseOptions, +): Response { + const metadataUrl = + options?.resourceMetadataUrl ?? getResourceMetadataUrl(req) + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { + 'Content-Type': 'application/json', + 'WWW-Authenticate': `Bearer resource_metadata="${metadataUrl}"`, + }, + }) +} + +/** + * RFC 9728 OAuth Protected Resource Metadata response. + * Advertises the authorization server, resource URI, and bearer methods supported. + * Auto-constructs URLs from `X-Forwarded-*` headers. + * + * @category Middleware + */ +export function resourceMetadataResponse( + req: Request, + options?: ResourceMetadataOptions, +): Response { + const resource = options?.resource ?? getResourceUrl(req) + const authorizationServers = options?.authorizationServers ?? [ + getAuthUrl(req), + ] + + return new Response( + JSON.stringify({ + resource, + authorization_servers: authorizationServers, + bearer_methods_supported: ['header'], + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + // Public discovery document — browser clients read it cross-origin. + 'Access-Control-Allow-Origin': '*', + }, + }, + ) +} diff --git a/src/oauth-protected-resource/types.ts b/src/oauth-protected-resource/types.ts new file mode 100644 index 0000000..b7f05e6 --- /dev/null +++ b/src/oauth-protected-resource/types.ts @@ -0,0 +1,21 @@ +/** + * Options for {@link unauthorizedResponse}. + * + * @category Types + */ +export interface UnauthorizedResponseOptions { + /** Absolute URL override for the resource metadata endpoint. */ + resourceMetadataUrl?: string +} + +/** + * Options for {@link resourceMetadataResponse}. + * + * @category Types + */ +export interface ResourceMetadataOptions { + /** Override the resource URI. */ + resource?: string + /** Override the authorization servers list. */ + authorizationServers?: string[] +} diff --git a/src/oauth-protected-resource/url.ts b/src/oauth-protected-resource/url.ts new file mode 100644 index 0000000..beac25e --- /dev/null +++ b/src/oauth-protected-resource/url.ts @@ -0,0 +1,63 @@ +/** + * Constructs the external-facing base URL from the request, + * considering the `X-Forwarded-*` headers set by the Supabase Edge Functions proxy. + * + * @internal + */ +export function getBaseUrl(req: Request): string { + const url = new URL(req.url) + const host = req.headers.get('X-Forwarded-Host') ?? url.hostname + const proto = + req.headers.get('X-Forwarded-Proto') ?? url.protocol.replace(':', '') + const port = 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}${portSuffix}` +} + +/** + * 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"`). + * + * @internal + */ +export function inferFunctionName(req: Request): string | undefined { + const url = new URL(req.url) + const segments = url.pathname.split('/').filter(Boolean) + return segments[0] +} + +/** + * Constructs the external-facing URL of the protected resource (the edge function itself). + * Restores the `/functions/v1` prefix stripped by the Supabase proxy. + * + * @internal + */ +export function getResourceUrl(req: Request): string { + const fn = inferFunctionName(req) ?? '' + return `${getBaseUrl(req)}/functions/v1/${fn}` +} + +/** + * 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` +} + +/** + * Constructs the external-facing Supabase Auth URL. + * + * @internal + */ +export function getAuthUrl(req: Request): string { + return `${getBaseUrl(req)}/auth/v1` +} diff --git a/src/oauth-protected-resource/with-oauth-protected-resource.test.ts b/src/oauth-protected-resource/with-oauth-protected-resource.test.ts new file mode 100644 index 0000000..f87bb7e --- /dev/null +++ b/src/oauth-protected-resource/with-oauth-protected-resource.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest' + +import { resourceMetadataResponse, unauthorizedResponse } from './responses.js' +import { withOAuthProtectedResource } from './with-oauth-protected-resource.js' + +const req = (method: string, path: string, headers?: Record) => + new Request(`http://localhost${path}`, { method, headers }) + +const passthrough = async () => new Response('ok', { status: 200 }) +const returns401 = async () => + new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) + +describe('withOAuthProtectedResource - metadata route', () => { + it('serves RFC 9728 JSON on GET /fn/oauth-protected-resource', async () => { + const res = await withOAuthProtectedResource(passthrough)( + req('GET', '/my-fn/oauth-protected-resource'), + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toHaveProperty('resource') + expect(body).toHaveProperty('authorization_servers') + expect(body.bearer_methods_supported).toContain('header') + }) + + it('ignores POST to /fn/oauth-protected-resource (passes through)', async () => { + const res = await withOAuthProtectedResource(passthrough)( + req('POST', '/my-fn/oauth-protected-resource'), + ) + expect(res.status).toBe(404) + }) +}) + +describe('withOAuthProtectedResource - method pass-through', () => { + it('passes POST through to inner handler', async () => { + const res = await withOAuthProtectedResource(passthrough)( + req('POST', '/my-fn'), + ) + expect(res.status).toBe(200) + }) + + it('passes OPTIONS through to inner handler (CORS preflight)', async () => { + const handler = async () => new Response(null, { status: 204 }) + const res = await withOAuthProtectedResource(handler)( + req('OPTIONS', '/my-fn'), + ) + expect(res.status).toBe(204) + }) + + it("passes GET, DELETE, and other methods through unchanged (method routing is the terminal handler's job)", async () => { + for (const method of ['GET', 'DELETE', 'PUT', 'PATCH', 'HEAD']) { + const res = await withOAuthProtectedResource(passthrough)( + req(method, '/my-fn'), + ) + expect(res.status).toBe(200) + } + }) + + it('returns 401 + WWW-Authenticate for unauthenticated non-POST (auth discovery before any method check)', async () => { + for (const method of ['GET', 'DELETE', 'PUT']) { + const res = await withOAuthProtectedResource(returns401)( + req(method, '/my-fn'), + ) + expect(res.status).toBe(401) + expect(res.headers.get('WWW-Authenticate')).toMatch(/resource_metadata=/) + } + }) +}) + +describe('withOAuthProtectedResource - path routing', () => { + it('returns 404 for unrecognized sub-paths', async () => { + // /my-fn/something is not a registered route under the my-fn function + const res = await withOAuthProtectedResource(passthrough)( + req('POST', '/my-fn/something'), + ) + expect(res.status).toBe(404) + }) + + it('infers function name from first path segment', async () => { + const res = await withOAuthProtectedResource(passthrough)( + req('GET', '/my-function/oauth-protected-resource'), + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(String(body.resource)).toContain('my-function') + }) + + it('includes /functions/v1/ prefix in resource metadata URL', async () => { + const res = await withOAuthProtectedResource(passthrough)( + req('GET', '/my-fn/oauth-protected-resource'), + ) + const body = await res.json() + expect(String(body.resource)).toContain('/functions/v1/my-fn') + expect(String(body.authorization_servers[0])).toContain('/auth/v1') + }) +}) + +describe('withOAuthProtectedResource - 401 enrichment', () => { + it('adds WWW-Authenticate to 401 responses from inner handler', async () => { + const res = await withOAuthProtectedResource(returns401)( + req('POST', '/my-fn'), + ) + expect(res.status).toBe(401) + const wwwAuth = res.headers.get('WWW-Authenticate') ?? '' + expect(wwwAuth).toMatch(/^Bearer /) + expect(wwwAuth).toContain('resource_metadata=') + }) + + it('does not modify non-401 responses', async () => { + const res = await withOAuthProtectedResource(passthrough)( + req('POST', '/my-fn'), + ) + expect(res.headers.get('WWW-Authenticate')).toBeNull() + }) + + it('preserves existing response body and headers on 401', async () => { + const handler = async () => + new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + const res = await withOAuthProtectedResource(handler)(req('POST', '/my-fn')) + expect(res.headers.get('Content-Type')).toBe('application/json') + const body = await res.json() + expect(body.error).toBe('Unauthorized') + }) + + it('does not clobber a WWW-Authenticate the handler already set', async () => { + const custom = + 'Bearer error="invalid_token", error_description="expired", resource_metadata="https://tenant.example.com/functions/v1/my-fn/oauth-protected-resource"' + const handler = async () => + new Response(null, { + status: 401, + headers: { 'WWW-Authenticate': custom }, + }) + const res = await withOAuthProtectedResource(handler)(req('POST', '/my-fn')) + expect(res.headers.get('WWW-Authenticate')).toBe(custom) + }) +}) + +describe('withOAuthProtectedResource - metadata CORS', () => { + it('serves metadata with a permissive CORS header (public discovery data)', async () => { + const res = await withOAuthProtectedResource(passthrough)( + req('GET', '/my-fn/oauth-protected-resource'), + ) + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*') + }) + + it('answers OPTIONS preflight on the metadata path', async () => { + const res = await withOAuthProtectedResource(passthrough)( + req('OPTIONS', '/my-fn/oauth-protected-resource'), + ) + expect(res.status).toBe(204) + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*') + expect(res.headers.get('Access-Control-Allow-Methods')).toContain('GET') + }) +}) + +describe('resourceMetadataResponse', () => { + it('returns 200 with RFC 9728 structure', async () => { + const res = resourceMetadataResponse( + req('GET', '/my-fn/oauth-protected-resource'), + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.resource).toBeTruthy() + expect(Array.isArray(body.authorization_servers)).toBe(true) + expect(body.bearer_methods_supported).toContain('header') + }) + + it('accepts resource and authorizationServers overrides', async () => { + const res = resourceMetadataResponse( + req('GET', '/my-fn/oauth-protected-resource'), + { + resource: 'https://example.com/functions/v1/my-fn', + authorizationServers: ['https://example.com/auth/v1'], + }, + ) + const body = await res.json() + expect(body.resource).toBe('https://example.com/functions/v1/my-fn') + expect(body.authorization_servers).toEqual(['https://example.com/auth/v1']) + }) +}) + +describe('unauthorizedResponse', () => { + it('returns 401 with WWW-Authenticate header', () => { + const res = unauthorizedResponse(req('POST', '/my-fn')) + expect(res.status).toBe(401) + const wwwAuth = res.headers.get('WWW-Authenticate') ?? '' + expect(wwwAuth).toMatch(/^Bearer /) + expect(wwwAuth).toContain('resource_metadata=') + }) + + it('accepts a resourceMetadataUrl override', () => { + const url = + 'https://example.com/functions/v1/my-fn/oauth-protected-resource' + const res = unauthorizedResponse(req('POST', '/my-fn'), { + resourceMetadataUrl: url, + }) + expect(res.headers.get('WWW-Authenticate')).toBe( + `Bearer resource_metadata="${url}"`, + ) + }) +}) + +describe('withOAuthProtectedResource - platform argument', () => { + it('forwards the platform second argument to the inner handler', async () => { + let seen: unknown + const handler = async (_req: Request, platformArg?: unknown) => { + seen = platformArg + return new Response('ok') + } + const env = { MY_BINDING: 'value' } + await withOAuthProtectedResource(handler)(req('POST', '/my-fn'), env) + expect(seen).toBe(env) + }) +}) diff --git a/src/oauth-protected-resource/with-oauth-protected-resource.ts b/src/oauth-protected-resource/with-oauth-protected-resource.ts new file mode 100644 index 0000000..c752fd0 --- /dev/null +++ b/src/oauth-protected-resource/with-oauth-protected-resource.ts @@ -0,0 +1,92 @@ +import { resourceMetadataResponse } from './responses.js' +import { getResourceMetadataUrl, inferFunctionName } from './url.js' + +/** + * Wraps a request handler with OAuth 2.1 Protected Resource behavior (RFC 9728) + * for Supabase Edge Functions. + * + * - Serves OAuth Protected Resource Metadata at `GET /{fn}/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) + * - Returns `404` for any other path (Edge Functions are single-endpoint - the inner handler owns `/{fn}` only) + * + * The returned handler's optional second parameter is the host's platform + * argument (a Workers `env`, a Deno `ServeHandlerInfo`) and is forwarded to + * the inner handler unchanged — required for `withSupabase` to capture it. + * + * @category Middleware + * + * @example + * ```ts + * import { withOAuthProtectedResource, withSupabase } from '@supabase/server' + * + * Deno.serve( + * withOAuthProtectedResource( + * withSupabase({ auth: 'user' }, async (_req, { supabase }) => { + * const { data, error } = await supabase.from('items').select('*') + * if (error) throw error + * return Response.json(data) + * }), + * ), + * ) + * ``` + */ +export function withOAuthProtectedResource( + handler: (req: Request, platformArg?: unknown) => Promise, +): (req: Request, platformArg?: unknown) => Promise { + return async (req: Request, platformArg?: unknown): Promise => { + const url = new URL(req.url) + const fn = inferFunctionName(req) + if (!fn) return new Response('Not Found', { status: 404 }) + const basePath = `/${fn}` + + // RFC 9728 — OAuth Protected Resource Metadata + if ( + req.method === 'GET' && + url.pathname === `${basePath}/oauth-protected-resource` + ) { + return resourceMetadataResponse(req) + } + + // CORS preflight for the metadata route — browser-based clients (e.g. + // MCP Inspector) fetch the discovery document cross-origin. + if ( + req.method === 'OPTIONS' && + url.pathname === `${basePath}/oauth-protected-resource` + ) { + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'content-type, mcp-protocol-version', + }, + }) + } + + if (url.pathname !== basePath) { + return new Response('Not Found', { status: 404 }) + } + + const response = await handler(req, platformArg) + + // Enrich a 401 with WWW-Authenticate so clients can discover the auth + // server — unless the handler already set one (its value wins, e.g. an + // RFC 6750 error or a custom resource_metadata override). + if (response.status === 401 && !response.headers.has('WWW-Authenticate')) { + const headers = new Headers(response.headers) + headers.set( + 'WWW-Authenticate', + `Bearer resource_metadata="${getResourceMetadataUrl(req)}"`, + ) + return new Response(response.body, { + status: 401, + statusText: response.statusText, + headers, + }) + } + + return response + } +} diff --git a/tsdown.config.ts b/tsdown.config.ts index d3e7282..05ec5be 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ 'src/middleware/claims/index.ts', 'src/middleware/client/index.ts', 'src/middleware/admin-client/index.ts', + 'src/oauth-protected-resource/index.ts', ], format: ['esm', 'cjs'], dts: true,