Skip to content

Commit 04831ce

Browse files
authored
fix: migrate withOAuthProtectedResource to defineMiddleware (#116)
* refactor: withOAuthProtectedResource * fix: address PR feedback by @mandarini
1 parent 77656ba commit 04831ce

7 files changed

Lines changed: 153 additions & 80 deletions

File tree

e2e/supabase/functions/server-e2e/deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"@supabase/server/middleware/postgres-admin": "../_vendor/package/dist/middleware/postgres-admin/index.mjs",
66
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
77
"@supabase/supabase-js/cors": "npm:@supabase/supabase-js@2/cors",
8-
"@supabase/middleware": "npm:@supabase/middleware@0.3.0",
8+
"@supabase/middleware": "npm:@supabase/middleware@0.3.1",
99
"jose": "npm:jose@6",
1010
"pg": "npm:pg@8"
1111
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@
243243
"vitest": "^4.1.0"
244244
},
245245
"dependencies": {
246-
"@supabase/middleware": "^0.3.0",
246+
"@supabase/middleware": "^0.3.1",
247247
"jose": "^6.2.0"
248248
}
249249
}

pnpm-lock.yaml

Lines changed: 12 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/oauth-protected-resource/with-oauth-protected-resource.test.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,14 @@ describe('withOAuthProtectedResource - metadata route', () => {
2222
expect(body.bearer_methods_supported).toContain('header')
2323
})
2424

25-
it('ignores POST to /fn/oauth-protected-resource (passes through)', async () => {
25+
it('passes POST to /fn/oauth-protected-resource through to the inner handler', async () => {
26+
// Only GET matches the metadata route; every other method (and path) falls
27+
// through to the inner handler rather than 404ing — see the path-routing
28+
// describe block below.
2629
const res = await withOAuthProtectedResource(passthrough)(
2730
req('POST', '/my-fn/oauth-protected-resource'),
2831
)
29-
expect(res.status).toBe(404)
32+
expect(res.status).toBe(200)
3033
})
3134
})
3235

@@ -67,12 +70,15 @@ describe('withOAuthProtectedResource - method pass-through', () => {
6770
})
6871

6972
describe('withOAuthProtectedResource - path routing', () => {
70-
it('returns 404 for unrecognized sub-paths', async () => {
71-
// /my-fn/something is not a registered route under the my-fn function
73+
it('passes unrecognized sub-paths through to the inner handler (deliberate: AI-995)', async () => {
74+
// Was a blanket 404 under the old hand-written closure — an accidental
75+
// side effect of being a standalone wrapper, not a deliberate contract.
76+
// The defineMiddleware conversion passes through instead, since that's
77+
// what fits the composition model: routing is the inner handler's job.
7278
const res = await withOAuthProtectedResource(passthrough)(
7379
req('POST', '/my-fn/something'),
7480
)
75-
expect(res.status).toBe(404)
81+
expect(res.status).toBe(200)
7682
})
7783

7884
it('infers function name from first path segment', async () => {
@@ -203,14 +209,14 @@ describe('unauthorizedResponse', () => {
203209
})
204210

205211
describe('withOAuthProtectedResource - platform argument', () => {
206-
it('forwards the platform second argument to the inner handler', async () => {
212+
it('no longer forwards the raw platform argument the inner handler receives ctx instead', async () => {
207213
let seen: unknown
208214
const handler = async (_req: Request, platformArg?: unknown) => {
209215
seen = platformArg
210216
return new Response('ok')
211217
}
212218
const env = { MY_BINDING: 'value' }
213219
await withOAuthProtectedResource(handler)(req('POST', '/my-fn'), env)
214-
expect(seen).toBe(env)
220+
expect(seen).not.toBe(env)
215221
})
216222
})
Lines changed: 82 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1+
import { defineMiddleware } from '@supabase/middleware'
2+
import type { Middleware } from '@supabase/middleware'
3+
14
import { resourceMetadataResponse } from './responses.js'
25
import { getResourceMetadataUrl, inferFunctionName } from './url.js'
36

7+
/** Shape contributed at `ctx.oauthProtectedResource`. */
8+
export interface OAuthProtectedResourceContribution {
9+
/** Absolute URL of this resource's OAuth Protected Resource Metadata document (RFC 9728). */
10+
resourceMetadataUrl: string
11+
}
12+
413
/**
514
* Wraps a request handler with OAuth 2.1 Protected Resource behavior (RFC 9728)
615
* for Supabase Edge Functions.
@@ -9,11 +18,13 @@ import { getResourceMetadataUrl, inferFunctionName } from './url.js'
918
* (with permissive CORS, including the `OPTIONS` preflight, so browser-based clients can read it)
1019
* - Enriches a `401` from the inner handler with `WWW-Authenticate: Bearer resource_metadata="..."`,
1120
* unless the handler already set a `WWW-Authenticate` header (its value wins)
12-
* - Returns `404` for any other path (Edge Functions are single-endpoint - the inner handler owns `/{fn}` only)
21+
* - Passes any other path through to the inner handler unchanged (composition,
22+
* not routing, decides what happens to it)
1323
*
14-
* The returned handler's optional second parameter is the host's platform
15-
* argument (a Workers `env`, a Deno `ServeHandlerInfo`) and is forwarded to
16-
* the inner handler unchanged — required for `withSupabase` to capture it.
24+
* Contributes `ctx.oauthProtectedResource` (the resolved metadata URL) to the
25+
* downstream context. When nested under `withSupabase`, this key is present at
26+
* runtime but not yet reflected in the handler's `SupabaseContext` type — see
27+
* `withSupabase`'s type note.
1728
*
1829
* @category Middleware
1930
*
@@ -32,61 +43,75 @@ import { getResourceMetadataUrl, inferFunctionName } from './url.js'
3243
* )
3344
* ```
3445
*/
35-
export function withOAuthProtectedResource(
36-
handler: (req: Request, platformArg?: unknown) => Promise<Response>,
37-
): (req: Request, platformArg?: unknown) => Promise<Response> {
38-
return async (req: Request, platformArg?: unknown): Promise<Response> => {
39-
const url = new URL(req.url)
40-
const fn = inferFunctionName(req)
41-
if (!fn) return new Response('Not Found', { status: 404 })
42-
const basePath = `/${fn}`
46+
export const withOAuthProtectedResource: Middleware<
47+
'oauthProtectedResource',
48+
undefined,
49+
Record<never, never>,
50+
OAuthProtectedResourceContribution
51+
> = defineMiddleware<
52+
'oauthProtectedResource',
53+
undefined,
54+
Record<never, never>,
55+
OAuthProtectedResourceContribution
56+
>({
57+
key: 'oauthProtectedResource',
58+
run: () =>
59+
async function* (req) {
60+
const url = new URL(req.url)
61+
const fn = inferFunctionName(req)
62+
const metadataPath = fn ? `/${fn}/oauth-protected-resource` : undefined
4363

44-
// RFC 9728 — OAuth Protected Resource Metadata
45-
if (
46-
req.method === 'GET' &&
47-
url.pathname === `${basePath}/oauth-protected-resource`
48-
) {
49-
return resourceMetadataResponse(req)
50-
}
64+
// RFC 9728 — OAuth Protected Resource Metadata
65+
if (
66+
metadataPath &&
67+
req.method === 'GET' &&
68+
url.pathname === metadataPath
69+
) {
70+
return resourceMetadataResponse(req)
71+
}
5172

52-
// CORS preflight for the metadata route — browser-based clients (e.g.
53-
// MCP Inspector) fetch the discovery document cross-origin.
54-
if (
55-
req.method === 'OPTIONS' &&
56-
url.pathname === `${basePath}/oauth-protected-resource`
57-
) {
58-
return new Response(null, {
59-
status: 204,
60-
headers: {
61-
'Access-Control-Allow-Origin': '*',
62-
'Access-Control-Allow-Methods': 'GET, OPTIONS',
63-
'Access-Control-Allow-Headers': 'content-type, mcp-protocol-version',
64-
},
65-
})
66-
}
73+
// CORS preflight for the metadata route — browser-based clients (e.g.
74+
// MCP Inspector) fetch the discovery document cross-origin.
75+
if (
76+
metadataPath &&
77+
req.method === 'OPTIONS' &&
78+
url.pathname === metadataPath
79+
) {
80+
return new Response(null, {
81+
status: 204,
82+
headers: {
83+
'Access-Control-Allow-Origin': '*',
84+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
85+
'Access-Control-Allow-Headers':
86+
'content-type, mcp-protocol-version',
87+
},
88+
})
89+
}
6790

68-
if (url.pathname !== basePath) {
69-
return new Response('Not Found', { status: 404 })
70-
}
91+
const resourceMetadataUrl = getResourceMetadataUrl(req)
92+
const response = yield {
93+
oauthProtectedResource: { resourceMetadataUrl },
94+
}
7195

72-
const response = await handler(req, platformArg)
96+
// Enrich a 401 with WWW-Authenticate so clients can discover the auth
97+
// server — unless the handler already set one (its value wins, e.g. an
98+
// RFC 6750 error or a custom resource_metadata override).
99+
if (
100+
response.status === 401 &&
101+
!response.headers.has('WWW-Authenticate')
102+
) {
103+
const headers = new Headers(response.headers)
104+
headers.set(
105+
'WWW-Authenticate',
106+
`Bearer resource_metadata="${resourceMetadataUrl}"`,
107+
)
108+
return new Response(response.body, {
109+
status: 401,
110+
statusText: response.statusText,
111+
headers,
112+
})
113+
}
73114

74-
// Enrich a 401 with WWW-Authenticate so clients can discover the auth
75-
// server — unless the handler already set one (its value wins, e.g. an
76-
// RFC 6750 error or a custom resource_metadata override).
77-
if (response.status === 401 && !response.headers.has('WWW-Authenticate')) {
78-
const headers = new Headers(response.headers)
79-
headers.set(
80-
'WWW-Authenticate',
81-
`Bearer resource_metadata="${getResourceMetadataUrl(req)}"`,
82-
)
83-
return new Response(response.body, {
84-
status: 401,
85-
statusText: response.statusText,
86-
headers,
87-
})
88-
}
89-
90-
return response
91-
}
92-
}
115+
return response
116+
},
117+
})

src/with-supabase.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { defineMiddleware, getEnv } from '@supabase/middleware'
33

44
import { _resetAllowDeprecationWarned } from './core/utils/deprecation.js'
55
import { EnvError } from './errors.js'
6+
import { withOAuthProtectedResource } from './oauth-protected-resource/with-oauth-protected-resource.js'
67
import { withSupabase } from './with-supabase.js'
78

89
const baseEnv = {
@@ -353,6 +354,40 @@ describe('withSupabase', () => {
353354
})
354355
})
355356

357+
describe('nested under an upstream middleware', () => {
358+
// Nested under another entry, withSupabase must spread the upstream context
359+
// rather than reseed — otherwise it drops upstream ctx keys and clobbers the
360+
// platform env the entry captured (silently breaking getEnv on Workers).
361+
it('preserves upstream ctx keys and the platform env captured by the entry', async () => {
362+
let seenMetadataUrl: string | undefined
363+
let seenBinding: string | undefined
364+
365+
const composed = withOAuthProtectedResource(
366+
withSupabase({ auth: 'none', env: baseEnv }, async (_req, ctx) => {
367+
// Present at runtime but not on the SupabaseContext type yet, so cast.
368+
const upstream = ctx as {
369+
oauthProtectedResource?: { resourceMetadataUrl: string }
370+
}
371+
seenMetadataUrl = upstream.oauthProtectedResource?.resourceMetadataUrl
372+
seenBinding = getEnv('NESTED_TEST_BINDING')
373+
return Response.json({ ok: true })
374+
}),
375+
)
376+
377+
// Workers-style entry invocation: fetch(request, env). withOAuthProtected-
378+
// Resource is the entry, so it seeds the context with this env.
379+
const res = await composed(new Request('http://localhost/my-fn'), {
380+
NESTED_TEST_BINDING: 'from-platform',
381+
})
382+
383+
expect(res.status).toBe(200)
384+
// Upstream contribution survived withSupabase's context construction.
385+
expect(seenMetadataUrl).toContain('/my-fn/oauth-protected-resource')
386+
// Platform env captured by the entry was not clobbered by a reseed.
387+
expect(seenBinding).toBe('from-platform')
388+
})
389+
})
390+
356391
describe('client construction errors', () => {
357392
it('maps client-construction EnvError to a 500 JSON response', async () => {
358393
const handler = withSupabase(

src/with-supabase.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { AuthError, CreateSupabaseClientError, EnvError } from './errors.js'
44
import { withSupabaseAdminClient } from './middleware/admin-client/index.js'
55
import { withSupabaseClient } from './middleware/client/index.js'
66
import type { SupabaseContext, WithSupabaseConfig } from './types.js'
7-
import { seedContext } from '@supabase/middleware'
7+
import { isContext, seedContext } from '@supabase/middleware'
88
import type { Entry } from '@supabase/middleware'
99

1010
type AnyEntry = Entry<string, object, unknown>
@@ -170,15 +170,15 @@ export function withSupabase<Database = unknown>(
170170

171171
let response: Response
172172
try {
173-
// seedContext() stamps the engine's context marker so middleware entries
174-
// recognise this as an upstream context, and captures the host's second
175-
// fetch argument (a Workers `env`, a Deno `ServeHandlerInfo`) as the
176-
// platform env behind the engine's importable getEnv — without the
177-
// forward, Workers bindings would be invisible to middleware. The
178-
// verified auth identity is seeded alongside it; the client middleware
179-
// read `authMode` / `authKeyName` to mirror the verified credentials.
173+
// As the entry point, `platformArg` is the host env — seed a context from
174+
// it (captured behind getEnv). Nested under another middleware, it's an
175+
// already-seeded context: reuse it, or reseeding would clobber the platform
176+
// env and drop upstream ctx keys.
177+
const baseContext = isContext(platformArg)
178+
? platformArg
179+
: seedContext(platformArg)
180180
response = await composed(req, {
181-
...seedContext(platformArg),
181+
...baseContext,
182182
userClaims: auth.userClaims,
183183
jwtClaims: auth.jwtClaims,
184184
authMode: auth.authMode,

0 commit comments

Comments
 (0)