Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion jsr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/oauth-protected-resource/index.ts
Original file line number Diff line number Diff line change
@@ -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'
60 changes: 60 additions & 0 deletions src/oauth-protected-resource/responses.ts
Original file line number Diff line number Diff line change
@@ -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': '*',
},
},
)
}
21 changes: 21 additions & 0 deletions src/oauth-protected-resource/types.ts
Original file line number Diff line number Diff line change
@@ -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[]
}
63 changes: 63 additions & 0 deletions src/oauth-protected-resource/url.ts
Original file line number Diff line number Diff line change
@@ -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`
}
Loading
Loading