From 2b856d115ae757936b2d609bded87e9502db2f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Barroso?= Date: Fri, 21 Aug 2026 20:10:31 +0200 Subject: [PATCH 1/2] refactor: withOAuthProtectedResource --- .../with-oauth-protected-resource.test.ts | 20 ++- .../with-oauth-protected-resource.ts | 136 ++++++++++-------- 2 files changed, 91 insertions(+), 65 deletions(-) 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 f87bb7e..bcc81fc 100644 --- a/src/oauth-protected-resource/with-oauth-protected-resource.test.ts +++ b/src/oauth-protected-resource/with-oauth-protected-resource.test.ts @@ -22,11 +22,14 @@ describe('withOAuthProtectedResource - metadata route', () => { expect(body.bearer_methods_supported).toContain('header') }) - it('ignores POST to /fn/oauth-protected-resource (passes through)', async () => { + it('passes POST to /fn/oauth-protected-resource through to the inner handler', async () => { + // Only GET matches the metadata route; every other method (and path) falls + // through to the inner handler rather than 404ing — see the path-routing + // describe block below. const res = await withOAuthProtectedResource(passthrough)( req('POST', '/my-fn/oauth-protected-resource'), ) - expect(res.status).toBe(404) + expect(res.status).toBe(200) }) }) @@ -67,12 +70,15 @@ describe('withOAuthProtectedResource - method pass-through', () => { }) 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 + it('passes unrecognized sub-paths through to the inner handler (deliberate: AI-995)', async () => { + // Was a blanket 404 under the old hand-written closure — an accidental + // side effect of being a standalone wrapper, not a deliberate contract. + // The defineMiddleware conversion passes through instead, since that's + // what fits the composition model: routing is the inner handler's job. const res = await withOAuthProtectedResource(passthrough)( req('POST', '/my-fn/something'), ) - expect(res.status).toBe(404) + expect(res.status).toBe(200) }) it('infers function name from first path segment', async () => { @@ -203,7 +209,7 @@ describe('unauthorizedResponse', () => { }) describe('withOAuthProtectedResource - platform argument', () => { - it('forwards the platform second argument to the inner handler', async () => { + it('no longer forwards the raw platform argument — the inner handler receives ctx instead', async () => { let seen: unknown const handler = async (_req: Request, platformArg?: unknown) => { seen = platformArg @@ -211,6 +217,6 @@ describe('withOAuthProtectedResource - platform argument', () => { } const env = { MY_BINDING: 'value' } await withOAuthProtectedResource(handler)(req('POST', '/my-fn'), env) - expect(seen).toBe(env) + expect(seen).not.toBe(env) }) }) diff --git a/src/oauth-protected-resource/with-oauth-protected-resource.ts b/src/oauth-protected-resource/with-oauth-protected-resource.ts index c752fd0..44a9a90 100644 --- a/src/oauth-protected-resource/with-oauth-protected-resource.ts +++ b/src/oauth-protected-resource/with-oauth-protected-resource.ts @@ -1,6 +1,15 @@ +import { defineMiddleware } from '@supabase/middleware' +import type { Middleware } from '@supabase/middleware' + import { resourceMetadataResponse } from './responses.js' import { getResourceMetadataUrl, inferFunctionName } from './url.js' +/** Shape contributed at `ctx.oauthProtectedResource`. */ +export interface OAuthProtectedResourceContribution { + /** Absolute URL of this resource's OAuth Protected Resource Metadata document (RFC 9728). */ + resourceMetadataUrl: string +} + /** * Wraps a request handler with OAuth 2.1 Protected Resource behavior (RFC 9728) * for Supabase Edge Functions. @@ -9,11 +18,8 @@ import { getResourceMetadataUrl, inferFunctionName } from './url.js' * (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. + * - Passes any other path through to the inner handler unchanged (composition, + * not routing, decides what happens to it) * * @category Middleware * @@ -32,61 +38,75 @@ import { getResourceMetadataUrl, inferFunctionName } from './url.js' * ) * ``` */ -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) - } +export const withOAuthProtectedResource: Middleware< + 'oauthProtectedResource', + undefined, + Record, + OAuthProtectedResourceContribution +> = defineMiddleware< + 'oauthProtectedResource', + undefined, + Record, + OAuthProtectedResourceContribution +>({ + key: 'oauthProtectedResource', + run: () => + async function* (req) { + const url = new URL(req.url) + const fn = inferFunctionName(req) + const metadataPath = fn ? `/${fn}/oauth-protected-resource` : undefined - // 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', - }, - }) - } + // RFC 9728 — OAuth Protected Resource Metadata + if ( + metadataPath && + req.method === 'GET' && + url.pathname === metadataPath + ) { + return resourceMetadataResponse(req) + } - if (url.pathname !== basePath) { - return new Response('Not Found', { status: 404 }) - } + // 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 + ) { + 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', + }, + }) + } - const response = await handler(req, platformArg) + const resourceMetadataUrl = getResourceMetadataUrl(req) + const response = yield { + oauthProtectedResource: { resourceMetadataUrl }, + } - // 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, - }) - } + // 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="${resourceMetadataUrl}"`, + ) + return new Response(response.body, { + status: 401, + statusText: response.statusText, + headers, + }) + } - return response - } -} + return response + }, +}) From b21fd7bb4773876d2cd09c331bbb54fed6d482f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Barroso?= Date: Mon, 24 Aug 2026 18:34:46 +0200 Subject: [PATCH 2/2] fix: address PR feedback by @mandarini --- e2e/supabase/functions/server-e2e/deno.json | 2 +- package.json | 2 +- pnpm-lock.yaml | 17 ++++++--- .../with-oauth-protected-resource.ts | 5 +++ src/with-supabase.test.ts | 35 +++++++++++++++++++ src/with-supabase.ts | 18 +++++----- 6 files changed, 63 insertions(+), 16 deletions(-) diff --git a/e2e/supabase/functions/server-e2e/deno.json b/e2e/supabase/functions/server-e2e/deno.json index 9cdef01..6107944 100644 --- a/e2e/supabase/functions/server-e2e/deno.json +++ b/e2e/supabase/functions/server-e2e/deno.json @@ -5,7 +5,7 @@ "@supabase/server/middleware/postgres-admin": "../_vendor/package/dist/middleware/postgres-admin/index.mjs", "@supabase/supabase-js": "npm:@supabase/supabase-js@2", "@supabase/supabase-js/cors": "npm:@supabase/supabase-js@2/cors", - "@supabase/middleware": "npm:@supabase/middleware@0.3.0", + "@supabase/middleware": "npm:@supabase/middleware@0.3.1", "jose": "npm:jose@6", "pg": "npm:pg@8" } diff --git a/package.json b/package.json index 417331c..4ec89a6 100644 --- a/package.json +++ b/package.json @@ -243,7 +243,7 @@ "vitest": "^4.1.0" }, "dependencies": { - "@supabase/middleware": "^0.3.0", + "@supabase/middleware": "^0.3.1", "jose": "^6.2.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b92054..abf9908 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: .: dependencies: '@supabase/middleware': - specifier: ^0.3.0 - version: 0.3.0 + specifier: ^0.3.1 + version: 0.3.1(typescript@5.9.3) jose: specifier: ^6.2.0 version: 6.2.0 @@ -860,9 +860,14 @@ packages: resolution: {integrity: sha512-ADIkJYH5w7HbnGVAAlCbyKoLF5QdfyezBLfYXpUqhxZOacK6YepOvnP/8p4p+50bhTPWp6VhDxu19KO7e/qU2g==} engines: {node: '>=20.0.0'} - '@supabase/middleware@0.3.0': - resolution: {integrity: sha512-JN+dUr7Fyx96jfCUEXpzPEIpmkkogxHfII+fx7wWIiAUFI8C0lObDlzKIqrSVEKiFEK6CMsHWcqRarBQ8azCtw==} + '@supabase/middleware@0.3.1': + resolution: {integrity: sha512-ssU8dSgRkKJwwT8AaAno4HDrXs0iD4ocNKDPeBGmi3KIQNtjXZrW4ae4NAPg5O75xHkheOfrZPeXhH2edwgeRw==} engines: {node: '>=22'} + peerDependencies: + typescript: '>=5.4' + peerDependenciesMeta: + typescript: + optional: true '@supabase/phoenix@0.4.2': resolution: {integrity: sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==} @@ -3409,9 +3414,11 @@ snapshots: dependencies: tslib: 2.8.1 - '@supabase/middleware@0.3.0': + '@supabase/middleware@0.3.1(typescript@5.9.3)': dependencies: std-env: 4.2.0 + optionalDependencies: + typescript: 5.9.3 '@supabase/phoenix@0.4.2': {} diff --git a/src/oauth-protected-resource/with-oauth-protected-resource.ts b/src/oauth-protected-resource/with-oauth-protected-resource.ts index 44a9a90..e52d48f 100644 --- a/src/oauth-protected-resource/with-oauth-protected-resource.ts +++ b/src/oauth-protected-resource/with-oauth-protected-resource.ts @@ -21,6 +21,11 @@ export interface OAuthProtectedResourceContribution { * - Passes any other path through to the inner handler unchanged (composition, * not routing, decides what happens to it) * + * Contributes `ctx.oauthProtectedResource` (the resolved metadata URL) to the + * downstream context. When nested under `withSupabase`, this key is present at + * runtime but not yet reflected in the handler's `SupabaseContext` type — see + * `withSupabase`'s type note. + * * @category Middleware * * @example diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index ea260d8..4603713 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -3,6 +3,7 @@ import { defineMiddleware, getEnv } from '@supabase/middleware' import { _resetAllowDeprecationWarned } from './core/utils/deprecation.js' import { EnvError } from './errors.js' +import { withOAuthProtectedResource } from './oauth-protected-resource/with-oauth-protected-resource.js' import { withSupabase } from './with-supabase.js' const baseEnv = { @@ -353,6 +354,40 @@ describe('withSupabase', () => { }) }) + describe('nested under an upstream middleware', () => { + // Nested under another entry, withSupabase must spread the upstream context + // rather than reseed — otherwise it drops upstream ctx keys and clobbers the + // platform env the entry captured (silently breaking getEnv on Workers). + it('preserves upstream ctx keys and the platform env captured by the entry', async () => { + let seenMetadataUrl: string | undefined + let seenBinding: string | undefined + + const composed = withOAuthProtectedResource( + withSupabase({ auth: 'none', env: baseEnv }, async (_req, ctx) => { + // Present at runtime but not on the SupabaseContext type yet, so cast. + const upstream = ctx as { + oauthProtectedResource?: { resourceMetadataUrl: string } + } + seenMetadataUrl = upstream.oauthProtectedResource?.resourceMetadataUrl + seenBinding = getEnv('NESTED_TEST_BINDING') + return Response.json({ ok: true }) + }), + ) + + // Workers-style entry invocation: fetch(request, env). withOAuthProtected- + // Resource is the entry, so it seeds the context with this env. + const res = await composed(new Request('http://localhost/my-fn'), { + NESTED_TEST_BINDING: 'from-platform', + }) + + expect(res.status).toBe(200) + // Upstream contribution survived withSupabase's context construction. + expect(seenMetadataUrl).toContain('/my-fn/oauth-protected-resource') + // Platform env captured by the entry was not clobbered by a reseed. + expect(seenBinding).toBe('from-platform') + }) + }) + describe('client construction errors', () => { it('maps client-construction EnvError to a 500 JSON response', async () => { const handler = withSupabase( diff --git a/src/with-supabase.ts b/src/with-supabase.ts index f428cb4..49a70ac 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -4,7 +4,7 @@ import { AuthError, CreateSupabaseClientError, EnvError } from './errors.js' import { withSupabaseAdminClient } from './middleware/admin-client/index.js' import { withSupabaseClient } from './middleware/client/index.js' import type { SupabaseContext, WithSupabaseConfig } from './types.js' -import { seedContext } from '@supabase/middleware' +import { isContext, seedContext } from '@supabase/middleware' import type { Entry } from '@supabase/middleware' type AnyEntry = Entry @@ -170,15 +170,15 @@ export function withSupabase( let response: Response try { - // seedContext() stamps the engine's context marker so middleware entries - // recognise this as an upstream context, and captures the host's second - // fetch argument (a Workers `env`, a Deno `ServeHandlerInfo`) as the - // platform env behind the engine's importable getEnv — without the - // forward, Workers bindings would be invisible to middleware. The - // verified auth identity is seeded alongside it; the client middleware - // read `authMode` / `authKeyName` to mirror the verified credentials. + // As the entry point, `platformArg` is the host env — seed a context from + // it (captured behind getEnv). Nested under another middleware, it's an + // already-seeded context: reuse it, or reseeding would clobber the platform + // env and drop upstream ctx keys. + const baseContext = isContext(platformArg) + ? platformArg + : seedContext(platformArg) response = await composed(req, { - ...seedContext(platformArg), + ...baseContext, userClaims: auth.userClaims, jwtClaims: auth.jwtClaims, authMode: auth.authMode,