diff --git a/docs/api-reference.md b/docs/api-reference.md index 7487139..43a9c20 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -233,10 +233,11 @@ Responses use the standard [error payload](error-handling.md#what-a-failure-look ```ts interface WithClaimsConfig { jwks?: JSONWebKeySet | URL + errors?: ErrorResponseConfig } ``` -Defaults to `SUPABASE_JWKS` (inline JSON) or `SUPABASE_JWKS_URL` (https endpoint) from the environment. +`jwks` defaults to `SUPABASE_JWKS` (inline JSON) or `SUPABASE_JWKS_URL` (https endpoint) from the environment. `errors` trims the short-circuit response body; see [`ErrorResponseConfig`](#errorresponseconfig). --- @@ -297,10 +298,11 @@ const entry = (h: (req: Request, ctx: object) => Promise) => ```ts interface WithRequiredClaimsConfig { jwks?: JSONWebKeySet | URL + errors?: ErrorResponseConfig } ``` -Defaults to `SUPABASE_JWKS` (inline JSON) or `SUPABASE_JWKS_URL` (https endpoint) from the environment. +`jwks` defaults to `SUPABASE_JWKS` (inline JSON) or `SUPABASE_JWKS_URL` (https endpoint) from the environment. `errors` trims the short-circuit response body; see [`ErrorResponseConfig`](#errorresponseconfig). --- @@ -387,10 +389,11 @@ const rows = await ctx.postgres.queryRaw( ```ts interface WithPostgresClientConfig { connectionString?: string + errors?: ErrorResponseConfig } ``` -Defaults to the `SUPABASE_DB_URL` environment variable. Pools are created lazily, one per connection string per process. +`connectionString` defaults to the `SUPABASE_DB_URL` environment variable. Pools are created lazily, one per connection string per process. `errors` trims the short-circuit response body; see [`ErrorResponseConfig`](#errorresponseconfig). ### RequestClaims @@ -436,10 +439,11 @@ Authorization is the caller's responsibility: RLS is not consulted, so per-user ```ts interface WithPostgresAdminClientConfig { connectionString?: string + errors?: ErrorResponseConfig } ``` -Defaults to the `SUPABASE_DB_URL` environment variable. +`connectionString` defaults to the `SUPABASE_DB_URL` environment variable. `errors` trims the short-circuit response body; see [`ErrorResponseConfig`](#errorresponseconfig). --- @@ -463,18 +467,19 @@ function withOAuthProtectedResource( ): FetchHandler ``` -OAuth 2.1 Protected Resource behavior (RFC 9728) for the wrapped handler. Answers `GET` and `OPTIONS` on any path ending in `/oauth-protected-resource` with the metadata document and a permissive CORS preflight; adds `WWW-Authenticate: Bearer resource_metadata="…"` to a `401` from below unless the handler already set that header; passes everything else through. Runs before the `withSupabase` gate; placing it directly after `withSupabase` with a credentialed auth mode is refused when the stack is built. +OAuth 2.1 Protected Resource behavior (RFC 9728) for the wrapped handler. Answers `GET` and `OPTIONS` on any path ending in `/oauth-protected-resource` with the metadata document and a permissive CORS preflight; adds `WWW-Authenticate: Bearer resource_metadata="…"` to a `401` from below unless the handler already set that header; passes everything else through. Runs before the `withSupabase` gate; placing it directly after `withSupabase` with a credentialed auth mode is refused when the stack is built. A default URL it cannot derive is answered with the library's JSON error response (500 and `x-supabase-server-error`, see [Error handling](error-handling.md#enverror-codes)); a throw from a configured `resourceServer` or `authorizationServer` function propagates. Contributes `ctx.oauthProtectedResource.resourceMetadataUrl`, the resolved absolute URL of the metadata document. ### OAuthProtectedResourceConfig -| Option | Type | Default on Supabase Edge Functions | Default elsewhere | -| --------------------- | ----------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `resourceServer` | `UrlOption` | Public origin from `X-Forwarded-*` (or `SUPABASE_PUBLIC_URL`) + `/functions/v1/{SUPABASE_FUNCTION_SLUG}` | None. Throws `MissingResourceServerError` (`MISSING_RESOURCE_SERVER`). | -| `authorizationServer` | `UrlOption` | Public origin + `/auth/v1` | `SUPABASE_PUBLIC_URL`, then `SUPABASE_URL`, each + `/auth/v1`. Throws `MissingAuthorizationServerError` (`MISSING_AUTHORIZATION_SERVER`) if neither is set. | +| Option | Type | Default on Supabase Edge Functions | Default elsewhere | +| --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `resourceServer` | `UrlOption` | Public origin from `X-Forwarded-*` (or `SUPABASE_PUBLIC_URL`) + `/functions/v1/{SUPABASE_FUNCTION_SLUG}` | None. Short-circuits with a 500 and code `MISSING_RESOURCE_SERVER` (`MissingResourceServerError`). | +| `authorizationServer` | `UrlOption` | Public origin + `/auth/v1` | `SUPABASE_PUBLIC_URL`, then `SUPABASE_URL`, each + `/auth/v1`. Short-circuits with a 500 and code `MISSING_AUTHORIZATION_SERVER` (`MissingAuthorizationServerError`) if neither is set. | +| `errors` | `ErrorResponseConfig` | `{ detailed: true }` | `{ detailed: true }` | -`UrlOption` is `string | ((req: Request) => string)`. Without `SUPABASE_FUNCTION_SLUG` the resource path is reconstructed from the request path with `/functions/v1` restored; a request at the root path with no slug throws `MissingResourceServerError`. +`UrlOption` is `string | ((req: Request) => string)`. Without `SUPABASE_FUNCTION_SLUG` the resource path is reconstructed from the request path with `/functions/v1` restored; a request at the root path with no slug short-circuits with a 500 and code `MISSING_RESOURCE_SERVER`. `errors` (an [`ErrorResponseConfig`](#errorresponseconfig)) trims the body of those 500s. ### fromSupabaseUrl @@ -581,7 +586,7 @@ interface ErrorResponseConfig { } ``` -`detailed: false` reduces the error response body to `code` and `message` alone, dropping `source`, `hint`, `docs`, and `details`. The status and `x-supabase-server-error` header are unaffected, and the error object itself keeps everything. See [`error-handling.md`](error-handling.md#trimming-the-response-body). +`detailed: false` reduces the error response body to `code` and `message` alone, dropping `source`, `hint`, `docs`, and `details`. The status and `x-supabase-server-error` header are unaffected, and the error object itself keeps everything. Accepted as `errors` by `withSupabase` and by every middleware that answers directly: `withClaims`, `withRequiredClaims`, `withPostgresClient`, `withPostgresAdminClient`, `withOAuthProtectedResource`. See [`error-handling.md`](error-handling.md#trimming-the-response-body). ### SupabaseEnv diff --git a/docs/error-handling.md b/docs/error-handling.md index f5523f3..13421b0 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -38,7 +38,7 @@ Access-Control-Expose-Headers: x-supabase-server-error The code is repeated in the `x-supabase-server-error` response header, and added to `Access-Control-Expose-Headers` so cross-origin browser code can actually read it. -Every layer that answers a request directly uses this shape: `withSupabase`, and the middleware that short-circuit (`withClaims`, `withRequiredClaims`, `withPostgresClient`). The `@supabase/server/middleware/*` subpaths and `@supabase/server/oauth-protected-resource` are alpha; the error payload documented here is stable either way. +Every layer that answers a request directly uses this shape: `withSupabase`, and the middleware that short-circuit (`withClaims`, `withRequiredClaims`, `withPostgresClient`, `withOAuthProtectedResource`). The `@supabase/server/middleware/*` subpaths and `@supabase/server/oauth-protected-resource` are alpha; the error payload documented here is stable either way. ## Trimming the response body @@ -48,6 +48,8 @@ Every layer that answers a request directly uses this shape: `withSupabase`, and withSupabase({ auth: 'user', errors: { detailed: false } }, handler) ``` +Every middleware that answers directly accepts the same option and trims its own short-circuit responses: `withClaims`, `withRequiredClaims`, `withPostgresClient`, `withPostgresAdminClient`, `withOAuthProtectedResource`. The option is per entry; a pipeline passes it to each one. + ``` HTTP/1.1 401 Unauthorized x-supabase-server-error: MISSING_CREDENTIALS @@ -245,11 +247,13 @@ Set `SUPABASE_SECRET_KEY`, or add a `"default"` entry to `SUPABASE_SECRET_KEYS`, ### `MISSING_RESOURCE_SERVER` -`withOAuthProtectedResource` is running outside Supabase Edge Functions, where it can't derive the resource URL from the request. Pass `resourceServer` — `hint` shows the shape. `withOAuthProtectedResource` treats the environment as Edge Functions when `SUPABASE_FUNCTION_SLUG` or `SB_EXECUTION_ID` is set, or when the host runtime is Deno. `details.runtime` carries the runtime name the SDK detected. +`withOAuthProtectedResource` is running outside Supabase Edge Functions, where it can't derive the resource URL from the request, so it short-circuits with a 500 on every request: the resource URL backs the metadata document, the `WWW-Authenticate` challenge and `ctx.oauthProtectedResource` alike. Pass `resourceServer` — `hint` shows the shape. `withOAuthProtectedResource` treats the environment as Edge Functions when `SUPABASE_FUNCTION_SLUG` or `SB_EXECUTION_ID` is set, or when the host runtime is Deno. `details.runtime` carries the runtime name the SDK detected. + +The escape hatches `resourceMetadataResponse` and `unauthorizedResponse` throw this error rather than returning it. ### `MISSING_AUTHORIZATION_SERVER` -As above for the authorization server. Pass `authorizationServer`, use `fromSupabaseUrl(...)` for Supabase Auth, or set `SUPABASE_PUBLIC_URL` / `SUPABASE_URL`. +As above for the authorization server. Only the metadata document needs it, so the 500 is confined to `GET …/oauth-protected-resource`. Pass `authorizationServer`, use `fromSupabaseUrl(...)` for Supabase Auth, or set `SUPABASE_PUBLIC_URL` / `SUPABASE_URL`. ### `MISSING_CONNECTION_STRING` @@ -263,20 +267,20 @@ Generic environment error. The default code when constructing an `EnvError` your ## How errors surface in each layer -| Function | Pattern | What happens on error | -| ------------------------------ | ------------- | ----------------------------------------------------------------------- | -| `withSupabase()` | Auto-response | Returns the JSON payload above, with CORS and `x-supabase-server-error` | -| `withClaims()` | Auto-response | Same payload, short-circuiting the pipeline | -| `withRequiredClaims()` | Auto-response | Same payload, short-circuiting the pipeline | -| `withPostgresClient()` | Auto-response | Same payload, on an unsupported `role` claim | -| `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` | +| Function | Pattern | What happens on error | +| ------------------------------ | ------------- | ------------------------------------------------------------------------------------------------- | +| `withSupabase()` | Auto-response | Returns the JSON payload above, with CORS and `x-supabase-server-error` | +| `withClaims()` | Auto-response | Same payload, short-circuiting the pipeline | +| `withRequiredClaims()` | Auto-response | Same payload, short-circuiting the pipeline | +| `withPostgresClient()` | Auto-response | Same payload, on an unsupported `role` claim | +| `withOAuthProtectedResource()` | Auto-response | Same payload, when a default URL cannot be derived (a configured URL function's throw propagates) | +| `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` | `verifyAuth()` also has the raw request in hand, so it adds diagnostics `verifyCredentials()` can't see — most usefully, an `Authorization` header that was present but unusable. diff --git a/docs/mcp.md b/docs/mcp.md index 0bb225f..f320fc1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -72,7 +72,7 @@ The handler is passed inline. Passing a separately declared function typed `(req ## URLs -On Supabase Edge Functions the metadata is derived with no configuration: the public origin from the gateway's `X-Forwarded-*` headers (`SUPABASE_PUBLIC_URL` wins when set, but the CLI does not set it), the path as `/functions/v1/{SUPABASE_FUNCTION_SLUG}`, and the issuer as `{origin}/auth/v1`. Supabase CLI 2.117.0 or later injects the slug locally. Without a slug the path is reconstructed from the request, which works for a function served at `/functions/v1/{name}`; a function served at the root path `/` with no slug throws `MISSING_RESOURCE_SERVER`, because there is no function segment to restore. +On Supabase Edge Functions the metadata is derived with no configuration: the public origin from the gateway's `X-Forwarded-*` headers (`SUPABASE_PUBLIC_URL` wins when set, but the CLI does not set it), the path as `/functions/v1/{SUPABASE_FUNCTION_SLUG}`, and the issuer as `{origin}/auth/v1`. Supabase CLI 2.117.0 or later injects the slug locally. Without a slug the path is reconstructed from the request, which works for a function served at `/functions/v1/{name}`; a function served at the root path `/` with no slug answers with a 500 and code `MISSING_RESOURCE_SERVER`, because there is no function segment to restore. Anywhere else, a Next.js route handler, a Worker, a plain server, the app's origin is unrelated to the project's, so pass both URLs: @@ -85,15 +85,17 @@ withOAuthProtectedResource({ }) ``` -- `resourceServer`: the public URL of this endpoint. Required off Edge Functions; `MissingResourceServerError` (`MISSING_RESOURCE_SERVER`) otherwise. -- `authorizationServer`: the OAuth issuer. Falls back to `SUPABASE_PUBLIC_URL`, then `SUPABASE_URL`, each with `/auth/v1`; `MissingAuthorizationServerError` (`MISSING_AUTHORIZATION_SERVER`) if neither is set. `fromSupabaseUrl(projectUrl)` builds it from a project URL. A non-Supabase OAuth 2.1 server (Clerk, WorkOS, Auth0) works too. +- `resourceServer`: the public URL of this endpoint. Required off Edge Functions; every request is answered with a 500 and code `MISSING_RESOURCE_SERVER` otherwise. +- `authorizationServer`: the OAuth issuer. Falls back to `SUPABASE_PUBLIC_URL`, then `SUPABASE_URL`, each with `/auth/v1`; the metadata route answers with a 500 and code `MISSING_AUTHORIZATION_SERVER` if neither is set. `fromSupabaseUrl(projectUrl)` builds it from a project URL. A non-Supabase OAuth 2.1 server (Clerk, WorkOS, Auth0) works too. Both accept a string or `(req: Request) => string` (`UrlOption`). The full config: -| Option | Type | Default on Edge Functions | Default elsewhere | -| --------------------- | ----------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `resourceServer` | `UrlOption` | `{public origin}/functions/v1/{slug}` | none; throws `MissingResourceServerError` | -| `authorizationServer` | `UrlOption` | `{public origin}/auth/v1` | `SUPABASE_PUBLIC_URL`, then `SUPABASE_URL`, each `+ /auth/v1`; else throws `MissingAuthorizationServerError` | +| Option | Type | Default on Edge Functions | Default elsewhere | +| --------------------- | ----------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `resourceServer` | `UrlOption` | `{public origin}/functions/v1/{slug}` | none; `500` with code `MISSING_RESOURCE_SERVER` | +| `authorizationServer` | `UrlOption` | `{public origin}/auth/v1` | `SUPABASE_PUBLIC_URL`, then `SUPABASE_URL`, each `+ /auth/v1`; else `500` with code `MISSING_AUTHORIZATION_SERVER` | + +Either 500 is the library's JSON error response, with the code in the `x-supabase-server-error` header and a `hint` naming the option to set; see [`docs/error-handling.md`](error-handling.md#enverror-codes). A throw from a `resourceServer` or `authorizationServer` function you supplied is yours and propagates. `errors: { detailed: false }` trims either body to `code` and `message`. The middleware contributes `ctx.oauthProtectedResource.resourceMetadataUrl`, the resolved metadata URL, to the downstream context. diff --git a/docs/postgres.md b/docs/postgres.md index 706abc2..dfec071 100644 --- a/docs/postgres.md +++ b/docs/postgres.md @@ -228,6 +228,8 @@ withPostgresAdminClient({ connectionString: 'postgresql://...' }) `connectionString` defaults to the `SUPABASE_DB_URL` environment variable, which Supabase Edge Functions provide automatically. If neither is set the middleware short-circuits with a 500 and code `MISSING_CONNECTION_STRING`, whose `hint` names the option to pass. +`errors: { detailed: false }` trims that response, and `withPostgresClient`'s `UNSUPPORTED_ROLE` refusal, to `code` and `message`; see [`docs/error-handling.md`](error-handling.md#trimming-the-response-body). + Connections are pooled per process, lazily, one pool per connection string (max 4 connections). The pool outlives individual requests — that is what makes this viable on a per-request runtime. Both middleware share that cache, so composing the pair opens one pool, not two. Sharing is safe because everything the scoped half sets is transaction-local: a connection always returns to the pool clean, and an admin query can never inherit a previous caller's claims or role. diff --git a/src/core/parts/construction-failure.ts b/src/core/parts/construction-failure.ts index 7be04ee..890af50 100644 --- a/src/core/parts/construction-failure.ts +++ b/src/core/parts/construction-failure.ts @@ -5,9 +5,11 @@ import type { ErrorResponseConfig } from '../../types.js' const constructionFailure = Symbol.for('@supabase/server:constructionFailure') /** - * Marks an error thrown while a Supabase client is constructed for the - * request. `withSupabase`'s boundary maps only marked errors to a JSON - * response; any other throw escaping a part or the handler propagates. + * Marks an error the library raises while building what a middleware + * contributes to the request: the Supabase clients under `withSupabase`, the + * URLs `withOAuthProtectedResource` advertises. Each boundary maps only marked + * errors to a JSON response; any other throw escaping a part, a configured + * callback or the handler propagates. * * The mark is a non-enumerable symbol property, so the error's class, own * properties and `toJSON` payload are unchanged. diff --git a/src/core/postgres-pool.ts b/src/core/postgres-pool.ts index 5cf0895..37806f0 100644 --- a/src/core/postgres-pool.ts +++ b/src/core/postgres-pool.ts @@ -3,6 +3,7 @@ import pg from 'pg' import { errorResponse } from '../error-response.js' import { Errors, MissingConnectionStringError } from '../errors.js' +import type { ErrorResponseConfig } from '../types.js' const { Pool } = pg @@ -128,6 +129,9 @@ export function resolveConnectionString( */ export function missingConnectionStringResponse( middlewareName: string, + errors?: ErrorResponseConfig, ): Response { - return errorResponse(Errors[MissingConnectionStringError](middlewareName)) + return errorResponse(Errors[MissingConnectionStringError](middlewareName), { + errors, + }) } diff --git a/src/error-response.ts b/src/error-response.ts index b123094..cc9766f 100644 --- a/src/error-response.ts +++ b/src/error-response.ts @@ -11,7 +11,8 @@ import type { ErrorResponseConfig } from './types.js' * of the library returns. * * One place so `withSupabase` and the middleware that answer directly - * (`withClaims`, `withRequiredClaims`, `withPostgresClient`) stay consistent: + * (`withClaims`, `withRequiredClaims`, `withPostgresClient`, + * `withOAuthProtectedResource`) stay consistent: * same body, same `x-supabase-server-error` header, same status. * * @param error - The error to render. diff --git a/src/middleware/claims/index.test.ts b/src/middleware/claims/index.test.ts index 4113c26..e1d24f2 100644 --- a/src/middleware/claims/index.test.ts +++ b/src/middleware/claims/index.test.ts @@ -3,7 +3,11 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import type { JSONWebKeySet } from 'jose' -import { InvalidJwtError } from '../../errors.js' +import { + ErrorCodeHeader, + InvalidJwtError, + JwksNotConfiguredError, +} from '../../errors.js' import { withClaims } from './index.js' describe('withClaims', () => { @@ -137,4 +141,20 @@ describe('withClaims', () => { const body = await res.json() expect(body.message).toContain('JWKS') }) + + it('honors errors: { detailed: false } on its short-circuits', async () => { + vi.stubEnv('SUPABASE_JWKS', '') + vi.stubEnv('SUPABASE_JWKS_URL', '') + const handler = withClaims({ errors: { detailed: false } }, async () => + Response.json({ ok: true }), + ) + + const res = await handler(requestWithToken(rsToken)) + expect(res.status).toBe(500) + expect(res.headers.get(ErrorCodeHeader)).toBe(JwksNotConfiguredError) + expect(await res.json()).toEqual({ + code: JwksNotConfiguredError, + message: expect.stringMatching(/^\[@supabase\/server\]/), + }) + }) }) diff --git a/src/middleware/claims/index.ts b/src/middleware/claims/index.ts index 9c30bb8..ccc9ded 100644 --- a/src/middleware/claims/index.ts +++ b/src/middleware/claims/index.ts @@ -12,7 +12,7 @@ import { JwksFetchFailedError, JwksNotConfiguredError, } from '../../errors.js' -import type { JWTClaims } from '../../types.js' +import type { ErrorResponseConfig, JWTClaims } from '../../types.js' /** * **Alpha.** Configuration for {@link withClaims}. @@ -30,6 +30,13 @@ export interface WithClaimsConfig { * (https endpoint) from the environment. */ jwks?: JSONWebKeySet | URL + + /** + * How much of an error to include in a short-circuit response body. + * + * @see {@link ErrorResponseConfig} + */ + errors?: ErrorResponseConfig } /** @@ -104,6 +111,7 @@ export const withClaims: Middleware< if (!jwks) { return errorResponse( Errors[JwksNotConfiguredError]({ middleware: 'withClaims' }), + { errors: config?.errors }, ) } @@ -122,6 +130,7 @@ export const withClaims: Middleware< jwt: failure.jwt, cause: failure.cause, }), + { errors: config?.errors }, ) } diff --git a/src/middleware/postgres-admin/index.test.ts b/src/middleware/postgres-admin/index.test.ts index b79bc22..15cb80d 100644 --- a/src/middleware/postgres-admin/index.test.ts +++ b/src/middleware/postgres-admin/index.test.ts @@ -62,6 +62,22 @@ describe('withPostgresAdminClient', () => { }) }) + it('honors errors: { detailed: false } on its short-circuit', async () => { + vi.stubEnv('SUPABASE_DB_URL', undefined) + const handler = withPostgresAdminClient( + { errors: { detailed: false } }, + async () => Response.json({ ok: true }), + ) + + const res = await handler(new Request('http://localhost'), seedContext()) + + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ + code: 'MISSING_CONNECTION_STRING', + message: expect.stringMatching(/^\[@supabase\/server\]/), + }) + }) + it('runs the query as-is — no transaction, no claims, no role switch', async () => { const handler = withPostgresAdminClient(async (_req, ctx) => { await ctx.postgresAdmin.query`select * from notes` diff --git a/src/middleware/postgres-admin/index.ts b/src/middleware/postgres-admin/index.ts index d6b0277..fce9a13 100644 --- a/src/middleware/postgres-admin/index.ts +++ b/src/middleware/postgres-admin/index.ts @@ -8,6 +8,7 @@ import { } from '../../core/postgres-pool.js' import type { PostgresApi } from '../../core/postgres-pool.js' import { compileTemplate, ident } from '../../core/sql.js' +import type { ErrorResponseConfig } from '../../types.js' export type { PostgresApi } // `ident` is exported here rather than only from core: it is the companion @@ -26,6 +27,13 @@ export { ident } export interface WithPostgresAdminClientConfig { /** Defaults to `getEnv('SUPABASE_DB_URL')` (from `@supabase/middleware`). */ connectionString?: string + + /** + * How much of an error to include in a short-circuit response body. + * + * @see {@link ErrorResponseConfig} + */ + errors?: ErrorResponseConfig } /** @@ -86,7 +94,10 @@ export const withPostgresAdminClient: Middleware< run: (config) => async () => { const connectionString = resolveConnectionString(config?.connectionString) if (!connectionString) { - return missingConnectionStringResponse('withPostgresAdminClient') + return missingConnectionStringResponse( + 'withPostgresAdminClient', + config?.errors, + ) } const p = getPool(connectionString) diff --git a/src/middleware/postgres/index.test.ts b/src/middleware/postgres/index.test.ts index 671def8..fbe6b74 100644 --- a/src/middleware/postgres/index.test.ts +++ b/src/middleware/postgres/index.test.ts @@ -98,6 +98,43 @@ describe('withPostgresClient', () => { }) }) + it('honors errors: { detailed: false } on the missing-connection-string 500', async () => { + vi.stubEnv('SUPABASE_DB_URL', undefined) + const handler = withPostgresClient( + { errors: { detailed: false } }, + async () => Response.json({ ok: true }), + ) + + const res = await handler(new Request('http://localhost'), { + ...seedContext(), + jwtClaims: null, + }) + + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ + code: 'MISSING_CONNECTION_STRING', + message: expect.stringMatching(/^\[@supabase\/server\]/), + }) + }) + + it('honors errors: { detailed: false } on the unsupported-role 500', async () => { + const handler = withPostgresClient( + { errors: { detailed: false } }, + async () => Response.json({ ok: true }), + ) + + const res = await handler(new Request('http://localhost'), { + ...seedContext(), + jwtClaims: { sub: 'attacker', role: 'service_role' }, + }) + + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ + code: 'UNSUPPORTED_ROLE', + message: expect.stringMatching(/^\[@supabase\/server\]/), + }) + }) + it('prefers config.connectionString over SUPABASE_DB_URL', async () => { const handler = withPostgresClient( { connectionString: 'postgres://localhost/from-config' }, diff --git a/src/middleware/postgres/index.ts b/src/middleware/postgres/index.ts index 3535b88..a29c470 100644 --- a/src/middleware/postgres/index.ts +++ b/src/middleware/postgres/index.ts @@ -10,6 +10,7 @@ import type { PostgresApi } from '../../core/postgres-pool.js' import { compileTemplate, ident } from '../../core/sql.js' import { errorResponse } from '../../error-response.js' import { Errors, UnsupportedRoleError } from '../../errors.js' +import type { ErrorResponseConfig } from '../../types.js' export type { PostgresApi } // `ident` is exported here rather than only from core: it is the companion @@ -34,7 +35,10 @@ const SUPPORTED_ROLES = new Set(['authenticated', 'anon']) * will not. Never silently downgrades a role the caller explicitly asked for: * that returns zero rows and leaves nothing to debug. */ -function resolveRole(claims: RequestClaims | null): string | Response { +function resolveRole( + claims: RequestClaims | null, + errors?: ErrorResponseConfig, +): string | Response { // `role` is typed as a string, but claims come from a token — a // misconfigured custom-claims hook can put anything here. const requested = claims?.role as unknown @@ -52,6 +56,7 @@ function resolveRole(claims: RequestClaims | null): string | Response { requestedRole: requested, supportedRoles: [...SUPPORTED_ROLES], }), + { errors }, ) } @@ -86,6 +91,13 @@ export interface RequestClaims { export interface WithPostgresClientConfig { /** Defaults to `getEnv('SUPABASE_DB_URL')` (from `@supabase/middleware`). */ connectionString?: string + + /** + * How much of an error to include in a short-circuit response body. + * + * @see {@link ErrorResponseConfig} + */ + errors?: ErrorResponseConfig } /** @@ -159,11 +171,14 @@ export const withPostgresClient: Middleware< run: (config) => async (_req, ctx) => { const connectionString = resolveConnectionString(config?.connectionString) if (!connectionString) { - return missingConnectionStringResponse('withPostgresClient') + return missingConnectionStringResponse( + 'withPostgresClient', + config?.errors, + ) } const claims = ctx.jwtClaims - const role = resolveRole(claims) + const role = resolveRole(claims, config?.errors) // Refused before the handler runs and before a connection is checked out. if (role instanceof Response) return role diff --git a/src/middleware/required-claims/index.test.ts b/src/middleware/required-claims/index.test.ts index 5ad8e75..eefaefd 100644 --- a/src/middleware/required-claims/index.test.ts +++ b/src/middleware/required-claims/index.test.ts @@ -13,6 +13,7 @@ import { import type { JSONWebKeySet } from 'jose' import { + ErrorCodeHeader, InvalidJwtError, JwksNotConfiguredError, MissingCredentialsError, @@ -104,6 +105,21 @@ describe('withRequiredClaims', () => { expect(ran).toBe(false) }) + it('honors errors: { detailed: false } on its short-circuits', async () => { + const handler = withRequiredClaims( + { jwks, errors: { detailed: false } }, + async () => Response.json({ ok: true }), + ) + + const res = await handler(requestWithToken()) + expect(res.status).toBe(401) + expect(res.headers.get(ErrorCodeHeader)).toBe(MissingCredentialsError) + expect(await res.json()).toEqual({ + code: MissingCredentialsError, + message: expect.stringMatching(/^\[@supabase\/server\]/), + }) + }) + it('short-circuits 401 UNUSABLE_CREDENTIAL for an sb_* apikey in the Authorization header', async () => { let ran = false const handler = withRequiredClaims({ jwks }, async () => { diff --git a/src/middleware/required-claims/index.ts b/src/middleware/required-claims/index.ts index 0e8f98f..a6fd27e 100644 --- a/src/middleware/required-claims/index.ts +++ b/src/middleware/required-claims/index.ts @@ -19,7 +19,7 @@ import { MissingCredentialsError, UnusableCredentialError, } from '../../errors.js' -import type { JWTClaims } from '../../types.js' +import type { ErrorResponseConfig, JWTClaims } from '../../types.js' /** * **Alpha.** Configuration for {@link withRequiredClaims}. @@ -37,6 +37,13 @@ export interface WithRequiredClaimsConfig { * (https endpoint) from the environment. */ jwks?: JSONWebKeySet | URL + + /** + * How much of an error to include in a short-circuit response body. + * + * @see {@link ErrorResponseConfig} + */ + errors?: ErrorResponseConfig } /** @@ -143,6 +150,7 @@ export const withRequiredClaims: Middleware< inApiKeyHeader: apikey !== null, })), }), + { errors: config?.errors }, ) } @@ -150,6 +158,7 @@ export const withRequiredClaims: Middleware< if (!jwks) { return errorResponse( Errors[JwksNotConfiguredError]({ middleware: 'withRequiredClaims' }), + { errors: config?.errors }, ) } @@ -168,6 +177,7 @@ export const withRequiredClaims: Middleware< jwt: failure.jwt, cause: failure.cause, }), + { errors: config?.errors }, ) } diff --git a/src/oauth-protected-resource/url.ts b/src/oauth-protected-resource/url.ts index 50b4ccd..a82a42f 100644 --- a/src/oauth-protected-resource/url.ts +++ b/src/oauth-protected-resource/url.ts @@ -1,5 +1,6 @@ import { getEnv, runtimeName } from '@supabase/middleware' +import { markConstructionFailure } from '../core/parts/construction-failure.js' import { Errors, MissingAuthorizationServerError, @@ -108,7 +109,9 @@ function edgeResourcePath(req: Request): string { '', ) if (received === '' || received === '/') { - throw Errors[MissingResourceServerError](runtimeName) + throw markConstructionFailure( + Errors[MissingResourceServerError](runtimeName), + ) } return `${EDGE_FUNCTIONS_PATH_PREFIX}${received}` } @@ -122,11 +125,18 @@ function edgeResourcePath(req: Request): string { * * @throws {EnvError} `MISSING_RESOURCE_SERVER` off Edge Functions, or on a * root path with no `SUPABASE_FUNCTION_SLUG` — see {@link edgeResourcePath}. + * The error carries the construction mark: `withOAuthProtectedResource` + * answers it as the JSON error response, while `resourceMetadataResponse` and + * `unauthorizedResponse` let it propagate. * * @internal */ export function defaultResourceServer(req: Request): string { - if (!isEdgeFunctions()) throw Errors[MissingResourceServerError](runtimeName) + if (!isEdgeFunctions()) { + throw markConstructionFailure( + Errors[MissingResourceServerError](runtimeName), + ) + } return `${edgeOrigin(req)}${edgeResourcePath(req)}` } @@ -142,7 +152,8 @@ export function defaultResourceServer(req: Request): string { * neither can displace the origin the client used. * * @throws {EnvError} `MISSING_AUTHORIZATION_SERVER` off Edge Functions with - * neither variable set. + * neither variable set. The error carries the construction mark, as + * {@link defaultResourceServer}'s does. * * @internal */ @@ -155,7 +166,7 @@ export function defaultAuthorizationServer(req: Request): string { const supabaseUrl = getEnv('SUPABASE_URL') if (supabaseUrl) return fromSupabaseUrl(supabaseUrl) - throw Errors[MissingAuthorizationServerError]() + throw markConstructionFailure(Errors[MissingAuthorizationServerError]()) } /** 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 347e249..9e1c427 100644 --- a/src/oauth-protected-resource/with-oauth-protected-resource.test.ts +++ b/src/oauth-protected-resource/with-oauth-protected-resource.test.ts @@ -4,8 +4,10 @@ import type { AddressInfo } from 'node:net' import { afterEach, describe, expect, it, vi } from 'vitest' import { pipeline } from '@supabase/middleware' +import { isConstructionFailure } from '../core/parts/construction-failure.js' import { EnvError, + ErrorCodeHeader, MissingAuthorizationServerError, MissingResourceServerError, } from '../errors.js' @@ -694,52 +696,85 @@ describe('withOAuthProtectedResource - off-platform defaults fail loudly', () => setEnv('SUPABASE_PUBLIC_URL', undefined) } - it('throws MISSING_RESOURCE_SERVER when resourceServer is absent', async () => { + it('answers MISSING_RESOURCE_SERVER as the JSON error response when resourceServer is absent', async () => { offEdgeFunctions() clearEnv() - await expect( - withOAuthProtectedResource(passthrough)( - req('GET', '/api/mcp/oauth-protected-resource', vercelHeaders), - ), - ).rejects.toMatchObject({ - constructor: EnvError, + const res = await withOAuthProtectedResource(passthrough)( + req('GET', '/api/mcp/oauth-protected-resource', vercelHeaders), + ) + expect(res.status).toBe(500) + expect(res.headers.get(ErrorCodeHeader)).toBe(MissingResourceServerError) + expect(res.headers.get('Content-Type')).toContain('application/json') + expect(await res.json()).toMatchObject({ + source: '@supabase/server', code: MissingResourceServerError, - status: 500, + message: expect.stringMatching(/^\[@supabase\/server\]/), + docs: expect.stringContaining('missing_resource_server'), }) }) - it('throws on every request, not just the metadata route', async () => { + it('answers 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) + const res = await withOAuthProtectedResource(passthrough)( + req('POST', '/api/mcp'), + ) + expect(res.status).toBe(500) + expect(res.headers.get(ErrorCodeHeader)).toBe(MissingResourceServerError) + expect((await res.json()).code).toBe(MissingResourceServerError) }) - it('throws MISSING_AUTHORIZATION_SERVER when only resourceServer is set', async () => { + it('answers 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, + const res = await withOAuthProtectedResource( + { resourceServer: 'https://api.example.com/mcp' }, + passthrough, + )(req('GET', '/api/mcp/oauth-protected-resource', vercelHeaders)) + expect(res.status).toBe(500) + expect(res.headers.get(ErrorCodeHeader)).toBe( + MissingAuthorizationServerError, + ) + expect((await res.json()).code).toBe(MissingAuthorizationServerError) + }) + + it('the pipeline form with no config answers the same 500 JSON', async () => { + offEdgeFunctions() + clearEnv() + const res = await pipeline( + [withOAuthProtectedResource()], + passthrough, + )(req('GET', '/api/mcp/oauth-protected-resource', vercelHeaders)) + expect(res.status).toBe(500) + expect(res.headers.get(ErrorCodeHeader)).toBe(MissingResourceServerError) + expect((await res.json()).code).toBe(MissingResourceServerError) + }) + + it('honors errors: { detailed: false } on its short-circuit', async () => { + offEdgeFunctions() + clearEnv() + const res = await withOAuthProtectedResource( + { errors: { detailed: false } }, + passthrough, + )(req('GET', '/api/mcp/oauth-protected-resource', vercelHeaders)) + expect(res.status).toBe(500) + expect(res.headers.get(ErrorCodeHeader)).toBe(MissingResourceServerError) + expect(await res.json()).toEqual({ + code: MissingResourceServerError, + message: expect.stringMatching(/^\[@supabase\/server\]/), }) }) it('the error names the option to set', async () => { offEdgeFunctions() clearEnv() - const call = withOAuthProtectedResource(passthrough)( + const body = await withOAuthProtectedResource(passthrough)( req('GET', '/api/mcp/oauth-protected-resource'), - ) - await expect(call).rejects.toThrow(/resourceServer/) + ).then((r) => r.json()) + expect(body.message).toMatch(/resourceServer/) // The message names what is missing; `hint` names how to supply it. - await expect(call).rejects.toMatchObject({ + expect(body).toMatchObject({ code: MissingResourceServerError, hint: expect.stringMatching(/withOAuthProtectedResource\(\)/), }) @@ -748,17 +783,50 @@ describe('withOAuthProtectedResource - off-platform defaults fail loudly', () => it('the error reports the detected runtime and the Edge Functions markers', async () => { offEdgeFunctions() clearEnv() - const call = withOAuthProtectedResource(passthrough)( + const body = await withOAuthProtectedResource(passthrough)( req('GET', '/api/mcp/oauth-protected-resource'), - ) + ).then((r) => r.json()) // `runtimeName` is std-env's `runtime`, which is `node` under vitest. - await expect(call).rejects.toMatchObject({ + expect(body).toMatchObject({ code: MissingResourceServerError, details: { runtime: 'node' }, hint: expect.stringMatching(/SUPABASE_FUNCTION_SLUG.*SB_EXECUTION_ID/), }) }) + it("a throw from a configured resourceServer function is the caller's and propagates", async () => { + offEdgeFunctions() + clearEnv() + const boom = new Error('resolver failed') + await expect( + withOAuthProtectedResource( + { + resourceServer: () => { + throw boom + }, + }, + passthrough, + )(req('POST', '/api/mcp')), + ).rejects.toBe(boom) + }) + + it('an EnvError a configured function throws itself propagates too', async () => { + // Only the library's own derivation failures carry the construction mark. + offEdgeFunctions() + clearEnv() + await expect( + withOAuthProtectedResource( + { + resourceServer: 'https://api.example.com/mcp', + authorizationServer: () => { + throw new EnvError('caller-level env failure') + }, + }, + passthrough, + )(req('GET', '/api/mcp/oauth-protected-resource')), + ).rejects.toThrow('caller-level env failure') + }) + it('a fully configured stack never reaches the env at all', async () => { offEdgeFunctions() clearEnv() @@ -895,18 +963,15 @@ describe('withOAuthProtectedResource - root path (no function segment)', () => { // With no slug and no path segment there is no function name to restore, so // the reconstruction would advertise a bare `/functions/v1` — a URL that // identifies no resource. Failing loudly matches the off-platform contract. - it('throws MISSING_RESOURCE_SERVER on a bare /oauth-protected-resource (edge default)', async () => { + it('answers MISSING_RESOURCE_SERVER on a bare /oauth-protected-resource (edge default)', async () => { setEnv('SUPABASE_PUBLIC_URL', undefined) setEnv('SUPABASE_FUNCTION_SLUG', undefined) - await expect( - withOAuthProtectedResource(passthrough)( - req('GET', '/oauth-protected-resource'), - ), - ).rejects.toMatchObject({ - constructor: EnvError, - code: MissingResourceServerError, - status: 500, - }) + const res = await withOAuthProtectedResource(passthrough)( + req('GET', '/oauth-protected-resource'), + ) + expect(res.status).toBe(500) + expect(res.headers.get(ErrorCodeHeader)).toBe(MissingResourceServerError) + expect((await res.json()).code).toBe(MissingResourceServerError) }) it('resourceMetadataResponse on a root path throws instead of advertising a bare /functions/v1', () => { @@ -920,6 +985,8 @@ describe('withOAuthProtectedResource - root path (no function segment)', () => { } expect(thrown).toBeInstanceOf(EnvError) expect(thrown).toMatchObject({ code: MissingResourceServerError }) + // The escape hatch throws the same marked error the middleware answers. + expect(isConstructionFailure(thrown)).toBe(true) }) it('SUPABASE_FUNCTION_SLUG rescues a root path with a canonical identifier', async () => { diff --git a/src/oauth-protected-resource/with-oauth-protected-resource.ts b/src/oauth-protected-resource/with-oauth-protected-resource.ts index e0aa03d..c0b6a48 100644 --- a/src/oauth-protected-resource/with-oauth-protected-resource.ts +++ b/src/oauth-protected-resource/with-oauth-protected-resource.ts @@ -1,7 +1,10 @@ import { defineMiddleware } from '@supabase/middleware' import type { Middleware } from '@supabase/middleware' +import { isConstructionFailure } from '../core/parts/construction-failure.js' import { tagPreAuth } from '../core/pre-auth.js' +import { errorResponse } from '../error-response.js' +import type { ErrorResponseConfig } from '../types.js' import { resourceMetadataResponse } from './responses.js' import { getAuthUrl, getResourceMetadataUrl, getResourceUrl } from './url.js' import type { UrlOption } from './url.js' @@ -40,8 +43,9 @@ export interface OAuthProtectedResourceConfig { * 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. + * usually from the request — `(req) => new URL(req.url).origin + '/api/mcp'`. + * Unset there, every request is answered with a `500` and code + * `MISSING_RESOURCE_SERVER`. */ resourceServer?: UrlOption /** @@ -49,11 +53,19 @@ export interface OAuthProtectedResourceConfig { * * 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. + * `/auth/v1` appended; with neither set, the metadata route is answered with + * a `500` and code `MISSING_AUTHORIZATION_SERVER`. Pass + * {@link fromSupabaseUrl} for a specific project, or any other issuer + * directly. */ authorizationServer?: UrlOption + + /** + * How much of an error to include in a short-circuit response body. + * + * @see {@link ErrorResponseConfig} + */ + errors?: ErrorResponseConfig } /** @@ -66,6 +78,10 @@ export interface OAuthProtectedResourceConfig { * 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) + * - Answers a default URL it cannot derive with the JSON error response + * `withSupabase` returns for its own configuration failures (`500`, + * `x-supabase-server-error`); a throw from a configured `resourceServer` or + * `authorizationServer` function is the caller's and propagates * * The metadata route is matched on the path *suffix*, so **any** `GET` or * `OPTIONS` ending in `/oauth-protected-resource` is answered here and never @@ -74,7 +90,8 @@ export interface OAuthProtectedResourceConfig { * 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. + * `SUPABASE_URL`; one that cannot be resolved is reported as + * `MISSING_RESOURCE_SERVER` / `MISSING_AUTHORIZATION_SERVER`. * * Contributes `ctx.oauthProtectedResource` (the resolved metadata URL) to the * downstream context. Nested under `withSupabase`, the key is typed on the @@ -155,16 +172,6 @@ export const withOAuthProtectedResource: Middleware< '/oauth-protected-resource', ) - // RFC 9728 — OAuth Protected Resource Metadata - 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 fetch the // discovery document cross-origin. if (isMetadataRoute && req.method === 'OPTIONS') { @@ -179,10 +186,34 @@ export const withOAuthProtectedResource: Middleware< }) } - const resourceMetadataUrl = getResourceMetadataUrl( - req, - config?.resourceServer, - ) + // Every advertised URL is resolved here, ahead of the yield. A default + // the library cannot derive is a deployment misconfiguration and is + // answered as the JSON error response, the same mapping `withSupabase`'s + // construction boundary applies. A throw from a configured + // `resourceServer` / `authorizationServer` function carries no + // construction mark and propagates as the caller's own. + let resourceMetadataUrl: string + try { + // RFC 9728 — OAuth Protected Resource Metadata + if (isMetadataRoute && req.method === 'GET') { + return resourceMetadataResponse(req, { + resource: getResourceUrl(req, config?.resourceServer), + authorizationServers: [ + getAuthUrl(req, config?.authorizationServer), + ], + }) + } + resourceMetadataUrl = getResourceMetadataUrl( + req, + config?.resourceServer, + ) + } catch (error) { + if (isConstructionFailure(error)) { + return errorResponse(error, { errors: config?.errors }) + } + throw error + } + const response = yield { oauthProtectedResource: { resourceMetadataUrl }, } diff --git a/src/types.ts b/src/types.ts index 26afffe..7f20f00 100644 --- a/src/types.ts +++ b/src/types.ts @@ -370,7 +370,10 @@ export interface WithSupabaseConfig { } /** - * Controls how much of an error {@link withSupabase} puts in the response body. + * Controls how much of an error goes in the response body. Accepted as + * `errors` by {@link withSupabase} and by every middleware that answers a + * request directly: `withClaims`, `withRequiredClaims`, `withPostgresClient`, + * `withPostgresAdminClient` and `withOAuthProtectedResource`. * * @example Trimming the response * ```ts