From 188b18cdd6e0bbb9d52260b89e97ce78ba2f2f9d Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Thu, 27 Aug 2026 12:19:41 -0300 Subject: [PATCH 1/6] feat: specific, self-identifying errors with hints and diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nearly every failure returned `{ message: "Invalid credentials", code: "INVALID_CREDENTIALS" }` — naming neither the cause nor the library it came from. Provenance. All errors now share a `SupabaseServerError` base carrying `source: "@supabase/server"`, a `[@supabase/server]` message prefix (the convention `deprecation.ts` already used for warnings), a `docs` link to the matching `docs/error-handling.md` section, an optional `hint`, and non-sensitive `details`. `toJSON()` renders the wire payload and is picked up by `JSON.stringify`, so logging no longer yields `{}`. One `errorResponse()` helper renders it everywhere, repeating the code in an `x-supabase-server-error` header and adding that to `Access-Control-Expose-Headers` so cross-origin callers can read it. Top-level `message` and `code` are unchanged, so existing consumers and the adapters keep working. Diagnosis. `verifyUserJwt` now returns *why* a token failed instead of `null`, and the mode chain records why each mode fell through, so the final error names the real cause: `MISSING_CREDENTIALS`, `INVALID_API_KEY`, `INVALID_JWT`, plus `JWKS_NOT_CONFIGURED`, `JWKS_FETCH_FAILED` and `NO_KEYS_CONFIGURED` for states where no request could ever have succeeded. `INVALID_CREDENTIALS` stays exported as the fallback. Hints cover the mistakes people actually make — a secret key sent to a publishable-only endpoint, a legacy anon/service_role key, an `Authorization` header without the `Bearer` scheme, a JWT with no `kid`, an expired token, a JWKS from the wrong project. The middleware that answer directly get the same treatment rather than their own hand-rolled bodies: `withClaims` / `withRequiredClaims` report `MISSING_JWKS`, `MISSING_CREDENTIALS`, `INVALID_JWT`; `withPostgresClient` / `withPostgresAdminClient` report `MISSING_CONNECTION_STRING` and a catalogued `UNSUPPORTED_ROLE`. `details` never carries key values or token payloads: API keys are reported by prefix format, named keys by name, JWTs by `alg`/`kid` only. Note: server misconfiguration now surfaces as 500 rather than 401. A missing or unreachable JWKS, or an auth mode no configured key can match, are not the caller's fault. --- docs/api-reference.md | 164 +++- docs/error-handling.md | 345 +++++-- docs/postgres.md | 2 +- src/core/create-admin-client.ts | 5 +- src/core/create-context-client.ts | 5 +- src/core/postgres-pool.ts | 13 +- src/core/utils/classify-credentials.ts | 20 + src/core/verify-auth.test.ts | 52 ++ src/core/verify-auth.ts | 63 +- src/core/verify-credentials.test.ts | 206 ++++- src/core/verify-credentials.ts | 396 +++++--- src/core/verify-user-jwt.ts | 223 ++++- src/create-supabase-context.ts | 14 +- src/error-response.ts | 27 + src/errors.test.ts | 142 +++ src/errors.ts | 842 ++++++++++++++++-- src/index.ts | 18 + src/middleware/claims/index.test.ts | 4 +- src/middleware/claims/index.ts | 35 +- src/middleware/postgres-admin/index.test.ts | 6 +- src/middleware/postgres/index.test.ts | 16 +- src/middleware/postgres/index.ts | 17 +- src/middleware/required-claims/index.test.ts | 34 +- src/middleware/required-claims/index.ts | 51 +- .../with-oauth-protected-resource.test.ts | 6 +- src/with-supabase.test.ts | 104 ++- src/with-supabase.ts | 47 +- 27 files changed, 2433 insertions(+), 424 deletions(-) create mode 100644 src/core/utils/classify-credentials.ts create mode 100644 src/error-response.ts create mode 100644 src/errors.test.ts diff --git a/docs/api-reference.md b/docs/api-reference.md index 58c0303..197dc53 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -184,8 +184,11 @@ Contributes `ctx.jwtClaims` by verifying the caller's Bearer token against the p Behavior: - No `Authorization: Bearer` token, or an `sb_*` API key in that position: contributes `null` and the request proceeds as anonymous. -- Token present but invalid: short-circuits with a 401 and `{ message, code: 'INVALID_CREDENTIALS' }`. -- Token present but no JWKS configured: short-circuits with a 500 and `{ message, code: 'ENV_ERROR' }`. Verification is required; the middleware has no decode-only mode. +- Token present but invalid: short-circuits with a 401 and code `INVALID_JWT`, naming the specific reason (expired, bad signature, unknown `kid`, malformed, no `sub`). +- Token present but no JWKS configured: short-circuits with a 500 and code `JWKS_NOT_CONFIGURED` — the same code `withSupabase`'s `user` mode reports, with a `hint` naming this middleware's `jwks` option. Verification is required; the middleware has no decode-only mode. +- Remote JWKS unreachable: short-circuits with a 500 and code `JWKS_FETCH_FAILED`. + +Responses use the standard [error payload](error-handling.md#what-a-failure-looks-like). `withClaims` is not an auth gate. It never rejects a request that has no token, so `[withClaims(), withSupabaseClient()]` is not the composable form of `withSupabase({ auth: 'user' })` and accepts anonymous callers. To require an authenticated caller, compose `withRequiredClaims` (`@supabase/server/middleware/required-claims`) instead. The two entries share the `jwtClaims` key, so a pipeline picks "claims if present" or "claims required"; composing both is a compile-time conflict. @@ -218,9 +221,12 @@ The user-mode auth gate. Verifies the caller's Bearer token against the project Behavior: -- No `Authorization: Bearer` token, or an `sb_*` API key in that position: short-circuits with a 401 and `{ message, code: 'INVALID_CREDENTIALS' }`. The handler never runs. -- Token present but invalid: the same 401. -- Token present but no JWKS configured: short-circuits with a 500 and `{ message, code: 'ENV_ERROR' }`. Verification is required; the middleware has no decode-only mode. +- No `Authorization: Bearer` token, or an `sb_*` API key in that position: short-circuits with a 401 and code `MISSING_CREDENTIALS`. The handler never runs. +- Token present but invalid: a 401 with code `INVALID_JWT`, naming the specific reason. +- Token present but no JWKS configured: short-circuits with a 500 and code `JWKS_NOT_CONFIGURED` — the same code `withSupabase`'s `user` mode reports, with a `hint` naming this middleware's `jwks` option. Verification is required; the middleware has no decode-only mode. +- Remote JWKS unreachable: short-circuits with a 500 and code `JWKS_FETCH_FAILED`. + +Responses use the standard [error payload](error-handling.md#what-a-failure-looks-like). `withRequiredClaims` is the required-caller counterpart to `withClaims`: "claims required" rather than "claims if present". The two share the `jwtClaims` key, so composing both in one pipeline is a compile-time conflict. @@ -275,7 +281,7 @@ Only `authenticated` and `anon` are assumed. A verified token naming any other r Requires `ctx.jwtClaims` upstream — supplied by `withSupabase` or by `withClaims` in a standalone `pipeline`. Composing it without one is a compile-time error. -Short-circuits with a 500 and `{ message, code: 'ENV_ERROR' }` when no connection string is available. +Short-circuits with a 500 and code `MISSING_CONNECTION_STRING` when no connection string is available. Needs raw TCP: Node, Deno, Bun, and the Supabase Edge runtime, not Workers-style isolates. `pg` is an optional peer dependency. @@ -369,7 +375,7 @@ Contributes `ctx.postgresAdmin` — a `pg` client that **bypasses RLS**. Queries Declares no upstream prerequisite, so it composes in any auth mode including `'secret'` and `'none'`. Shares the pool cache with `withPostgresClient` — same connection string, one pool. -Short-circuits with a 500 and `{ message, code: 'ENV_ERROR' }` when no connection string is available. +Short-circuits with a 500 and code `MISSING_CONNECTION_STRING` when no connection string is available. Authorization is the caller's responsibility: RLS is not consulted, so per-user scoping must be an explicit `where` clause. @@ -550,21 +556,74 @@ import type { ## Error Classes +### SupabaseServerError + +Base class for every error the library produces — catch this to handle anything from `@supabase/server`. + +```ts +abstract class SupabaseServerError extends Error { + readonly source: '@supabase/server' + abstract readonly status: number + readonly code: string + readonly hint?: string // actionable next step + readonly docs: string // link to docs/error-handling.md# + readonly details?: Record // non-sensitive diagnostics + toJSON(): ErrorPayload +} +``` + +`message` is always prefixed `[@supabase/server]`. `details` never contains key values or token payloads. `toJSON()` is picked up by `JSON.stringify`, so logging the error yields the full diagnostics. + ### EnvError ```ts -class EnvError extends Error { +class EnvError extends SupabaseServerError { readonly status: 500 - readonly code: string + constructor( + message: string, + code?: string, + options?: SupabaseServerErrorOptions, + ) } ``` ### AuthError ```ts -class AuthError extends Error { - readonly status: number // 401 or 500 - readonly code: string +class AuthError extends SupabaseServerError { + readonly status: number // 401 = bad credentials, 500 = server misconfigured + constructor( + message: string, + code?: string, + status?: number, + options?: SupabaseServerErrorOptions, + ) +} +``` + +### ErrorPayload + +The JSON body every auto-responding layer returns, and the return type of `toJSON()`. + +```ts +interface ErrorPayload { + source: '@supabase/server' + code: string + message: string + hint?: string + docs: string + details?: Record +} +``` + +### SupabaseServerErrorOptions + +```ts +interface SupabaseServerErrorOptions { + hint?: string + details?: Record + docs?: string // overrides the generated URL + cause?: unknown } ``` @@ -572,18 +631,30 @@ class AuthError extends Error { ## Error Code Constants -| Constant | Value | Class | Meaning | -| ----------------------------------- | ----------------------------------- | ----------- | -------------------------------------------------------------- | -| `EnvGenericError` | `'ENV_ERROR'` | `EnvError` | Generic environment error | -| `MissingSupabaseURLError` | `'MISSING_SUPABASE_URL'` | `EnvError` | `SUPABASE_URL` not set | -| `MissingPublishableKeyError` | `'MISSING_PUBLISHABLE_KEY'` | `EnvError` | Named publishable key not found | -| `MissingDefaultPublishableKeyError` | `'MISSING_DEFAULT_PUBLISHABLE_KEY'` | `EnvError` | No default publishable key | -| `MissingSecretKeyError` | `'MISSING_SECRET_KEY'` | `EnvError` | Named secret key not found | -| `MissingDefaultSecretKeyError` | `'MISSING_DEFAULT_SECRET_KEY'` | `EnvError` | No default secret key | -| `AuthGenericError` | `'AUTH_ERROR'` | `AuthError` | Generic auth error | -| `InvalidCredentialsError` | `'INVALID_CREDENTIALS'` | `AuthError` | No credential matched, or JWT failed verification | -| `CreateSupabaseClientError` | `'CREATE_SUPABASE_CLIENT_ERROR'` | `AuthError` | Client creation failed after auth | -| `UnsupportedRoleError` | `'UNSUPPORTED_ROLE'` | — | `withPostgresClient` will not assume the caller's `role` claim | +| Constant | Value | Class | Meaning | +| ----------------------------------- | ----------------------------------- | ----------- | -------------------------------------------------------------------- | +| `EnvGenericError` | `'ENV_ERROR'` | `EnvError` | Generic environment error | +| `MissingSupabaseURLError` | `'MISSING_SUPABASE_URL'` | `EnvError` | `SUPABASE_URL` not set | +| `MissingPublishableKeyError` | `'MISSING_PUBLISHABLE_KEY'` | `EnvError` | Named publishable key not found | +| `MissingDefaultPublishableKeyError` | `'MISSING_DEFAULT_PUBLISHABLE_KEY'` | `EnvError` | No default publishable key | +| `MissingSecretKeyError` | `'MISSING_SECRET_KEY'` | `EnvError` | Named secret key not found | +| `MissingDefaultSecretKeyError` | `'MISSING_DEFAULT_SECRET_KEY'` | `EnvError` | No default secret key | +| `MissingResourceServerError` | `'MISSING_RESOURCE_SERVER'` | `EnvError` | `withOAuthProtectedResource` cannot derive a `resourceServer` | +| `MissingAuthorizationServerError` | `'MISSING_AUTHORIZATION_SERVER'` | `EnvError` | `withOAuthProtectedResource` cannot derive an authorization server | +| `AuthGenericError` | `'AUTH_ERROR'` | `AuthError` | Generic auth error (401) | +| `MissingCredentialsError` | `'MISSING_CREDENTIALS'` | `AuthError` | Request carried no usable credentials (401) | +| `InvalidApiKeyError` | `'INVALID_API_KEY'` | `AuthError` | `apikey` matched no configured key (401) | +| `InvalidJwtError` | `'INVALID_JWT'` | `AuthError` | JWT failed verification (401) | +| `InvalidCredentialsError` | `'INVALID_CREDENTIALS'` | `AuthError` | Fallback credential failure (401) | +| `JwksNotConfiguredError` | `'JWKS_NOT_CONFIGURED'` | `AuthError` | JWT sent but no JWKS configured (500) | +| `JwksFetchFailedError` | `'JWKS_FETCH_FAILED'` | `AuthError` | Remote JWKS unreachable or unusable (500) | +| `NoKeysConfiguredError` | `'NO_KEYS_CONFIGURED'` | `AuthError` | Auth mode no configured key can match (500) | +| `UnsupportedRoleError` | `'UNSUPPORTED_ROLE'` | `AuthError` | `withPostgresClient` will not assume the caller's `role` claim (500) | +| `CreateSupabaseClientError` | `'CREATE_SUPABASE_CLIENT_ERROR'` | `AuthError` | Client creation failed after auth (500) | + +Also exported: `ErrorSource` (`'@supabase/server'`) and `ErrorCodeHeader` (`'x-supabase-server-error'`). + +See [`error-handling.md`](error-handling.md) for the meaning, `hint`, and `details` of each code. --- @@ -592,13 +663,44 @@ class AuthError extends Error { ```ts const Errors: { [MissingSupabaseURLError]: () => EnvError - [MissingPublishableKeyError]: (name: string) => EnvError - [MissingDefaultPublishableKeyError]: () => EnvError - [MissingSecretKeyError]: (name: string) => EnvError - [MissingDefaultSecretKeyError]: () => EnvError - [InvalidCredentialsError]: () => AuthError - [CreateSupabaseClientError]: () => AuthError + [MissingPublishableKeyError]: (name, configuredKeyNames?) => EnvError + [MissingDefaultPublishableKeyError]: (configuredKeyNames?) => EnvError + [MissingSecretKeyError]: (name, configuredKeyNames?) => EnvError + [MissingDefaultSecretKeyError]: (configuredKeyNames?) => EnvError + [MissingResourceServerError]: () => EnvError + [MissingAuthorizationServerError]: () => EnvError + [MissingCredentialsError]: (context: AuthFailureContext) => AuthError + [InvalidApiKeyError]: (context: AuthFailureContext) => AuthError + [InvalidJwtError]: (context: PartialContext & JwtFailure) => AuthError + [InvalidCredentialsError]: (context?: AuthFailureContext) => AuthError + [JwksNotConfiguredError]: ( + context?: PartialContext & { middleware? }, + ) => AuthError + [JwksFetchFailedError]: (context: PartialContext & { reason }) => AuthError + [NoKeysConfiguredError]: ( + context: AuthFailureContext & { mode; keyKind }, + ) => AuthError + [UnsupportedRoleError]: (context: { + requestedRole + supportedRoles + }) => AuthError + [CreateSupabaseClientError]: (options?: { cause?: unknown }) => AuthError } ``` -Keyed by error code constant. Each entry returns a pre-configured error instance. +Keyed by error code constant. Each entry returns an error pre-configured with `hint`, `docs`, and non-sensitive `details`. The named-key factories accept the configured key names so they can be reported in the message without exposing key values. + +### AuthFailureContext + +Non-sensitive diagnostics the auth pipeline passes to the factories. + +```ts +interface AuthFailureContext { + authModes: readonly string[] + received: { + authorization: 'bearer' | 'non-bearer-scheme' | 'absent' + apikey: 'absent' | 'publishable' | 'secret' | 'legacy-jwt' | 'unrecognized' + } + configuredKeyNames?: Record +} +``` diff --git a/docs/error-handling.md b/docs/error-handling.md index 14b67cd..aaf6bd7 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -1,64 +1,246 @@ # Error Handling +Every error this library produces identifies itself and tells you what to do about it. An error carries: + +| Field | Description | +| --------- | ------------------------------------------------------------------------------------- | +| `source` | Always `"@supabase/server"` — which library produced this | +| `code` | Machine-readable code, e.g. `MISSING_CREDENTIALS` | +| `message` | Human-readable description, prefixed `[@supabase/server]` | +| `hint` | The actionable next step. Omitted when there isn't a useful one | +| `docs` | Link to the section of this page for `code` | +| `details` | Structured diagnostics — accepted auth modes, what the request carried, key **names** | +| `status` | HTTP status code (on the error object; not in the JSON body) | + +`details` never contains secret material: no key values, no token payloads. API keys are reported by _format_ (`"secret"`, `"publishable"`, `"legacy-jwt"`), named keys by _name_ only, and JWTs by their public `alg` / `kid` header fields. + +## What a failure looks like + +``` +HTTP/1.1 401 Unauthorized +x-supabase-server-error: MISSING_CREDENTIALS +Access-Control-Expose-Headers: x-supabase-server-error +``` + +```json +{ + "source": "@supabase/server", + "code": "MISSING_CREDENTIALS", + "message": "[@supabase/server] No credentials found on the request. This endpoint accepts auth mode(s): \"user\", \"publishable\".", + "hint": "Send one of: Authorization: Bearer (for auth mode \"user\"); apikey: (for auth mode \"publishable\").", + "docs": "https://github.com/supabase/server/blob/main/docs/error-handling.md#missing_credentials", + "details": { + "acceptedAuthModes": ["user", "publishable"], + "received": { "authorization": "absent", "apikey": "absent" } + } +} +``` + +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`). + ## Error classes -The SDK has two error classes, both with `status` (HTTP code) and `code` (machine-readable string) properties. +``` +Error +└── SupabaseServerError ← catch this for anything from @supabase/server + ├── EnvError ← always status 500 + └── AuthError ← status 401 or 500 +``` -### EnvError +```ts +import { SupabaseServerError } from '@supabase/server' -Thrown when a required environment variable is missing or malformed. Always `status: 500` — these are server configuration issues, not client errors. +try { + const supabase = createAdminClient() +} catch (e) { + if (e instanceof SupabaseServerError) { + console.error(e.code, e.message, e.hint, e.docs) + return Response.json(e.toJSON(), { status: e.status }) + } + throw e +} +``` -| Code | Meaning | -| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| `MISSING_SUPABASE_URL` | `SUPABASE_URL` is not set | -| `MISSING_PUBLISHABLE_KEY` | Named publishable key not found in `SUPABASE_PUBLISHABLE_KEYS` | -| `MISSING_DEFAULT_PUBLISHABLE_KEY` | No default publishable key found | -| `MISSING_SECRET_KEY` | Named secret key not found in `SUPABASE_SECRET_KEYS` | -| `MISSING_DEFAULT_SECRET_KEY` | No default secret key found | -| `MISSING_RESOURCE_SERVER` | `withOAuthProtectedResource` has no `resourceServer` and is not on Edge Functions | -| `MISSING_AUTHORIZATION_SERVER` | `withOAuthProtectedResource` has no `authorizationServer`, and neither `SUPABASE_PUBLIC_URL` nor `SUPABASE_URL` is set | -| `ENV_ERROR` | Generic environment error | +`toJSON()` returns the payload above, and is picked up automatically by `JSON.stringify` — so logging the error yields the full diagnostics instead of `{}`. -### AuthError +## AuthError codes -Thrown when authentication or authorization fails. Status is `401` for invalid credentials, `500` for server-side auth failures. +Thrown when authentication fails. `401` means the request's credentials are at fault. **`500` means the server is misconfigured** — the request could not have succeeded no matter what it sent, so don't blame the caller. -| Code | Status | Meaning | -| ------------------------------ | ------ | ----------------------------------------------------------------------------------------- | -| `INVALID_CREDENTIALS` | 401 | No credential matched any allowed auth mode, or a JWT was present but failed verification | -| `ENV_ERROR` | 500 | `user` mode is allowed, a user token is present, and no JWKS source is configured | -| `CREATE_SUPABASE_CLIENT_ERROR` | 500 | Auth succeeded but client creation failed | -| `AUTH_ERROR` | 401 | Generic authentication error | +| Code | Status | Meaning | +| --------------------------------------------------------------- | ------ | ------------------------------------------------------------------- | +| [`MISSING_CREDENTIALS`](#missing_credentials) | 401 | The request carried no usable credentials | +| [`INVALID_API_KEY`](#invalid_api_key) | 401 | An `apikey` was sent but matched no configured key | +| [`INVALID_JWT`](#invalid_jwt) | 401 | A JWT was sent but failed verification | +| [`INVALID_CREDENTIALS`](#invalid_credentials) | 401 | Fallback when nothing more specific applies | +| [`JWKS_NOT_CONFIGURED`](#jwks_not_configured) | 500 | A JWT was sent but no JWKS is configured to verify it | +| [`JWKS_FETCH_FAILED`](#jwks_fetch_failed) | 500 | The remote JWKS could not be fetched or parsed | +| [`NO_KEYS_CONFIGURED`](#no_keys_configured) | 500 | An auth mode was requested that no configured key could ever match | +| [`UNSUPPORTED_ROLE`](#unsupported_role) | 500 | The caller's `role` claim names a role `withPostgresClient` refuses | +| [`CREATE_SUPABASE_CLIENT_ERROR`](#create_supabase_client_error) | 500 | Auth succeeded but client creation failed | +| [`AUTH_ERROR`](#auth_error) | 401 | Generic authentication error | -## How errors surface in each layer +### `MISSING_CREDENTIALS` -Different layers of the SDK handle errors differently. Understanding which pattern each function uses prevents surprises. +Neither an `apikey` header nor a usable `Authorization: Bearer` token was present, and no accepted auth mode allows that. -| Function | Pattern | What happens on error | -| ------------------------------ | ------------- | ------------------------------------------------------------------------ | -| `withSupabase()` | Auto-response | Returns `Response.json({ message, code }, { status })` with CORS headers | -| `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` | +`details.acceptedAuthModes` lists what the endpoint accepts; `hint` tells you exactly which header to send for each. -The two client factory functions (`createContextClient`, `createAdminClient`) are the only ones that throw. Everything else returns a result tuple `{ data, error }`. +Watch for `details.received.authorization` being `"non-bearer-scheme"`. Credentials are only read from `Authorization: Bearer ` — a wrong scheme, wrong casing (`bearer`), a bare token, or an `sb_*` API key in that header produces no user credential at all, and the `hint` will say which of those happened. -## Handling errors in withSupabase +### `INVALID_API_KEY` -`withSupabase` handles errors automatically. If auth fails, the caller receives a JSON response: +An `apikey` header was present but matched none of the keys configured for the attempted modes. -```json -{ "message": "Invalid credentials", "code": "INVALID_CREDENTIALS" } -``` +The `hint` prioritises format mismatches, since sending the wrong _kind_ of key is the most common cause: + +- a secret key sent to a `publishable`-only endpoint (or the reverse) +- a legacy JWT-style `anon` / `service_role` key, where an `sb_publishable_…` / `sb_secret_…` key is expected +- a value that isn't a Supabase API key at all + +Otherwise the key was well-formed but simply unknown — usually a different Supabase project. `details.configuredKeyNames` lists the names configured for the attempted modes, and `details.received.apikey` gives the format of what you sent. + +### `INVALID_JWT` + +A JWT was present in `Authorization` but failed verification. The message names the specific reason and `hint` explains it: + +| Reason | Usual cause | +| -------------------------------------------- | -------------------------------------------------------- | +| the token has expired | Stale access token, or server clock skew | +| the signature did not verify | JWKS belongs to a different project | +| no key in the JWKS matches the token's `kid` | Wrong project, or a rotated signing key with stale JWKS | +| its header is missing `alg` or `kid` | Legacy JWT signed with the shared JWT secret | +| it has no `sub` claim | Not a user token — likely an `anon` / `service_role` JWT | +| a registered claim failed validation | `nbf` in the future, or a mismatched `aud` / `iss` | +| the token is malformed | Truncated, URL-encoded, or quoted token | + +`details.jwt` carries the token's `alg` and `kid` — both client-supplied and public — which is what you need to debug a JWKS mismatch. Claim values are never included. + +A present-but-invalid JWT rejects immediately rather than falling through to the next auth mode, so this code always wins over a later mode's failure. + +### `INVALID_CREDENTIALS` + +Fallback code, returned when a credential was present but no more specific code applies. + +> **Changed in v1.6.** This used to be the only code returned for a failed request. The specific codes above now cover essentially every real failure, so match on those instead. `INVALID_CREDENTIALS` and `Errors[InvalidCredentialsError]()` remain exported and working. + +### `JWKS_NOT_CONFIGURED` + +Auth mode `"user"` was requested and a JWT was supplied, but no JWKS is configured — the token cannot be verified. + +This is a **`500`**, not a `401`. The endpoint can never authenticate a user in this state. + +Set `SUPABASE_JWKS_URL` (e.g. `https://.supabase.co/auth/v1/.well-known/jwks.json`) or `SUPABASE_JWKS` (inline JSON), or pass `env.jwks`. + +> A **malformed** value resolves to `null` rather than erroring, and surfaces here. `SUPABASE_JWKS` must be valid JSON; `SUPABASE_JWKS_URL` must be `https` (plain `http` is only accepted for loopback hosts, so the Supabase CLI works against `http://localhost:54321`). + +`withClaims` / `withRequiredClaims` report this same code when they reach verification without a JWKS — they only get there with a token in hand, so the situation is identical. Their `hint` names their own `jwks` option instead of `env.jwks`, and `details.middleware` says which one asked. + +### `JWKS_FETCH_FAILED` + +The remote JWKS endpoint could not be reached, timed out, or returned something unusable — so a token that may well be valid could not be verified. + +A **`500`**: an upstream outage is not the caller's fault. The underlying error is attached as `cause`. -with the appropriate HTTP status code and CORS headers. Your handler never runs. +### `NO_KEYS_CONFIGURED` -If you need custom error formatting, use `createSupabaseContext` instead: +A `publishable` or `secret` auth mode was requested, but no key it could match is configured. Covers both an empty key set and a named mode like `publishable:mobile` when no `"mobile"` key exists. + +A **`500`** — that mode can never match any request. `details.mode` names the offending mode and `details.configuredKeyNames` lists what _is_ configured. + +This is only reported once every mode has been tried. With `auth: ['publishable:mobile', 'secret']`, a valid secret key still succeeds even though the first mode is unreachable. + +### `UNSUPPORTED_ROLE` + +`withPostgresClient` will not assume the Postgres role the caller's verified `role` claim names, and refuses rather than silently running the query as `anon` — which would return zero rows and leave nothing to debug. + +- `role: "service_role"` — that role bypasses RLS, the guarantee this middleware exists to provide. `hint` points at `withPostgresAdminClient` if bypassing RLS is intended. +- any other custom role — not supported yet; `details.supportedRoles` lists what is. +- a non-string `role` claim — a misconfigured custom-claims hook. + +### `CREATE_SUPABASE_CLIENT_ERROR` + +Auth succeeded but `createClient()` failed — almost always a missing or malformed `SUPABASE_URL` or API key. The underlying error is attached as `cause`. + +When the cause is an `EnvError`, its specific code (e.g. `MISSING_DEFAULT_PUBLISHABLE_KEY`) is preserved instead, along with that error's `hint` and `details`. + +### `AUTH_ERROR` + +Generic authentication error. The default code when constructing an `AuthError` yourself. + +## EnvError codes + +Thrown when a required environment variable is missing or malformed. Always `status: 500`. + +| Code | Meaning | +| --------------------------------------------------------------------- | ------------------------------------------------------------------ | +| [`MISSING_SUPABASE_URL`](#missing_supabase_url) | `SUPABASE_URL` is not set | +| [`MISSING_PUBLISHABLE_KEY`](#missing_publishable_key) | Named publishable key not found in `SUPABASE_PUBLISHABLE_KEYS` | +| [`MISSING_DEFAULT_PUBLISHABLE_KEY`](#missing_default_publishable_key) | No default publishable key found | +| [`MISSING_SECRET_KEY`](#missing_secret_key) | Named secret key not found in `SUPABASE_SECRET_KEYS` | +| [`MISSING_DEFAULT_SECRET_KEY`](#missing_default_secret_key) | No default secret key found | +| [`MISSING_RESOURCE_SERVER`](#missing_resource_server) | `withOAuthProtectedResource` cannot derive a `resourceServer` | +| [`MISSING_AUTHORIZATION_SERVER`](#missing_authorization_server) | `withOAuthProtectedResource` cannot derive an authorization server | +| [`ENV_ERROR`](#env_error) | Generic environment error | + +### `MISSING_SUPABASE_URL` + +Set `SUPABASE_URL` to your project URL (`https://.supabase.co`), or pass `env.url`. A local Supabase CLI stack uses `http://localhost:54321`. + +### `MISSING_PUBLISHABLE_KEY` + +The requested named publishable key doesn't exist. The message and `details.configuredKeyNames` list which names _are_ configured. + +Add the entry to `SUPABASE_PUBLISHABLE_KEYS` — a JSON object of name → key — or pass `env.publishableKeys`. + +### `MISSING_DEFAULT_PUBLISHABLE_KEY` + +Set `SUPABASE_PUBLISHABLE_KEY`, or add a `"default"` entry to `SUPABASE_PUBLISHABLE_KEYS`, or pass `env.publishableKeys`. + +### `MISSING_SECRET_KEY` + +As `MISSING_PUBLISHABLE_KEY`, for `SUPABASE_SECRET_KEYS` / `env.secretKeys`. + +### `MISSING_DEFAULT_SECRET_KEY` + +Set `SUPABASE_SECRET_KEY`, or add a `"default"` entry to `SUPABASE_SECRET_KEYS`, or pass `env.secretKeys`. + +### `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. + +### `MISSING_AUTHORIZATION_SERVER` + +As above for the authorization server. Pass `authorizationServer`, use `fromSupabaseUrl(...)` for Supabase Auth, or set `SUPABASE_PUBLIC_URL` / `SUPABASE_URL`. + +### `ENV_ERROR` + +Generic environment error. The default code when constructing an `EnvError` yourself. + +## 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` | + +`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. + +## Custom error formatting + +`withSupabase` responds for you. To shape the response yourself, use `createSupabaseContext`: ```ts import { createSupabaseContext } from '@supabase/server' @@ -70,17 +252,15 @@ export default { }) if (error) { - // Custom error format + // Log everything, return only what the caller needs. + console.error(error.code, error.message, error.hint, error.details) return Response.json( - { - success: false, - error: { message: error.message, code: error.code }, - }, + { success: false, error: { message: error.message, code: error.code } }, { status: error.status }, ) } - const { data } = await ctx!.supabase.from('todos').select() + const { data } = await ctx.supabase.from('todos').select() return Response.json({ success: true, data }) }, } @@ -91,21 +271,9 @@ export default { The Hono adapter throws an `HTTPException` when auth fails. Access the original `AuthError` via `.cause`: ```ts -import { Hono } from 'hono' -import { HTTPException } from 'hono/http-exception' -import { withSupabase } from '@supabase/server/adapters/hono' - -const app = new Hono() - -app.use('*', withSupabase({ auth: 'user' })) - app.onError((err, c) => { - if (err instanceof HTTPException && err.cause) { - const authError = err.cause - return c.json( - { message: authError.message, code: authError.code }, - err.status, - ) + if (err instanceof HTTPException && err.cause instanceof AuthError) { + return c.json(err.cause.toJSON(), err.status) } return c.json({ message: 'Internal error' }, 500) }) @@ -113,44 +281,32 @@ app.onError((err, c) => { ## Handling errors in core primitives -Result-tuple functions: - ```ts import { verifyAuth, resolveEnv } from '@supabase/server/core' -// verifyAuth returns { data, error } const { data: auth, error } = await verifyAuth(request, { auth: 'user' }) if (error) { - return Response.json({ message: error.message }, { status: error.status }) + return Response.json(error.toJSON(), { status: error.status }) } -// resolveEnv returns { data, error } const { data: env, error: envError } = resolveEnv() if (envError) { - console.error(`Config issue [${envError.code}]: ${envError.message}`) + console.error(`[${envError.code}] ${envError.message}\n${envError.hint}`) } ``` Client factories throw — wrap them in try/catch: ```ts -import { - verifyAuth, - createContextClient, - createAdminClient, -} from '@supabase/server/core' -import { EnvError } from '@supabase/server' - -const { data: auth, error } = await verifyAuth(request, { auth: 'user' }) -// ... handle error ... +import { createContextClient } from '@supabase/server/core' +import { SupabaseServerError } from '@supabase/server' try { - const supabase = createContextClient({ auth: { token: auth!.token } }) - const supabaseAdmin = createAdminClient() + const supabase = createContextClient({ auth: { token: auth.token } }) } catch (e) { - if (e instanceof EnvError) { - console.error(`Config issue [${e.code}]: ${e.message}`) - return Response.json({ message: e.message }, { status: 500 }) + if (e instanceof SupabaseServerError) { + console.error(e.code, e.message, e.hint) + return Response.json(e.toJSON(), { status: e.status }) } throw e } @@ -158,38 +314,39 @@ try { ## Using the Errors factory map -The `Errors` object provides factory functions for creating error instances by code. Useful when building custom error handling or testing: +`Errors` provides a factory per code, each returning a fully-populated error. ```ts import { Errors, MissingSupabaseURLError, - InvalidCredentialsError, + MissingSecretKeyError, } from '@supabase/server' -// Create specific errors -const envError = Errors[MissingSupabaseURLError]() -// → EnvError { message: "SUPABASE_URL is required but not set", code: "MISSING_SUPABASE_URL", status: 500 } +Errors[MissingSupabaseURLError]() +// → EnvError { code: 'MISSING_SUPABASE_URL', status: 500, hint: 'Set SUPABASE_URL to …' } -const authError = Errors[InvalidCredentialsError]() -// → AuthError { message: "Invalid credentials", code: "INVALID_CREDENTIALS", status: 401 } +// Pass the configured names to get them into the message and details. +Errors[MissingSecretKeyError]('mobile', ['default', 'web']) +// → message: '… No "mobile" secret key found. Configured names: "default", "web".' ``` ## Checking error types ```ts -import { AuthError, EnvError } from '@supabase/server' +import { AuthError, EnvError, SupabaseServerError } from '@supabase/server' try { - // ... some operation + // ... } catch (e) { + if (e instanceof SupabaseServerError) { + // Anything from @supabase/server. e.code, e.status, e.hint, e.docs, e.details + } if (e instanceof AuthError) { - // e.status is 401 or 500 - // e.code is 'INVALID_CREDENTIALS', 'CREATE_SUPABASE_CLIENT_ERROR', or 'AUTH_ERROR' + // e.status is 401 (bad credentials) or 500 (server misconfigured) } if (e instanceof EnvError) { // e.status is always 500 - // e.code is one of the MISSING_* constants or 'ENV_ERROR' } } ``` diff --git a/docs/postgres.md b/docs/postgres.md index 272fa82..60a397f 100644 --- a/docs/postgres.md +++ b/docs/postgres.md @@ -212,7 +212,7 @@ withPostgresClient({ connectionString: 'postgresql://...' }) 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 `{ message, code: 'ENV_ERROR' }`. +`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. 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. diff --git a/src/core/create-admin-client.ts b/src/core/create-admin-client.ts index 6f4ab6b..6a0db3f 100644 --- a/src/core/create-admin-client.ts +++ b/src/core/create-admin-client.ts @@ -53,9 +53,10 @@ export function createAdminClient( const secretKey = keys[name] ?? (keyName == null ? Object.values(keys)[0] : undefined) if (!secretKey) { + const configuredKeyNames = Object.keys(keys) throw name === 'default' - ? Errors[MissingDefaultSecretKeyError]() - : Errors[MissingSecretKeyError](name) + ? Errors[MissingDefaultSecretKeyError](configuredKeyNames) + : Errors[MissingSecretKeyError](name, configuredKeyNames) } // Sanitize auth headers — only the service-role key controls Authorization and apikey. diff --git a/src/core/create-context-client.ts b/src/core/create-context-client.ts index 81c8a65..a338c8f 100644 --- a/src/core/create-context-client.ts +++ b/src/core/create-context-client.ts @@ -52,9 +52,10 @@ export function createContextClient( const anonKey = keys[name] ?? (keyName == null ? Object.values(keys)[0] : undefined) if (!anonKey) { + const configuredKeyNames = Object.keys(keys) throw name === 'default' - ? Errors[MissingDefaultPublishableKeyError]() - : Errors[MissingPublishableKeyError](name) + ? Errors[MissingDefaultPublishableKeyError](configuredKeyNames) + : Errors[MissingPublishableKeyError](name, configuredKeyNames) } // Sanitize auth headers — only verified credentials control Authorization and apikey. diff --git a/src/core/postgres-pool.ts b/src/core/postgres-pool.ts index 55cffeb..5a2cd9f 100644 --- a/src/core/postgres-pool.ts +++ b/src/core/postgres-pool.ts @@ -1,7 +1,8 @@ import { getEnv } from '@supabase/middleware' import pg from 'pg' -import { EnvGenericError } from '../errors.js' +import { errorResponse } from '../error-response.js' +import { Errors, MissingConnectionStringError } from '../errors.js' const { Pool } = pg @@ -102,18 +103,12 @@ export function resolveConnectionString( /** * The 500 both middleware short-circuit with when no connection string is - * available, in the package's standard `{ message, code }` error shape. + * available, in the package's standard error payload. * * @internal */ export function missingConnectionStringResponse( middlewareName: string, ): Response { - return Response.json( - { - message: `A Postgres connection string is required. Set SUPABASE_DB_URL, or pass \`connectionString\` to ${middlewareName}.`, - code: EnvGenericError, - }, - { status: 500 }, - ) + return errorResponse(Errors[MissingConnectionStringError](middlewareName)) } diff --git a/src/core/utils/classify-credentials.ts b/src/core/utils/classify-credentials.ts new file mode 100644 index 0000000..baaba17 --- /dev/null +++ b/src/core/utils/classify-credentials.ts @@ -0,0 +1,20 @@ +import type { ApiKeyFormat } from '../../errors.js' + +/** + * Classifies an `apikey` value by its public prefix so a format mismatch can be + * reported in an error without echoing the key itself. + * + * Sending a secret key to a publishable-only endpoint (or a legacy + * `anon` / `service_role` JWT to either) is a far more common mistake than a + * genuinely wrong key, and the prefix is enough to tell them apart. + * + * @internal + */ +export function classifyApiKey(apikey: string | null): ApiKeyFormat { + if (!apikey) return 'absent' + if (apikey.startsWith('sb_publishable_')) return 'publishable' + if (apikey.startsWith('sb_secret_')) return 'secret' + // Legacy anon / service_role keys are unsigned-header JWTs, always "eyJ…". + if (apikey.startsWith('eyJ')) return 'legacy-jwt' + return 'unrecognized' +} diff --git a/src/core/verify-auth.test.ts b/src/core/verify-auth.test.ts index d7065a4..dea0af6 100644 --- a/src/core/verify-auth.test.ts +++ b/src/core/verify-auth.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' +import { MissingCredentialsError } from '../errors.js' import { verifyAuth } from './verify-auth.js' describe('verifyAuth', () => { @@ -26,4 +27,55 @@ describe('verifyAuth', () => { const result = await verifyAuth(req, { auth: 'publishable', env }) expect(result.error).not.toBeNull() }) + + // `extractCredentials` only reads `Authorization: Bearer `, so these + // all produce no credential at all. Without a hint the failure reads as + // "you sent nothing", which is the most confusing way for auth to fail. + describe('unusable Authorization header', () => { + const userEnv = { ...env, jwks: null } + + async function failFor(authorization: string) { + const req = new Request('http://localhost', { + headers: { authorization }, + }) + const result = await verifyAuth(req, { auth: 'user', env: userEnv }) + expect(result.error).not.toBeNull() + return result.error! + } + + it('explains a non-Bearer scheme', async () => { + const error = await failFor('Basic dXNlcjpwYXNz') + expect(error.code).toBe(MissingCredentialsError) + expect(error.hint).toContain('"Basic"') + expect(error.hint).toContain('not `Bearer`') + expect(error.details!.received).toMatchObject({ + authorization: 'non-bearer-scheme', + }) + }) + + it('explains a lowercased bearer scheme', async () => { + const error = await failFor('bearer some.jwt.value') + expect(error.hint).toContain('"bearer"') + expect(error.hint).toContain('must be exactly `Bearer`') + }) + + it('explains a bare token with no scheme', async () => { + const error = await failFor('some.jwt.value') + expect(error.hint).toContain('no scheme') + }) + + it('explains an empty Bearer token', async () => { + const error = await failFor('Bearer ') + expect(error.hint).toContain('empty token') + }) + + it('leaves the error untouched when no Authorization header is sent', async () => { + const req = new Request('http://localhost') + const result = await verifyAuth(req, { auth: 'user', env: userEnv }) + expect(result.error!.code).toBe(MissingCredentialsError) + expect(result.error!.details!.received).toMatchObject({ + authorization: 'absent', + }) + }) + }) }) diff --git a/src/core/verify-auth.ts b/src/core/verify-auth.ts index d5ff5b3..596e71d 100644 --- a/src/core/verify-auth.ts +++ b/src/core/verify-auth.ts @@ -1,4 +1,4 @@ -import type { AuthError } from '../errors.js' +import { withExtraDiagnostics, type AuthError } from '../errors.js' import type { AuthModeWithKey, AuthResult, SupabaseEnv } from '../types.js' import { extractCredentials } from './extract-credentials.js' import { verifyCredentials } from './verify-credentials.js' @@ -28,6 +28,43 @@ export interface VerifyAuthOptions { env?: Partial } +/** + * Explains an `Authorization` header that was present but yielded no token. + * + * {@link extractCredentials} only reads `Authorization: Bearer `, so a + * wrong scheme, wrong casing, or a bare token silently produces no credential + * at all. That reads as "you sent nothing", which is the single most confusing + * way for auth to fail — name it explicitly instead. + * + * @returns A hint sentence, or `null` when the header was genuinely absent or + * did carry a token. + * + * @internal + */ +function explainUnusableAuthorizationHeader(raw: string): string | null { + const [scheme = '', ...rest] = raw.split(' ') + + // Correct scheme, so the only way `extractCredentials` yielded nothing is an + // empty token. (Header values are trimmed in transit, so a trailing-space-only + // value arrives here as a bare "Bearer".) + if (scheme === 'Bearer') { + return 'The Authorization header used the `Bearer` scheme but carried an empty token.' + } + if (scheme.toLowerCase() === 'bearer') { + return ( + `The Authorization header used the scheme "${scheme}" — it must be exactly \`Bearer\`, ` + + 'capitalised, followed by a single space and the JWT.' + ) + } + if (rest.length > 0) { + return `The Authorization header used the "${scheme}" scheme, not \`Bearer\`, so no token was read.` + } + return ( + 'The Authorization header carried a bare value with no scheme. It must be ' + + '`Authorization: Bearer `.' + ) +} + /** * Extracts credentials from a request and verifies them in a single step. * @@ -66,5 +103,27 @@ export async function verifyAuth( { data: AuthResult; error: null } | { data: null; error: AuthError } > { const credentials = extractCredentials(request) - return verifyCredentials(credentials, options) + const result = await verifyCredentials(credentials, options) + if (result.error === null || credentials.token) return result + + // Only reachable with the raw request in hand, so `verifyCredentials` can't + // report it — layer it on here. + const rawAuthorization = request.headers.get('authorization') + if (!rawAuthorization) return result + + const hint = explainUnusableAuthorizationHeader(rawAuthorization) + if (!hint) return result + + return { + data: null, + error: withExtraDiagnostics(result.error, { + hint, + details: { + received: { + ...(result.error.details?.received as Record), + authorization: 'non-bearer-scheme', + }, + }, + }), + } } diff --git a/src/core/verify-credentials.test.ts b/src/core/verify-credentials.test.ts index bd1085e..4cda2ef 100644 --- a/src/core/verify-credentials.test.ts +++ b/src/core/verify-credentials.test.ts @@ -14,7 +14,15 @@ import type { JSONWebKeySet } from 'jose' import type { Credentials, SupabaseEnv } from '../types.js' import { verifyCredentials } from './verify-credentials.js' import { _resetAllowDeprecationWarned } from './utils/deprecation.js' -import { EnvGenericError, InvalidCredentialsError } from '../errors.js' +import { + InvalidApiKeyError, + InvalidCredentialsError, + InvalidJwtError, + JwksFetchFailedError, + JwksNotConfiguredError, + MissingCredentialsError, + NoKeysConfiguredError, +} from '../errors.js' function makeEnv(overrides?: Partial): Partial { return { @@ -62,7 +70,7 @@ describe('verifyCredentials', () => { env: makeEnv(), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(InvalidApiKeyError) }) it('only matches default key when bare publishable is used', async () => { @@ -78,7 +86,7 @@ describe('verifyCredentials', () => { env, }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(InvalidApiKeyError) }) it('matches named key with colon syntax and returns keyName', async () => { @@ -113,7 +121,7 @@ describe('verifyCredentials', () => { env, }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(InvalidApiKeyError) }) it('rejects wrong named key type', async () => { @@ -129,7 +137,7 @@ describe('verifyCredentials', () => { env, }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(NoKeysConfiguredError) }) it('matches any key with wildcard syntax', async () => { @@ -191,7 +199,7 @@ describe('verifyCredentials', () => { env: makeEnv(), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(InvalidApiKeyError) }) it('only matches default key when bare secret is used', async () => { @@ -204,7 +212,7 @@ describe('verifyCredentials', () => { env, }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(InvalidApiKeyError) }) it('matches secret named key with colon syntax and returns keyName', async () => { @@ -230,7 +238,7 @@ describe('verifyCredentials', () => { env, }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(InvalidApiKeyError) }) it('rejects wrong secret named key type', async () => { @@ -243,7 +251,7 @@ describe('verifyCredentials', () => { env, }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(NoKeysConfiguredError) }) it('matches any key with wildcard syntax', async () => { @@ -345,7 +353,7 @@ describe('verifyCredentials', () => { env: makeEnv({ jwks }), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(InvalidJwtError) }) it('fails with no token', async () => { @@ -355,7 +363,7 @@ describe('verifyCredentials', () => { env: makeEnv({ jwks }), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(MissingCredentialsError) }) it('fails with expired JWT', async () => { @@ -377,7 +385,7 @@ describe('verifyCredentials', () => { env: makeEnv({ jwks: expiredJwks }), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(InvalidJwtError) }) }) @@ -393,16 +401,17 @@ describe('verifyCredentials', () => { vi.unstubAllEnvs() }) - it('fails 500 ENV_ERROR when a user token is present', async () => { + it('fails 500 JWKS_NOT_CONFIGURED when a user token is present', async () => { const creds: Credentials = { token: 'some.jwt.token', apikey: null } const result = await verifyCredentials(creds, { auth: 'user', env: makeEnv(), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(EnvGenericError) + expect(result.error!.code).toBe(JwksNotConfiguredError) expect(result.error!.status).toBe(500) expect(result.error!.message).toContain('JWKS') + expect(result.error!.hint).toContain('SUPABASE_JWKS_URL') }) it('fails 401 when no token is present', async () => { @@ -414,20 +423,26 @@ describe('verifyCredentials', () => { env: makeEnv(), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(MissingCredentialsError) expect(result.error!.status).toBe(401) }) it('fails 401 for an sb_* value in the Authorization slot', async () => { - // An API key can never pass user mode, JWKS or not. + // An API key can never pass user mode, JWKS or not. The header arrived + // but carried no user credential, so this is the same class of failure + // as sending nothing — and the hint says which mistake was made. const creds: Credentials = { token: 'sb_secret_xyz', apikey: null } const result = await verifyCredentials(creds, { auth: 'user', env: makeEnv(), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(MissingCredentialsError) expect(result.error!.status).toBe(401) + expect(result.error!.hint).toContain('sb_* API key') + expect(result.error!.details!.received).toMatchObject({ + authorization: 'api-key', + }) }) it('another matching mode still wins over the config error', async () => { @@ -450,7 +465,7 @@ describe('verifyCredentials', () => { env: makeEnv(), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(EnvGenericError) + expect(result.error!.code).toBe(JwksNotConfiguredError) expect(result.error!.status).toBe(500) }) @@ -578,7 +593,7 @@ describe('verifyCredentials', () => { }), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(InvalidJwtError) }) it('rejects when the remote JWKS endpoint fails', async () => { @@ -591,7 +606,7 @@ describe('verifyCredentials', () => { }), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(JwksFetchFailedError) }) it('replaces the cached resolver when the URL changes', async () => { @@ -684,7 +699,7 @@ describe('verifyCredentials', () => { env, }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(NoKeysConfiguredError) }) }) @@ -732,7 +747,7 @@ describe('verifyCredentials', () => { env: makeEnv({ jwks }), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(InvalidJwtError) }) it('rejects expired JWT instead of falling through to none mode', async () => { @@ -754,7 +769,7 @@ describe('verifyCredentials', () => { env: makeEnv({ jwks: expiredJwks }), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(InvalidJwtError) }) it('falls through to always when no token is present', async () => { @@ -777,7 +792,7 @@ describe('verifyCredentials', () => { env: makeEnv({ jwks }), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(InvalidJwtError) }) it('rejects invalid JWT instead of falling through to secret mode', async () => { @@ -790,7 +805,7 @@ describe('verifyCredentials', () => { env: makeEnv({ jwks }), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(InvalidJwtError) }) it('falls through to secret when Authorization carries an sb_ secret', async () => { @@ -842,7 +857,7 @@ describe('verifyCredentials', () => { env: makeEnv({ jwks: noSubJwks }), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(InvalidJwtError) }) }) @@ -914,9 +929,144 @@ describe('verifyCredentials', () => { it('defaults to `user` when neither `auth` nor `allow` is provided', async () => { const creds: Credentials = { token: null, apikey: null } const result = await verifyCredentials(creds, { env: makeEnv() }) - // No token, no apikey, default mode is `user` → fails with invalid credentials. + // No token, no apikey, default mode is `user` → nothing to verify. expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.code).toBe(MissingCredentialsError) + }) + }) + + describe('error diagnostics', () => { + async function failWith( + creds: Credentials, + options: Parameters[1], + ) { + const result = await verifyCredentials(creds, options) + expect(result.error).not.toBeNull() + return result.error! + } + + it('names the accepted auth modes and what the request carried', async () => { + const error = await failWith( + { token: null, apikey: null }, + { auth: ['user', 'publishable'], env: makeEnv() }, + ) + expect(error.code).toBe(MissingCredentialsError) + expect(error.status).toBe(401) + expect(error.message).toContain('"user", "publishable"') + expect(error.hint).toContain('Authorization: Bearer ') + expect(error.hint).toContain('apikey: ') + expect(error.details).toMatchObject({ + acceptedAuthModes: ['user', 'publishable'], + received: { authorization: 'absent', apikey: 'absent' }, + }) + }) + + it('calls out a secret key sent to a publishable-only endpoint', async () => { + const error = await failWith( + { token: null, apikey: 'sb_secret_xyz' }, + { auth: 'publishable', env: makeEnv() }, + ) + expect(error.code).toBe(InvalidApiKeyError) + expect(error.status).toBe(401) + expect(error.hint).toContain('only accepts publishable keys') + expect(error.details).toMatchObject({ + received: { apikey: 'secret' }, + }) + }) + + it('calls out a publishable key sent to a secret-only endpoint', async () => { + const error = await failWith( + { token: null, apikey: 'sb_publishable_xyz' }, + { auth: 'secret', env: makeEnv() }, + ) + expect(error.code).toBe(InvalidApiKeyError) + expect(error.hint).toContain('only accepts secret keys') + }) + + it('calls out a legacy JWT-style anon/service_role key', async () => { + const error = await failWith( + { token: null, apikey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.legacy' }, + { auth: 'publishable', env: makeEnv() }, + ) + expect(error.code).toBe(InvalidApiKeyError) + expect(error.hint).toContain('legacy JWT-based key') + expect(error.details).toMatchObject({ + received: { apikey: 'legacy-jwt' }, + }) + }) + + it('lists configured key names, never key values', async () => { + const error = await failWith( + { token: null, apikey: 'sb_publishable_nope' }, + { + auth: 'publishable:*', + env: makeEnv({ + publishableKeys: { + web: 'sb_publishable_web', + mobile: 'sb_publishable_mobile', + }, + }), + }, + ) + expect(error.details).toMatchObject({ + configuredKeyNames: { publishable: ['web', 'mobile'] }, + }) + const serialized = JSON.stringify(error.toJSON()) + expect(serialized).toContain('web') + expect(serialized).not.toContain('sb_publishable_web') + expect(serialized).not.toContain('sb_publishable_nope') + }) + + it('reports a missing JWKS as a 500, not a 401', async () => { + const error = await failWith( + { token: 'header.payload.signature', apikey: null }, + { auth: 'user', env: makeEnv({ jwks: null }) }, + ) + expect(error.code).toBe(JwksNotConfiguredError) + expect(error.status).toBe(500) + expect(error.hint).toContain('SUPABASE_JWKS_URL') + }) + + it('reports an unmatched named key as a 500 misconfiguration', async () => { + const error = await failWith( + { token: null, apikey: 'sb_publishable_xyz' }, + { + auth: 'publishable:mobile', + env: makeEnv({ publishableKeys: { default: 'sb_publishable_xyz' } }), + }, + ) + expect(error.code).toBe(NoKeysConfiguredError) + expect(error.status).toBe(500) + expect(error.message).toContain('"publishable:mobile"') + expect(error.hint).toContain('"default"') + expect(error.details).toMatchObject({ + mode: 'publishable:mobile', + keyKind: 'publishable', + }) + }) + + it('still lets a later mode match when an earlier one is unreachable', async () => { + // `publishable:mobile` can never match, but `secret` can — the + // misconfiguration must not short-circuit the chain. + const result = await verifyCredentials( + { token: null, apikey: 'sb_secret_xyz' }, + { + auth: ['publishable:mobile', 'secret'], + env: makeEnv(), + }, + ) + expect(result.error).toBeNull() + expect(result.data!.authMode).toBe('secret') + }) + + it('stamps provenance on every error', async () => { + const error = await failWith( + { token: null, apikey: null }, + { auth: 'user', env: makeEnv() }, + ) + expect(error.source).toBe('@supabase/server') + expect(error.message.startsWith('[@supabase/server] ')).toBe(true) + expect(error.docs).toContain('error-handling.md#missing_credentials') }) }) }) diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index f9fca96..e320e59 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -1,8 +1,14 @@ import { AuthError, - EnvGenericError, Errors, + InvalidApiKeyError, InvalidCredentialsError, + InvalidJwtError, + JwksFetchFailedError, + JwksNotConfiguredError, + MissingCredentialsError, + NoKeysConfiguredError, + type AuthFailureContext, } from '../errors.js' import type { AuthMode, @@ -12,6 +18,7 @@ import type { SupabaseEnv, } from '../types.js' import { resolveEnv } from './resolve-env.js' +import { classifyApiKey } from './utils/classify-credentials.js' import { resolveAuthOption } from './utils/deprecation.js' import { timingSafeEqual } from './utils/timing-safe-equal.js' import { verifyUserJwt } from './verify-user-jwt.js' @@ -74,15 +81,83 @@ function parseAuthMode(mode: AuthModeWithKey): { return { base, keyName } } -const INVALID = Symbol('invalid') +/** + * Why a mode didn't apply to the request. Collected as the mode chain falls + * through so the final error can name the actual cause instead of a generic + * "invalid credentials". + * + * @internal + */ +type ModeSkip = + /** Mode needs an `apikey` header; the request had none. */ + | { reason: 'no-apikey' } + /** Mode needs a bearer token; the request had none. */ + | { reason: 'no-token' } + /** `Authorization` carried an `sb_*` API key rather than a JWT. */ + | { reason: 'token-is-api-key' } + /** Auth mode `"user"` with a JWT present, but no JWKS to verify it against. */ + | { reason: 'jwks-not-configured' } + /** The mode's key set has no key it could ever match — a misconfiguration. */ + | { + reason: 'no-keys-configured' + mode: string + keyKind: 'publishable' | 'secret' + } + /** A key was present and the mode had keys, but none matched. */ + | { reason: 'apikey-mismatch' } + +/** + * Result of attempting a single auth mode. + * + * `reject` short-circuits the whole chain (a credential was present and + * definitively bad); `skip` falls through to the next mode. The error is + * returned as a thunk because only {@link verifyCredentials} knows the full + * {@link AuthFailureContext}. + * + * @internal + */ +type ModeOutcome = + | { kind: 'match'; auth: AuthResult } + | { kind: 'reject'; error: (context: AuthFailureContext) => AuthError } + | { kind: 'skip'; skip: ModeSkip } + +const NoToken: ModeOutcome = { kind: 'skip', skip: { reason: 'no-token' } } +const NoApiKey: ModeOutcome = { kind: 'skip', skip: { reason: 'no-apikey' } } +const ApiKeyMismatch: ModeOutcome = { + kind: 'skip', + skip: { reason: 'apikey-mismatch' }, +} + +/** + * Matches an `apikey` against a mode's key set, honouring the `:*` wildcard and + * named-key syntax. Returns the matched key name, or `null` when nothing matched. + * + * @internal + */ +async function matchApiKey( + apikey: string, + keys: Record, + keyName: string | null, +): Promise { + if (keyName === '*') { + for (const [name, value] of Object.entries(keys)) { + if (await timingSafeEqual(apikey, value)) return name + } + return null + } + + const name = keyName ?? 'default' + const value = keys[name] + if (value && (await timingSafeEqual(apikey, value))) return name + return null +} /** * Attempts to authenticate credentials against a single auth mode. * - * Returns: - * - `AuthResult` on success. - * - `null` if the mode doesn't apply (no relevant credential present — safe to try the next mode). - * - `INVALID` if a credential was present but failed verification (must reject immediately). + * Returns a `match` on success, a `reject` when a credential was present but + * definitively bad (the chain must stop), or a `skip` carrying the reason the + * mode didn't apply (the chain continues, and the reason feeds the final error). * * @internal */ @@ -90,123 +165,215 @@ async function tryMode( mode: AuthModeWithKey, credentials: Credentials, env: SupabaseEnv, -): Promise { +): Promise { const { base, keyName } = parseAuthMode(mode) switch (base) { case 'none': return { - authMode: 'none', - token: null, - userClaims: null, - jwtClaims: null, - keyName: null, + kind: 'match', + auth: { + authMode: 'none', + token: null, + userClaims: null, + jwtClaims: null, + keyName: null, + }, } - case 'publishable': { - if (!credentials.apikey) return null - const keys = env.publishableKeys + case 'publishable': + case 'secret': { + if (!credentials.apikey) return NoApiKey - if (keyName === '*') { - for (const [name, value] of Object.entries(keys)) { - if (await timingSafeEqual(credentials.apikey, value)) { - return { - authMode: 'publishable', - token: null, - userClaims: null, - jwtClaims: null, - keyName: name, - } - } - } - } else { - const name = keyName ?? 'default' - const value = keys[name] - if (value && (await timingSafeEqual(credentials.apikey, value))) { - return { - authMode: 'publishable', - token: null, - userClaims: null, - jwtClaims: null, - keyName: name, - } + const keys = base === 'publishable' ? env.publishableKeys : env.secretKeys + + // A mode whose key set can never yield a match is a server + // misconfiguration, not a bad request. Record it so the final error can + // say so — but keep falling through, since a later mode may still match. + const reachable = + keyName === '*' || keyName === null + ? Object.keys(keys).length > 0 + : keys[keyName] !== undefined + if (!reachable) { + return { + kind: 'skip', + skip: { reason: 'no-keys-configured', mode, keyKind: base }, } } - return null - } - case 'secret': { - if (!credentials.apikey) return null - const keys = env.secretKeys + const matched = await matchApiKey(credentials.apikey, keys, keyName) + if (matched === null) return ApiKeyMismatch - if (keyName === '*') { - for (const [name, value] of Object.entries(keys)) { - if (await timingSafeEqual(credentials.apikey, value)) { - return { - authMode: 'secret', - token: null, - userClaims: null, - jwtClaims: null, - keyName: name, - } - } - } - } else { - const name = keyName ?? 'default' - const value = keys[name] - if (value && (await timingSafeEqual(credentials.apikey, value))) { - return { - authMode: 'secret', - token: null, - userClaims: null, - jwtClaims: null, - keyName: name, - } - } + return { + kind: 'match', + auth: { + authMode: base, + token: null, + userClaims: null, + jwtClaims: null, + keyName: matched, + }, } - return null } case 'user': { - if (!credentials.token) return null + if (!credentials.token) return NoToken // The Supabase SDK forwards `sb_*` secrets in the Authorization header // alongside the apikey header. Treat them as not-applicable here so the // chain falls through to `secret` / `publishable` instead of failing // JWT verification. - if (credentials.token.startsWith('sb_')) return null - if (!env.jwks) return null + if (credentials.token.startsWith('sb_')) { + return { kind: 'skip', skip: { reason: 'token-is-api-key' } } + } + if (!env.jwks) { + return { kind: 'skip', skip: { reason: 'jwks-not-configured' } } + } + const verified = await verifyUserJwt(credentials.token, env.jwks) - if (!verified) { - return INVALID + if (!verified.ok) { + const { failure } = verified + return { + kind: 'reject', + error: (context) => + failure.kind === 'jwks-source' + ? Errors[JwksFetchFailedError]({ + ...context, + reason: failure.reason, + cause: failure.cause, + }) + : Errors[InvalidJwtError]({ + ...context, + reason: failure.reason, + hint: failure.hint, + jwt: failure.jwt, + cause: failure.cause, + }), + } } + return { - authMode: 'user', - token: credentials.token, - userClaims: verified.userClaims, - jwtClaims: verified.jwtClaims, - keyName: null, + kind: 'match', + auth: { + authMode: 'user', + token: credentials.token, + userClaims: verified.userClaims, + jwtClaims: verified.jwtClaims, + keyName: null, + }, } } default: - return null + return NoApiKey } } +/** + * Builds the non-sensitive diagnostics shared by every failure path: which + * modes were attempted, what the request carried, and the *names* of the keys + * configured for the attempted modes. Never includes key values or token + * payloads. + * + * @internal + */ +function buildFailureContext( + modes: readonly AuthModeWithKey[], + credentials: Credentials, + env: SupabaseEnv, +): AuthFailureContext { + const usesKeyKind = (kind: 'publishable' | 'secret') => + modes.some((mode) => mode === kind || mode.startsWith(`${kind}:`)) + + const configuredKeyNames: Record = {} + if (usesKeyKind('publishable')) { + configuredKeyNames.publishable = Object.keys(env.publishableKeys) + } + if (usesKeyKind('secret')) { + configuredKeyNames.secret = Object.keys(env.secretKeys) + } + + return { + authModes: modes, + received: { + authorization: !credentials.token + ? 'absent' + : // The Supabase SDK forwards `sb_*` secrets in this header too; saying + // "bearer" would imply a JWT arrived when it did not. + credentials.token.startsWith('sb_') + ? 'api-key' + : 'bearer', + apikey: classifyApiKey(credentials.apikey), + }, + ...(Object.keys(configuredKeyNames).length > 0 + ? { configuredKeyNames } + : {}), + } +} + +/** + * Picks the most useful error once every mode has fallen through. + * + * Server misconfiguration wins over credential problems: if a mode could never + * have matched — no JWKS to verify a JWT against, or no keys for a key mode — + * that's a `500` the operator needs to see, not a `401` blamed on the caller. + * + * @internal + */ +function explainFallthrough( + skips: readonly ModeSkip[], + context: AuthFailureContext, +): AuthError { + if (skips.some((skip) => skip.reason === 'jwks-not-configured')) { + return Errors[JwksNotConfiguredError](context) + } + + const unreachableMode = skips.find( + (skip): skip is Extract => + skip.reason === 'no-keys-configured', + ) + if (unreachableMode) { + return Errors[NoKeysConfiguredError]({ + ...context, + mode: unreachableMode.mode, + keyKind: unreachableMode.keyKind, + }) + } + + // `api-key` and `non-bearer-scheme` mean the header arrived but carried + // nothing usable — the same situation as absent, and reported the same way so + // this matches what `withRequiredClaims` says for an identical request. + const { authorization, apikey } = context.received + if (authorization !== 'bearer' && apikey === 'absent') { + return Errors[MissingCredentialsError](context) + } + if (apikey !== 'absent') { + return Errors[InvalidApiKeyError](context) + } + return Errors[InvalidCredentialsError](context) +} + /** * Verifies pre-extracted credentials against one or more allowed auth modes. * * Tries each mode in order — first match wins. A mode is only tried when its * credential is present; a JWT that is present but fails verification - * short-circuits the chain with `InvalidCredentialsError` instead of falling + * short-circuits the chain with {@link InvalidJwtError} instead of falling * through to the next mode. Use {@link verifyAuth} to extract and verify in a * single call. * - * When `user` is among the allowed modes, a request carries a user token, and - * no JWKS source is configured, the failure is a 500 `ENV_ERROR` rather than - * a 401: the token cannot be verified, and that is a server misconfiguration, - * not a caller error. Another allowed mode matching the request's credentials - * still wins — the 500 is reported only when nothing matched. + * When every mode falls through, the returned error names the actual cause + * rather than a generic failure — {@link MissingCredentialsError} when the + * request carried nothing, {@link InvalidApiKeyError} when an `apikey` matched + * no configured key, or a `500` ({@link JwksNotConfiguredError}, + * {@link NoKeysConfiguredError}) when the server is configured such that no + * request could ever have succeeded. Every error carries `hint`, `docs`, and + * non-sensitive `details`. + * + * A misconfiguration `500` is only reported once nothing has matched, so an + * allowed mode that does match the request's credentials still wins — e.g. + * `['user', 'secret']` with no JWKS but a valid apikey authenticates as + * `secret`. `sb_*` values in the Authorization slot are API keys, not user + * tokens, and stay a caller error. * * @param credentials - The credentials to verify (from {@link extractCredentials}). * @param options - Allowed auth modes and optional env overrides. @@ -219,7 +386,8 @@ async function tryMode( * auth: ['user', 'publishable'], * }) * if (error) { - * return Response.json({ message: error.message }, { status: error.status }) + * console.error(error.code, error.message, error.hint) + * return Response.json(error.toJSON(), { status: error.status }) * } * ``` * @@ -233,50 +401,46 @@ export async function verifyCredentials( > { const { data: env, error: envError } = resolveEnv(options.env) if (envError) { + // The EnvError already names the exact missing variable — keep its code, + // hint, and details rather than flattening to a generic auth failure. return { data: null, - error: new AuthError(envError.message, envError.code, 500), + error: new AuthError(envError.message, envError.code, 500, { + hint: envError.hint, + details: envError.details, + docs: envError.docs, + cause: envError, + }), } } const resolved = resolveAuthOption(options) const modes = Array.isArray(resolved) ? resolved : [resolved] + const skips: ModeSkip[] = [] for (const mode of modes) { - const result = await tryMode(mode, credentials, env) - if (result === INVALID) { - return { data: null, error: Errors[InvalidCredentialsError]() } - } - if (result) { - return { data: result, error: null } + const outcome = await tryMode(mode, credentials, env) + if (outcome.kind === 'match') { + return { data: outcome.auth, error: null } } - } - - // A user token that cannot be verified because no JWKS source is - // configured is a server misconfiguration, not a caller error — the same - // 500 `ENV_ERROR` the standalone claims middleware reports. Checked only - // after every mode has been tried, so key-based fallthrough (e.g. - // `['user', 'secret']` with a valid apikey) is unaffected. `sb_*` values - // in the Authorization slot are API keys, not user tokens, and stay a - // caller error. - const userTokenUnverifiable = - !env.jwks && - credentials.token !== null && - !credentials.token.startsWith('sb_') && - modes.some((mode) => parseAuthMode(mode).base === 'user') - if (userTokenUnverifiable) { - return { - data: null, - error: new AuthError( - 'A JWKS source is required to verify user tokens. Set SUPABASE_JWKS or SUPABASE_JWKS_URL, or pass `jwks` in the env overrides.', - EnvGenericError, - 500, - ), + if (outcome.kind === 'reject') { + return { + data: null, + error: outcome.error(buildFailureContext(modes, credentials, env)), + } } + skips.push(outcome.skip) } + // An unverifiable user token (#128) is reported by `explainFallthrough` from + // the `jwks-not-configured` skip the `user` mode records — same guards, same + // after-every-mode timing, but as JWKS_NOT_CONFIGURED rather than a generic + // ENV_ERROR. return { data: null, - error: Errors[InvalidCredentialsError](), + error: explainFallthrough( + skips, + buildFailureContext(modes, credentials, env), + ), } } diff --git a/src/core/verify-user-jwt.ts b/src/core/verify-user-jwt.ts index ecc54a3..7d640bd 100644 --- a/src/core/verify-user-jwt.ts +++ b/src/core/verify-user-jwt.ts @@ -67,31 +67,182 @@ function getJwksResolver(jwks: JSONWebKeySet | URL): JwksResolver { return localJwksResolver } +/** + * Why a JWT failed verification, in terms a caller can act on. + * + * `kind` separates the two audiences: `token` failures are the caller's + * problem (`401`), `jwks-source` failures are the operator's (`500`) — a JWKS + * endpoint outage is not a bad request. + * + * @internal + */ +export interface JwtFailure { + kind: 'token' | 'jwks-source' + /** Plain-language reason, phrased to follow "failed verification: …". */ + reason: string + /** Actionable next step. */ + hint: string + /** Non-sensitive header fields (`alg`, `kid`). Never claim values. */ + jwt: Record + /** The underlying `jose` error, when there was one. */ + cause?: unknown +} + +/** + * Result of {@link verifyUserJwt}: the claims on success, or a described + * failure. Discriminate on `ok`. + * + * @internal + */ +export type VerifyUserJwtResult = + | { ok: true; jwtClaims: JWTClaims; userClaims: UserClaims } + | { ok: false; failure: JwtFailure } + +/** + * `jose` error codes that mean the *JWKS* could not be obtained or parsed, + * rather than that the token was bad. + * + * @internal + */ +const JwksSourceErrorCodes = new Set([ + 'ERR_JWKS_TIMEOUT', + 'ERR_JWKS_INVALID', + 'ERR_JOSE_GENERIC', +]) + +/** Reads a `jose` error code (`ERR_*`) off a thrown value, if present. @internal */ +function joseErrorCode(error: unknown): string | undefined { + const code = (error as { code?: unknown } | null)?.code + return typeof code === 'string' && code.startsWith('ERR_') ? code : undefined +} + +/** + * Translates a `jose` verification failure into a reason and a hint. + * + * @internal + */ +function describeJoseFailure(error: unknown): { reason: string; hint: string } { + switch (joseErrorCode(error)) { + case 'ERR_JWT_EXPIRED': + return { + reason: 'the token has expired', + hint: + 'Refresh the session on the client (supabase.auth.refreshSession()) and retry with the new ' + + 'access token. If tokens appear to expire immediately, check the server clock for skew.', + } + case 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED': + return { + reason: 'the signature did not verify against the configured JWKS', + hint: + 'The token was signed by a different key than the JWKS provides. Check that ' + + 'SUPABASE_JWKS_URL / SUPABASE_JWKS belongs to the same Supabase project that issued the token.', + } + case 'ERR_JWKS_NO_MATCHING_KEY': + return { + reason: 'no key in the JWKS matches the token\'s "kid"', + hint: + 'Either the JWKS belongs to a different Supabase project, or the signing key was rotated and ' + + 'the JWKS is stale. Prefer SUPABASE_JWKS_URL over inline SUPABASE_JWKS so rotations are picked ' + + 'up automatically.', + } + case 'ERR_JWKS_MULTIPLE_MATCHING_KEYS': + return { + reason: 'more than one key in the JWKS matches the token\'s "kid"', + hint: + 'The JWKS contains duplicate "kid" values. Remove the duplicates, or point SUPABASE_JWKS_URL ' + + "at the project's own /auth/v1/.well-known/jwks.json.", + } + case 'ERR_JWT_CLAIM_VALIDATION_FAILED': + return { + reason: 'a registered claim failed validation', + hint: + 'Usually "nbf" (not-before) being in the future, or a mismatched "aud" / "iss". Check the ' + + 'server clock and that the token came from the expected Supabase project.', + } + case 'ERR_JOSE_ALG_NOT_ALLOWED': + case 'ERR_JOSE_NOT_SUPPORTED': + return { + reason: "the token's signing algorithm is not supported", + hint: + 'Supabase signs JWTs with ES256, RS256, or HS256. A token using anything else was not issued ' + + 'by Supabase Auth.', + } + default: + return { + reason: 'the token is malformed', + hint: MalformedTokenHint, + } + } +} + +/** @internal */ +const MalformedTokenHint = + 'The Authorization header must carry a compact JWS — three base64url segments separated by dots. ' + + 'Check the token was not truncated, URL-encoded, or wrapped in quotes.' + /** * Verifies a user JWT against the project JWKS — the single verification core - * shared by `verifyCredentials`'s `user` mode and the `withClaims` middleware. + * shared by `verifyCredentials`'s `user` mode and the `withClaims` / + * `withRequiredClaims` middleware. * * Handles both asymmetric keys (resolved through the JWKS) and the `HS256` * shared-secret case (imported from the matching JWK). A payload without a * string `sub` is rejected — a user token always identifies a subject. * + * On failure it returns *why*, so callers can report the specific cause + * (expired, bad signature, unknown `kid`, malformed, no `sub`) rather than a + * blanket "invalid credentials". + * * @param token - The bearer token to verify. * @param jwks - JWKS source: an inline key set or a remote JWKS URL. - * @returns The decoded claims on success, `null` when verification fails. + * @returns `{ ok: true, ...claims }` on success, `{ ok: false, failure }` otherwise. * * @internal */ export async function verifyUserJwt( token: string, jwks: JSONWebKeySet | URL, -): Promise<{ jwtClaims: JWTClaims; userClaims: UserClaims } | null> { +): Promise { + let alg: string | undefined + let kid: string | undefined try { - const jwkResolver = getJwksResolver(jwks) - const { alg, kid } = decodeProtectedHeader(token) - if (!alg || !kid) { - return null + ;({ alg, kid } = decodeProtectedHeader(token)) + } catch (e) { + return { + ok: false, + failure: { + kind: 'token', + reason: 'its header could not be decoded', + hint: MalformedTokenHint, + jwt: { decodable: false }, + cause: e, + }, } + } + + const jwt = { alg: alg ?? null, kid: kid ?? null } + + if (!alg || !kid) { + const missing = [!alg && '"alg"', !kid && '"kid"'] + .filter(Boolean) + .join(' and ') + return { + ok: false, + failure: { + kind: 'token', + reason: `its header is missing ${missing}`, + hint: + 'A JWT issued by a project using JWT signing keys carries both "alg" and "kid". A token ' + + 'with no "kid" is usually a legacy JWT signed with the project\'s shared JWT secret — ' + + 'migrate the project to JWT signing keys. For API keys use auth mode "publishable" / ' + + '"secret" rather than "user".', + jwt, + }, + } + } + try { + const jwkResolver = getJwksResolver(jwks) let payload: JWTPayload | null = null // Symmetric algorithm requires importing the shared secret @@ -100,7 +251,18 @@ export async function verifyUserJwt( .jwks() ?.keys.find((key) => key.alg === alg && key.kid === kid) if (!jwk) { - return null + return { + ok: false, + failure: { + kind: 'token', + reason: 'no HS256 key in the JWKS matches the token\'s "kid"', + hint: + 'The JWKS must contain the symmetric signing key (alg "HS256") with a matching "kid". ' + + 'Check SUPABASE_JWKS / SUPABASE_JWKS_URL belongs to the project that issued the token, ' + + 'and that its signing key has not been rotated.', + jwt, + }, + } } const sharedSecret = await importJWK(jwk, 'HS256') @@ -112,11 +274,48 @@ export async function verifyUserJwt( } if (typeof payload.sub !== 'string') { - return null + return { + ok: false, + failure: { + kind: 'token', + reason: 'it has no "sub" claim, so it identifies no user', + hint: + 'Auth mode "user" expects an end-user access token from Supabase Auth. A token without ' + + '"sub" is typically a legacy anon / service_role JWT — use auth mode "publishable" or ' + + '"secret" for those.', + jwt, + }, + } } const jwtClaims = payload as unknown as JWTClaims - return { jwtClaims, userClaims: jwtClaimsToUserClaims(jwtClaims) } - } catch { - return null + return { ok: true, jwtClaims, userClaims: jwtClaimsToUserClaims(jwtClaims) } + } catch (e) { + const code = joseErrorCode(e) + // A JWKS that could not be fetched or parsed is a server / upstream fault. + // A non-`jose` throw here is almost always the fetch itself failing, since + // the token header already decoded cleanly. + const jwksSourceFailed = + (code && JwksSourceErrorCodes.has(code)) || + (code === undefined && jwks instanceof URL) + if (jwksSourceFailed) { + return { + ok: false, + failure: { + kind: 'jwks-source', + reason: e instanceof Error ? e.message : String(e), + hint: + 'Check that SUPABASE_JWKS_URL points at a reachable JWKS endpoint and that the server ' + + 'has outbound network access to it. The endpoint must return 200 with a JSON ' + + '`{ "keys": [...] }` body.', + jwt, + cause: e, + }, + } + } + + return { + ok: false, + failure: { kind: 'token', ...describeJoseFailure(e), jwt, cause: e }, + } } } diff --git a/src/create-supabase-context.ts b/src/create-supabase-context.ts index 78b1fd7..14fb9ac 100644 --- a/src/create-supabase-context.ts +++ b/src/create-supabase-context.ts @@ -26,7 +26,8 @@ import type { SupabaseContext, WithSupabaseConfig } from './types.js' * ```ts * const { data: ctx, error } = await createSupabaseContext(request, { auth: 'user' }) * if (error) { - * return Response.json({ message: error.message }, { status: error.status }) + * // `toJSON()` yields { source, code, message, hint, docs, details } + * return Response.json(error.toJSON(), { status: error.status }) * } * const { data } = await ctx.supabase.rpc('get_my_items') * ``` @@ -78,10 +79,17 @@ export async function createSupabaseContext( error: null, } } catch (e) { + // An EnvError already names the exact missing variable — preserve its code, + // hint, and details rather than flattening it to a generic client failure. const error = e instanceof EnvError - ? new AuthError(e.message, e.code, 500) - : Errors[CreateSupabaseClientError]() + ? new AuthError(e.message, e.code, 500, { + hint: e.hint, + details: e.details, + docs: e.docs, + cause: e, + }) + : Errors[CreateSupabaseClientError]({ cause: e }) return { data: null, error } } } diff --git a/src/error-response.ts b/src/error-response.ts new file mode 100644 index 0000000..05992a4 --- /dev/null +++ b/src/error-response.ts @@ -0,0 +1,27 @@ +import { ErrorCodeHeader, type SupabaseServerError } from './errors.js' + +/** + * Renders a {@link SupabaseServerError} as the JSON error response every layer + * of the library returns. + * + * One place so `withSupabase` and the middleware that answer directly + * (`withClaims`, `withRequiredClaims`, `withPostgresClient`) stay consistent: + * same body, same `x-supabase-server-error` header, same status. + * + * @param error - The error to render. + * @param options - Extra headers, merged in ahead of the code header. + * + * @internal + */ +export function errorResponse( + error: SupabaseServerError, + options?: { + /** Merged in ahead of the code header — CORS headers, typically. */ + headers?: Record + }, +): Response { + return Response.json(error.toJSON(), { + status: error.status, + headers: { ...options?.headers, [ErrorCodeHeader]: error.code }, + }) +} diff --git a/src/errors.test.ts b/src/errors.test.ts new file mode 100644 index 0000000..b8472aa --- /dev/null +++ b/src/errors.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' + +import { + AuthError, + EnvError, + Errors, + ErrorSource, + InvalidCredentialsError, + MissingDefaultSecretKeyError, + MissingSecretKeyError, + MissingSupabaseURLError, + SupabaseServerError, +} from './errors.js' + +describe('SupabaseServerError', () => { + it('is the common base for both error classes', () => { + expect(new AuthError('nope')).toBeInstanceOf(SupabaseServerError) + expect(new EnvError('nope')).toBeInstanceOf(SupabaseServerError) + // Still ordinary Errors, so existing `instanceof Error` checks hold. + expect(new AuthError('nope')).toBeInstanceOf(Error) + expect(new EnvError('nope')).toBeInstanceOf(Error) + }) + + it('keeps the subclasses distinguishable', () => { + expect(new AuthError('nope')).not.toBeInstanceOf(EnvError) + expect(new EnvError('nope')).not.toBeInstanceOf(AuthError) + expect(new AuthError('nope').name).toBe('AuthError') + expect(new EnvError('nope').name).toBe('EnvError') + }) + + it('stamps provenance on the message and as a field', () => { + const error = new AuthError('something went wrong') + expect(error.source).toBe(ErrorSource) + expect(error.message).toBe('[@supabase/server] something went wrong') + }) + + it('does not double-prefix an already-prefixed message', () => { + const once = new AuthError('boom') + const rewrapped = new AuthError(once.message, once.code, once.status) + expect(rewrapped.message).toBe('[@supabase/server] boom') + }) + + it('derives a docs URL from the code', () => { + expect(new AuthError('boom', 'SOME_CODE').docs).toBe( + 'https://github.com/supabase/server/blob/main/docs/error-handling.md#some_code', + ) + }) + + it('honours an explicit docs override', () => { + const error = new AuthError('boom', 'SOME_CODE', 401, { + docs: 'https://example.com/x', + }) + expect(error.docs).toBe('https://example.com/x') + }) + + it('omits absent optional fields from toJSON', () => { + const payload = new AuthError('boom', 'SOME_CODE').toJSON() + expect(payload).toEqual({ + source: ErrorSource, + code: 'SOME_CODE', + message: '[@supabase/server] boom', + docs: expect.any(String), + }) + expect('hint' in payload).toBe(false) + expect('details' in payload).toBe(false) + }) + + it('serializes via JSON.stringify instead of collapsing to {}', () => { + const error = new AuthError('boom', 'SOME_CODE', 401, { + hint: 'try this', + details: { mode: 'user' }, + }) + expect(JSON.parse(JSON.stringify(error))).toEqual({ + source: ErrorSource, + code: 'SOME_CODE', + message: '[@supabase/server] boom', + hint: 'try this', + docs: expect.any(String), + details: { mode: 'user' }, + }) + }) + + it('retains the underlying cause when wrapping', () => { + const inner = new Error('inner') + expect(new AuthError('outer', 'X', 500, { cause: inner }).cause).toBe(inner) + }) +}) + +describe('EnvError', () => { + it('is always a 500', () => { + expect(new EnvError('boom').status).toBe(500) + }) + + it('defaults to the generic code', () => { + expect(new EnvError('boom').code).toBe('ENV_ERROR') + }) +}) + +describe('AuthError', () => { + it('defaults to a 401 with the generic code', () => { + const error = new AuthError('boom') + expect(error.status).toBe(401) + expect(error.code).toBe('AUTH_ERROR') + }) +}) + +describe('Errors factory map', () => { + it('produces an actionable EnvError for a missing URL', () => { + const error = Errors[MissingSupabaseURLError]() + expect(error.status).toBe(500) + expect(error.code).toBe(MissingSupabaseURLError) + expect(error.hint).toContain('SUPABASE_URL') + expect(error.docs).toContain('#missing_supabase_url') + }) + + it('reports which key names are configured, never their values', () => { + const error = Errors[MissingSecretKeyError]('mobile', ['default', 'web']) + expect(error.message).toContain('"default", "web"') + expect(error.hint).toContain('SUPABASE_SECRET_KEYS') + expect(error.details).toMatchObject({ + requestedKeyName: 'mobile', + configuredKeyNames: ['default', 'web'], + }) + }) + + it('says so plainly when nothing is configured', () => { + expect(Errors[MissingDefaultSecretKeyError]([]).message).toContain( + 'None are configured.', + ) + }) + + it('still supports the zero-argument legacy signatures', () => { + expect(Errors[MissingSecretKeyError]('mobile').code).toBe( + MissingSecretKeyError, + ) + expect(Errors[MissingDefaultSecretKeyError]().code).toBe( + MissingDefaultSecretKeyError, + ) + expect(Errors[InvalidCredentialsError]().code).toBe(InvalidCredentialsError) + expect(Errors[InvalidCredentialsError]().status).toBe(401) + }) +}) diff --git a/src/errors.ts b/src/errors.ts index 68df3e1..b2b4a08 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,3 +1,172 @@ +/** + * Package identifier stamped on every error this library produces. + * + * Present as `source` on each error instance, as the `source` field of the JSON + * body {@link withSupabase} returns, and as a `[@supabase/server]` prefix on + * every `message` — so an error is traceable back here from a log line, a + * response body, or a response header alone. + * + * @category Errors + */ +export const ErrorSource = '@supabase/server' + +/** Prefix prepended to every error message. @internal */ +const MessagePrefix = `[${ErrorSource}] ` + +/** + * Response header {@link withSupabase} sets on every error response, carrying + * the error {@link SupabaseServerError.code}. + * + * @category Errors + */ +export const ErrorCodeHeader = 'x-supabase-server-error' + +/** @internal */ +const DocsBase = + 'https://github.com/supabase/server/blob/main/docs/error-handling.md' + +/** + * Builds the documentation URL for an error code. Anchors match the + * `### CODE` headings in `docs/error-handling.md`. + * + * @internal + */ +function docsFor(code: string): string { + return `${DocsBase}#${code.toLowerCase()}` +} + +/** Renders a list of names as `"a", "b"` for interpolation into messages. @internal */ +function quoteList(items: readonly string[]): string { + return items.map((item) => `"${item}"`).join(', ') +} + +/** + * Optional diagnostics attached to a {@link SupabaseServerError}. + * @category Errors + */ +export interface SupabaseServerErrorOptions { + /** Actionable next step for whoever has to fix this. */ + hint?: string + + /** + * Structured, non-sensitive diagnostics. Never contains key values, token + * payloads, or any other secret material. + */ + details?: Record + + /** Override the generated documentation URL. */ + docs?: string + + /** Underlying error, when this one wraps another. */ + cause?: unknown +} + +/** + * Serializable form of a {@link SupabaseServerError} — the exact JSON body + * {@link withSupabase} returns on failure. + * + * @category Errors + */ +export interface ErrorPayload { + /** Always `"@supabase/server"`. Identifies which library produced the error. */ + source: typeof ErrorSource + + /** Machine-readable error code, also sent as the `x-supabase-server-error` header. */ + code: string + + /** Human-readable description, prefixed with `[@supabase/server]`. */ + message: string + + /** Actionable next step. Omitted when there isn't a useful one. */ + hint?: string + + /** Documentation URL for this specific code. */ + docs: string + + /** Structured, non-sensitive diagnostics. Omitted when there are none. */ + details?: Record +} + +/** + * Base class for every error `@supabase/server` produces. + * + * Catch this to handle anything originating from this library, regardless of + * whether it's an {@link EnvError} or an {@link AuthError}: + * + * @example Catching any library error + * ```ts + * import { SupabaseServerError } from '@supabase/server' + * + * try { + * const supabase = createAdminClient() + * } catch (e) { + * if (e instanceof SupabaseServerError) { + * console.error(e.code, e.message, e.hint, e.docs) + * return Response.json(e.toJSON(), { status: e.status }) + * } + * throw e + * } + * ``` + * + * @category Errors + */ +export abstract class SupabaseServerError extends Error { + /** Always `"@supabase/server"`. @see {@link ErrorSource} */ + readonly source = ErrorSource + + /** HTTP status code appropriate for this error. */ + abstract readonly status: number + + /** Machine-readable error code. */ + readonly code: string + + /** Actionable next step, when one applies. */ + readonly hint?: string + + /** Documentation URL for this error's `code`. */ + readonly docs: string + + /** + * Structured, non-sensitive diagnostics — accepted auth modes, which + * credential headers were present, configured key *names*, JWT header + * fields. Never key values or token payloads. + */ + readonly details?: Record + + constructor( + message: string, + code: string, + options?: SupabaseServerErrorOptions, + ) { + // Prefix so provenance survives even when only `message` is logged or + // re-thrown by a framework. Guarded so re-wrapping doesn't double-prefix. + super( + message.startsWith(MessagePrefix) ? message : MessagePrefix + message, + options && 'cause' in options ? { cause: options.cause } : undefined, + ) + this.name = 'SupabaseServerError' + this.code = code + this.docs = options?.docs ?? docsFor(code) + if (options?.hint) this.hint = options.hint + if (options?.details) this.details = options.details + } + + /** + * Returns the wire format — also used implicitly by `JSON.stringify`, so + * logging the error yields the full diagnostics rather than `{}`. + */ + toJSON(): ErrorPayload { + return { + source: this.source, + code: this.code, + message: this.message, + ...(this.hint ? { hint: this.hint } : {}), + docs: this.docs, + ...(this.details ? { details: this.details } : {}), + } + } +} + /** * Thrown when a required environment variable is missing or malformed. * @@ -11,32 +180,33 @@ * const client = createAdminClient() * } catch (e) { * if (e instanceof EnvError) { - * console.error(`Config issue [${e.code}]: ${e.message}`) - * // → "Config issue [MISSING_SUPABASE_URL]: SUPABASE_URL is required but not set" + * console.error(`[${e.code}] ${e.message}\n${e.hint}`) + * // → "[MISSING_SUPABASE_URL] [@supabase/server] SUPABASE_URL is required but not set." + * // "Set SUPABASE_URL to your project URL (https://.supabase.co), …" * } * } * ``` * * @category Errors */ -export class EnvError extends Error { +export class EnvError extends SupabaseServerError { /** Always `500` — environment errors are server-side issues. */ readonly status = 500 /** - * Machine-readable error code. - * - * @see {@link EnvGenericError}, {@link MissingSupabaseURLError}, + * @param message - Human-readable description. Prefixed with `[@supabase/server]`. + * @param code - Machine-readable code. @see {@link EnvGenericError}, {@link MissingSupabaseURLError}, * {@link MissingPublishableKeyError}, {@link MissingDefaultPublishableKeyError}, - * {@link MissingSecretKeyError}, {@link MissingDefaultSecretKeyError}, - * {@link MissingResourceServerError}, {@link MissingAuthorizationServerError} + * {@link MissingSecretKeyError}, {@link MissingDefaultSecretKeyError} + * @param options - Optional `hint`, `details`, `docs`, and `cause`. */ - readonly code: string - - constructor(message: string, code = EnvGenericError) { - super(message) + constructor( + message: string, + code = EnvGenericError, + options?: SupabaseServerErrorOptions, + ) { + super(message, code, options) this.name = 'EnvError' - this.code = code } } @@ -89,43 +259,137 @@ export const MissingResourceServerError = 'MISSING_RESOURCE_SERVER' */ export const MissingAuthorizationServerError = 'MISSING_AUTHORIZATION_SERVER' +/** + * No Postgres connection string is configured, so the `withPostgresClient` / + * `withPostgresAdminClient` middleware cannot connect. + * + * @category Errors + */ +export const MissingConnectionStringError = 'MISSING_CONNECTION_STRING' + +/** + * Describes the configured key names for a hint without revealing key values. + * @internal + */ +function configuredNames(available?: readonly string[]): string { + if (!available || available.length === 0) return 'None are configured.' + return `Configured names: ${quoteList(available)}.` +} + const EnvErrorMap = { [MissingSupabaseURLError]: (): EnvError => new EnvError( - 'SUPABASE_URL is required but not set', + 'SUPABASE_URL is required but not set.', MissingSupabaseURLError, + { + hint: + 'Set SUPABASE_URL to your project URL (https://.supabase.co), ' + + 'or pass `env.url`. A local Supabase CLI stack uses http://localhost:54321.', + }, ), - [MissingSecretKeyError]: (name: string): EnvError => + + [MissingSecretKeyError]: ( + name: string, + availableKeyNames?: readonly string[], + ): EnvError => new EnvError( - `No "${name}" secret key found. Include a "${name}" entry in SUPABASE_SECRET_KEYS.`, + `No "${name}" secret key found. ${configuredNames(availableKeyNames)}`, MissingSecretKeyError, + { + hint: + `Add a "${name}" entry to SUPABASE_SECRET_KEYS — a JSON object of name → key, ` + + `e.g. {"${name}":"sb_secret_…"} — or pass \`env.secretKeys\`.`, + details: { + requestedKeyName: name, + configuredKeyNames: availableKeyNames ?? [], + }, + }, ), - [MissingDefaultSecretKeyError]: (): EnvError => + + [MissingDefaultSecretKeyError]: ( + availableKeyNames?: readonly string[], + ): EnvError => new EnvError( - 'No default secret key found. Set SUPABASE_SECRET_KEY or include a "default" entry in SUPABASE_SECRET_KEYS.', + `No default secret key found. ${configuredNames(availableKeyNames)}`, MissingDefaultSecretKeyError, + { + hint: + 'Set SUPABASE_SECRET_KEY, or add a "default" entry to SUPABASE_SECRET_KEYS ' + + '(a JSON object of name → key), or pass `env.secretKeys`.', + details: { configuredKeyNames: availableKeyNames ?? [] }, + }, ), - [MissingPublishableKeyError]: (name: string): EnvError => + [MissingPublishableKeyError]: ( + name: string, + availableKeyNames?: readonly string[], + ): EnvError => new EnvError( - `No "${name}" publishable key found. Include a "${name}" entry in SUPABASE_PUBLISHABLE_KEYS.`, + `No "${name}" publishable key found. ${configuredNames(availableKeyNames)}`, MissingPublishableKeyError, + { + hint: + `Add a "${name}" entry to SUPABASE_PUBLISHABLE_KEYS — a JSON object of name → key, ` + + `e.g. {"${name}":"sb_publishable_…"} — or pass \`env.publishableKeys\`.`, + details: { + requestedKeyName: name, + configuredKeyNames: availableKeyNames ?? [], + }, + }, ), - [MissingDefaultPublishableKeyError]: (): EnvError => + + [MissingDefaultPublishableKeyError]: ( + availableKeyNames?: readonly string[], + ): EnvError => new EnvError( - 'No default publishable key found. Set SUPABASE_PUBLISHABLE_KEY or include a "default" entry in SUPABASE_PUBLISHABLE_KEYS.', + `No default publishable key found. ${configuredNames(availableKeyNames)}`, MissingDefaultPublishableKeyError, + { + hint: + 'Set SUPABASE_PUBLISHABLE_KEY, or add a "default" entry to SUPABASE_PUBLISHABLE_KEYS ' + + '(a JSON object of name → key), or pass `env.publishableKeys`.', + details: { configuredKeyNames: availableKeyNames ?? [] }, + }, ), [MissingResourceServerError]: (): EnvError => new EnvError( - "resourceServer is required outside Supabase Edge Functions. Pass it to withOAuthProtectedResource(), e.g. { resourceServer: (req) => new URL(req.url).origin + '/api/mcp' }.", + 'resourceServer is required outside Supabase Edge Functions and could not be derived.', MissingResourceServerError, + { + hint: + 'Pass it to withOAuthProtectedResource(), e.g. ' + + "{ resourceServer: (req) => new URL(req.url).origin + '/api/mcp' }. On Edge Functions it " + + 'is derived from the request instead.', + }, ), + [MissingAuthorizationServerError]: (): EnvError => new EnvError( - "authorizationServer is required outside Supabase Edge Functions. Pass it to withOAuthProtectedResource() — use fromSupabaseUrl('https://.supabase.co') for Supabase Auth — or set SUPABASE_URL.", + 'authorizationServer is required outside Supabase Edge Functions and could not be derived.', MissingAuthorizationServerError, + { + hint: + 'Pass it to withOAuthProtectedResource() — use ' + + "fromSupabaseUrl('https://.supabase.co') for Supabase Auth — or set " + + 'SUPABASE_PUBLIC_URL or SUPABASE_URL.', + }, + ), + + [MissingConnectionStringError]: ( + /** The middleware that needed the connection string, for a precise hint. */ + middleware: string, + ): EnvError => + new EnvError( + 'A Postgres connection string is required, and none is configured.', + MissingConnectionStringError, + { + hint: + `Set SUPABASE_DB_URL, or pass \`connectionString\` to ${middleware}(). Supabase Edge ` + + 'Functions provide SUPABASE_DB_URL automatically; elsewhere, copy it from Project ' + + 'Settings → Database → Connection string.', + details: { middleware }, + }, ), } @@ -133,45 +397,51 @@ const EnvErrorMap = { * Thrown when authentication or authorization fails. * * Carries an HTTP `status` code suitable for returning directly in a response - * (typically `401` for invalid credentials, `500` for server-side auth failures). + * (`401` when the request's credentials are at fault, `500` when the server is + * misconfigured and could not have authenticated anyone). * * @example Catching an AuthError * ```ts - * import { AuthError, createSupabaseContext } from '@supabase/server' + * import { createSupabaseContext } from '@supabase/server' * * const { data: ctx, error } = await createSupabaseContext(request, { auth: 'user' }) * if (error) { - * // error is an AuthError - * return Response.json( - * { message: error.message, code: error.code }, - * { status: error.status }, - * ) + * // error is an AuthError — `toJSON()` includes source, code, message, hint, docs, details + * return Response.json(error.toJSON(), { status: error.status }) * } * ``` * * @category Errors */ -export class AuthError extends Error { +export class AuthError extends SupabaseServerError { /** * HTTP status code. * - * - `401` — Invalid or missing credentials - * - `500` — Server-side auth failure (e.g., missing JWKS, env misconfiguration) + * - `401` — The request's credentials are at fault ({@link MissingCredentialsError}, + * {@link InvalidApiKeyError}, {@link InvalidJwtError}). + * - `500` — The server is misconfigured ({@link JwksNotConfiguredError}, + * {@link NoKeysConfiguredError}, {@link JwksFetchFailedError}, + * {@link CreateSupabaseClientError}). */ readonly status: number /** - * Machine-readable error code. - * - * @see {@link AuthGenericError}, {@link InvalidCredentialsError}, - * {@link CreateSupabaseClientError} + * @param message - Human-readable description. Prefixed with `[@supabase/server]`. + * @param code - Machine-readable code. @see {@link AuthGenericError}, {@link MissingCredentialsError}, + * {@link InvalidApiKeyError}, {@link InvalidJwtError}, {@link InvalidCredentialsError}, + * {@link JwksNotConfiguredError}, {@link JwksFetchFailedError}, + * {@link NoKeysConfiguredError}, {@link CreateSupabaseClientError} + * @param status - HTTP status. Defaults to `401`. + * @param options - Optional `hint`, `details`, `docs`, and `cause`. */ - readonly code: string - - constructor(message: string, code = AuthGenericError, status = 401) { - super(message) + constructor( + message: string, + code = AuthGenericError, + status = 401, + options?: SupabaseServerErrorOptions, + ) { + super(message, code, options) this.name = 'AuthError' - this.code = code this.status = status } } @@ -183,11 +453,69 @@ export class AuthError extends Error { export const AuthGenericError = 'AUTH_ERROR' /** - * No credential matched any allowed auth mode. + * The request carried no credentials at all — neither an `apikey` header nor + * an `Authorization: Bearer` token — and no accepted auth mode allows that. + * + * @category Errors + */ +export const MissingCredentialsError = 'MISSING_CREDENTIALS' + +/** + * An `apikey` header was present but matched none of the keys configured for + * the accepted auth modes. + * + * @category Errors + */ +export const InvalidApiKeyError = 'INVALID_API_KEY' + +/** + * A JWT was present in the `Authorization` header but failed verification. + * `details.jwt` and the message carry the specific reason (expired, bad + * signature, unknown `kid`, malformed, no `sub`). + * + * @category Errors + */ +export const InvalidJwtError = 'INVALID_JWT' + +/** + * Generic credential failure. Retained as the fallback code for cases that + * don't match a more specific one. + * + * @remarks Prior to v1.5 this was the only code {@link withSupabase} ever + * returned for a failed request. Prefer matching on the specific codes — + * {@link MissingCredentialsError}, {@link InvalidApiKeyError}, + * {@link InvalidJwtError} — which now cover nearly every real failure. + * * @category Errors */ export const InvalidCredentialsError = 'INVALID_CREDENTIALS' +/** + * Auth mode `"user"` was requested and a JWT was supplied, but no JWKS is + * configured — so the token cannot be verified. Server misconfiguration + * (`status: 500`), not a bad request. + * + * @category Errors + */ +export const JwksNotConfiguredError = 'JWKS_NOT_CONFIGURED' + +/** + * The remote JWKS endpoint could not be fetched or returned something + * unusable. Server-side / upstream failure (`status: 500`), not a bad request. + * + * @category Errors + */ +export const JwksFetchFailedError = 'JWKS_FETCH_FAILED' + +/** + * An `"publishable"` or `"secret"` auth mode was requested but no keys of that + * kind are configured, so the mode could never match. Server + * misconfiguration (`status: 500`). + * + * @category Errors + */ +export const NoKeysConfiguredError = 'NO_KEYS_CONFIGURED' + /** * Failed to create a Supabase client after auth succeeded. * @category Errors @@ -203,25 +531,439 @@ export const CreateSupabaseClientError = 'CREATE_SUPABASE_CLIENT_ERROR' */ export const UnsupportedRoleError = 'UNSUPPORTED_ROLE' +/** + * How an `apikey` header value was classified by its public prefix. Used in + * diagnostics so a format mismatch can be reported without echoing the key. + * + * @category Errors + */ +export type ApiKeyFormat = + | 'absent' + | 'publishable' + | 'secret' + | 'legacy-jwt' + | 'unrecognized' + +/** + * Non-sensitive summary of the credentials a request carried. + * @category Errors + */ +export interface ReceivedCredentials { + /** + * What the `Authorization` header carried. + * + * - `'bearer'` — a bearer token that looks like a JWT + * - `'api-key'` — an `sb_*` API key, which the Supabase SDK forwards here + * alongside the `apikey` header; not a user token + * - `'non-bearer-scheme'` — present but unusable (wrong scheme, wrong + * casing, bare value, empty token) + * - `'absent'` — no header + */ + authorization: 'bearer' | 'api-key' | 'non-bearer-scheme' | 'absent' + + /** The `apikey` header classified by prefix. Never the value itself. */ + apikey: ApiKeyFormat +} + +/** + * Everything the auth pipeline knows about a failed attempt, minus anything + * secret. Used to build messages, hints, and `details`. + * + * @category Errors + */ +export interface AuthFailureContext { + /** Auth modes that were attempted, in the order they were tried. */ + authModes: readonly string[] + + /** Which credential headers the request carried. */ + received: ReceivedCredentials + + /** + * Names (never values) of the keys configured for the attempted modes. + * Omitted for modes that don't use API keys. + */ + configuredKeyNames?: Record +} + +/** + * Maps an auth mode to the header the caller must send for it to match. + * @internal + */ +function credentialForMode(mode: string): string | null { + if (mode === 'user') return 'Authorization: Bearer ' + if (mode === 'publishable' || mode.startsWith('publishable:')) + return 'apikey: ' + if (mode === 'secret' || mode.startsWith('secret:')) + return 'apikey: ' + return null +} + +/** + * Builds a "send one of these" hint from the attempted auth modes. + * @internal + */ +function sendOneOfHint(authModes: readonly string[]): string | undefined { + const options = [ + ...new Set( + authModes + .map((mode) => { + const credential = credentialForMode(mode) + return credential ? `${credential} (for auth mode "${mode}")` : null + }) + .filter((entry): entry is string => entry !== null), + ), + ] + if (options.length === 0) return undefined + const lead = options.length > 1 ? 'Send one of' : 'Send' + return `${lead}: ${options.join('; ')}.` +} + +/** Human label for a key format, for use in hints. @internal */ +const ApiKeyFormatLabel: Record = { + absent: 'no apikey header', + publishable: 'a publishable key (sb_publishable_…)', + secret: 'a secret key (sb_secret_…)', + 'legacy-jwt': 'a legacy JWT-style key (eyJ…)', + unrecognized: 'a value in an unrecognized format', +} + +/** + * Explains an API key rejection, favouring the format-mismatch case — sending + * a secret key to a publishable-only endpoint (or vice versa) is a far more + * common mistake than a genuinely wrong key. + * + * @internal + */ +function apiKeyHint(context: AuthFailureContext): string { + const { authModes, received } = context + const keyModes = authModes.filter((mode) => + credentialForMode(mode)?.startsWith('apikey'), + ) + const acceptsPublishable = keyModes.some((mode) => + mode.startsWith('publishable'), + ) + const acceptsSecret = keyModes.some((mode) => mode.startsWith('secret')) + + if (received.apikey === 'legacy-jwt') { + return ( + 'The apikey looks like a legacy JWT-based key (anon / service_role). ' + + '@supabase/server expects the newer API key format — sb_publishable_… or sb_secret_…. ' + + 'Find them under Project Settings → API Keys.' + ) + } + if (received.apikey === 'secret' && acceptsPublishable && !acceptsSecret) { + return ( + 'You sent a secret key, but this endpoint only accepts publishable keys. ' + + 'Send a sb_publishable_… key, or add "secret" to `auth` if server-to-server calls should be allowed.' + ) + } + if ( + received.apikey === 'publishable' && + acceptsSecret && + !acceptsPublishable + ) { + return ( + 'You sent a publishable key, but this endpoint only accepts secret keys. ' + + 'Send a sb_secret_… key, or add "publishable" to `auth` if client-facing calls should be allowed.' + ) + } + if (received.apikey === 'unrecognized') { + return ( + `The apikey header held ${ApiKeyFormatLabel.unrecognized} — Supabase API keys start with ` + + 'sb_publishable_ or sb_secret_. Copy the key from Project Settings → API Keys.' + ) + } + + // Name only the key kinds the endpoint actually accepts — pointing at + // SUPABASE_SECRET_KEYS for a publishable-only endpoint sends people the + // wrong way. + const kinds = Object.entries(context.configuredKeyNames ?? {}) + const names = kinds + .map( + ([kind, keyNames]) => + `${kind}: ${keyNames.length ? quoteList(keyNames) : 'none configured'}`, + ) + .join('; ') + const envVars = kinds + .map(([kind]) => `SUPABASE_${kind.toUpperCase()}_KEY(S)`) + .join(' / ') + return ( + `The key was well-formed but matched no configured key${names ? ` (${names})` : ''}. ` + + `Keys come from ${envVars || 'the SUPABASE_*_KEY(S) variables'}, or the \`env\` option. ` + + 'Check you are pointing at the right Supabase project.' + ) +} + const AuthErrorMap = { - [InvalidCredentialsError]: (): AuthError => - new AuthError('Invalid credentials', InvalidCredentialsError, 401), - [CreateSupabaseClientError]: (): AuthError => + [MissingCredentialsError]: (context: AuthFailureContext): AuthError => { + const { authorization } = context.received + return new AuthError( + `No usable credentials found on the request. This endpoint accepts auth mode(s): ${quoteList(context.authModes)}.`, + MissingCredentialsError, + 401, + { + hint: [ + authorization === 'non-bearer-scheme' && + 'The Authorization header was present but did not use the `Bearer` scheme, so no token was read.', + authorization === 'api-key' && + 'The Authorization header carried an sb_* API key, not a user JWT. API keys belong in the `apikey` header; ' + + 'the Supabase SDK sends them in both, which is why this is easy to miss.', + sendOneOfHint(context.authModes), + ] + .filter(Boolean) + .join(' '), + details: { + acceptedAuthModes: context.authModes, + received: context.received, + }, + }, + ) + }, + + [InvalidApiKeyError]: (context: AuthFailureContext): AuthError => + new AuthError( + `The apikey header matched no key configured for auth mode(s): ${quoteList(context.authModes)}.`, + InvalidApiKeyError, + 401, + { + hint: apiKeyHint(context), + details: { + acceptedAuthModes: context.authModes, + received: context.received, + ...(context.configuredKeyNames + ? { configuredKeyNames: context.configuredKeyNames } + : {}), + }, + }, + ), + + // Context is partial because the claims middleware verify a JWT without an + // auth-mode chain to report — they only ever accept a user token. + [InvalidJwtError]: ( + context: Partial & { + /** Why verification failed, in plain language. */ + reason: string + /** How to fix it. */ + hint: string + /** Non-sensitive JWT header fields (`alg`, `kid`). Never claim values. */ + jwt?: Record + /** The underlying `jose` error, when there was one. */ + cause?: unknown + }, + ): AuthError => new AuthError( - 'Failed to create Supabase client', + `The JWT in the Authorization header failed verification: ${context.reason}.`, + InvalidJwtError, + 401, + { + hint: context.hint, + details: { + ...(context.authModes + ? { acceptedAuthModes: context.authModes } + : {}), + ...(context.received ? { received: context.received } : {}), + ...(context.jwt ? { jwt: context.jwt } : {}), + }, + cause: context.cause, + }, + ), + + // Context is partial because the claims middleware reach this without an + // auth-mode chain; `middleware` names the option to pass instead of `env.jwks`. + [JwksNotConfiguredError]: ( + context: Partial & { middleware?: string } = {}, + ): AuthError => + new AuthError( + 'A JWT was provided but no JWKS is configured, so it cannot be verified. ' + + 'This is a server configuration problem, not a problem with the request.', + JwksNotConfiguredError, + 500, + { + hint: + 'Set SUPABASE_JWKS_URL (e.g. https://.supabase.co/auth/v1/.well-known/jwks.json) ' + + 'or SUPABASE_JWKS (inline JSON), or pass ' + + (context.middleware + ? `\`jwks\` to ${context.middleware}()` + : '`env.jwks`') + + '. Note that a malformed value resolves to null rather than erroring: SUPABASE_JWKS must be ' + + 'valid JSON, and SUPABASE_JWKS_URL must be https (plain http is only allowed for localhost).', + details: { + ...(context.authModes + ? { acceptedAuthModes: context.authModes } + : {}), + ...(context.received ? { received: context.received } : {}), + ...(context.middleware ? { middleware: context.middleware } : {}), + }, + }, + ), + + [JwksFetchFailedError]: ( + context: Partial & { + reason: string + cause?: unknown + }, + ): AuthError => + new AuthError( + `The remote JWKS could not be fetched, so the JWT could not be verified: ${context.reason}. ` + + 'This is a server-side or upstream failure, not a problem with the request.', + JwksFetchFailedError, + 500, + { + hint: + 'Check that SUPABASE_JWKS_URL points at a reachable JWKS endpoint and that the server has ' + + 'outbound network access to it. The endpoint must return 200 with a JSON `{ "keys": [...] }` body.', + details: { + ...(context.authModes + ? { acceptedAuthModes: context.authModes } + : {}), + ...(context.received ? { received: context.received } : {}), + }, + cause: context.cause, + }, + ), + + [NoKeysConfiguredError]: ( + context: AuthFailureContext & { + /** The full auth mode that could never match, e.g. `"publishable:mobile"`. */ + mode: string + /** Which key set the mode draws from. */ + keyKind: 'publishable' | 'secret' + }, + ): AuthError => { + const envVar = + context.keyKind === 'publishable' + ? 'SUPABASE_PUBLISHABLE_KEY / SUPABASE_PUBLISHABLE_KEYS' + : 'SUPABASE_SECRET_KEY / SUPABASE_SECRET_KEYS' + const configured = context.configuredKeyNames?.[context.keyKind] ?? [] + return new AuthError( + `Auth mode "${context.mode}" was requested but no matching ${context.keyKind} key is configured, ` + + 'so the mode could never match any request. This is a server configuration problem, not a ' + + 'problem with the request.', + NoKeysConfiguredError, + 500, + { + hint: + `${configuredNames(configured)} Set ${envVar} (the plural form is a JSON object of ` + + `name → key), or pass \`env.${context.keyKind}Keys\`.`, + details: { + acceptedAuthModes: context.authModes, + received: context.received, + mode: context.mode, + keyKind: context.keyKind, + ...(context.configuredKeyNames + ? { configuredKeyNames: context.configuredKeyNames } + : {}), + }, + }, + ) + }, + + [InvalidCredentialsError]: (context?: AuthFailureContext): AuthError => + new AuthError( + context + ? `No credential matched any of the accepted auth mode(s): ${quoteList(context.authModes)}.` + : 'No credential matched any accepted auth mode.', + InvalidCredentialsError, + 401, + { + hint: context + ? sendOneOfHint(context.authModes) + : 'Check that the request carries a credential for one of the accepted auth modes.', + details: context + ? { + acceptedAuthModes: context.authModes, + received: context.received, + } + : undefined, + }, + ), + + [CreateSupabaseClientError]: (options?: { cause?: unknown }): AuthError => + new AuthError( + 'Authentication succeeded but the Supabase client could not be created.', CreateSupabaseClientError, 500, + { + hint: + 'This is almost always a missing or malformed SUPABASE_URL or API key. The underlying ' + + 'error is attached as `cause` — log it to see which value is at fault.', + cause: options?.cause, + }, ), + + [UnsupportedRoleError]: (context: { + /** The `role` claim as it arrived, whatever its type. */ + requestedRole: unknown + /** Roles the middleware will assume. */ + supportedRoles: readonly string[] + }): AuthError => { + const { requestedRole, supportedRoles } = context + const supported = quoteList(supportedRoles) + + const [reason, hint] = + requestedRole === 'service_role' + ? [ + 'the caller\'s token carries the "service_role" role, which withPostgresClient will not assume', + 'That role bypasses RLS — the guarantee this middleware exists to provide. If bypassing ' + + "RLS is intended, compose withPostgresAdminClient from '@supabase/server/middleware/postgres-admin'.", + ] + : typeof requestedRole === 'string' + ? [ + `the caller's token carries the role "${requestedRole}", which withPostgresClient does not support yet`, + `It assumes ${supported} only. Custom roles are on the roadmap; until then, issue tokens ` + + 'with one of the supported roles.', + ] + : [ + `the caller's token has a "role" claim that is not a string (${JSON.stringify(requestedRole)})`, + `withPostgresClient assumes ${supported} only and will not guess what a malformed claim ` + + 'meant. Check how the token is minted.', + ] + + return new AuthError( + `Cannot select a Postgres role: ${reason}.`, + UnsupportedRoleError, + 500, + { + hint, + details: { requestedRole, supportedRoles }, + }, + ) + }, +} + +/** + * Returns a copy of `error` carrying an extra leading hint sentence and merged + * `details`. Lets an outer layer add diagnostics the inner layer could not see — + * {@link core.verifyAuth} can inspect the raw `Authorization` header, while + * {@link core.verifyCredentials} only receives already-extracted + * {@link Credentials}. + * + * @internal + */ +export function withExtraDiagnostics( + error: AuthError, + extra: { hint?: string; details?: Record }, +): AuthError { + return new AuthError(error.message, error.code, error.status, { + hint: [extra.hint, error.hint].filter(Boolean).join(' ') || undefined, + details: + error.details || extra.details + ? { ...error.details, ...extra.details } + : undefined, + docs: error.docs, + cause: error.cause, + }) } /** * Factory map for all error types. Keyed by error code constant, each entry - * returns a pre-configured {@link EnvError} or {@link AuthError}. + * returns a pre-configured {@link EnvError} or {@link AuthError} complete with + * `hint`, `docs`, and `details`. * * @example Throwing typed errors * ```ts * throw Errors[MissingSupabaseURLError]() - * throw Errors[MissingPublishableKeyError]('mobile') + * throw Errors[MissingPublishableKeyError]('mobile', ['default', 'web']) * ``` * * @category Errors diff --git a/src/index.ts b/src/index.ts index 006479d..8c6ab02 100644 --- a/src/index.ts +++ b/src/index.ts @@ -126,14 +126,32 @@ export { CreateSupabaseClientError, EnvError, EnvGenericError, + ErrorCodeHeader, Errors, + ErrorSource, + InvalidApiKeyError, InvalidCredentialsError, + InvalidJwtError, + JwksFetchFailedError, + JwksNotConfiguredError, MissingAuthorizationServerError, + MissingConnectionStringError, + MissingCredentialsError, MissingDefaultPublishableKeyError, MissingDefaultSecretKeyError, MissingPublishableKeyError, MissingResourceServerError, MissingSecretKeyError, MissingSupabaseURLError, + NoKeysConfiguredError, + SupabaseServerError, UnsupportedRoleError, } from './errors.js' + +export type { + ApiKeyFormat, + AuthFailureContext, + ErrorPayload, + ReceivedCredentials, + SupabaseServerErrorOptions, +} from './errors.js' diff --git a/src/middleware/claims/index.test.ts b/src/middleware/claims/index.test.ts index 728216e..4113c26 100644 --- a/src/middleware/claims/index.test.ts +++ b/src/middleware/claims/index.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import type { JSONWebKeySet } from 'jose' -import { InvalidCredentialsError } from '../../errors.js' +import { InvalidJwtError } from '../../errors.js' import { withClaims } from './index.js' describe('withClaims', () => { @@ -75,7 +75,7 @@ describe('withClaims', () => { const res = await handler(requestWithToken(foreignToken)) expect(res.status).toBe(401) const body = await res.json() - expect(body.code).toBe(InvalidCredentialsError) + expect(body.code).toBe(InvalidJwtError) }) it('short-circuits 401 for a malformed token', async () => { diff --git a/src/middleware/claims/index.ts b/src/middleware/claims/index.ts index 07a3299..b9bbcb7 100644 --- a/src/middleware/claims/index.ts +++ b/src/middleware/claims/index.ts @@ -5,7 +5,13 @@ import type { JSONWebKeySet } from 'jose' import { extractCredentials } from '../../core/extract-credentials.js' import { resolveJwks } from '../../core/resolve-env.js' import { verifyUserJwt } from '../../core/verify-user-jwt.js' -import { EnvGenericError, InvalidCredentialsError } from '../../errors.js' +import { errorResponse } from '../../error-response.js' +import { + Errors, + InvalidJwtError, + JwksFetchFailedError, + JwksNotConfiguredError, +} from '../../errors.js' import type { JWTClaims } from '../../types.js' /** @@ -88,21 +94,26 @@ export const withClaims: Middleware< const jwks = config?.jwks ?? resolveJwks() if (!jwks) { - return Response.json( - { - message: - 'A JWKS source is required to verify claims. Set SUPABASE_JWKS or SUPABASE_JWKS_URL, or pass `jwks` to withClaims.', - code: EnvGenericError, - }, - { status: 500 }, + return errorResponse( + Errors[JwksNotConfiguredError]({ middleware: 'withClaims' }), ) } const verified = await verifyUserJwt(token, jwks) - if (!verified) { - return Response.json( - { message: 'Invalid credentials', code: InvalidCredentialsError }, - { status: 401 }, + if (!verified.ok) { + const { failure } = verified + return errorResponse( + failure.kind === 'jwks-source' + ? Errors[JwksFetchFailedError]({ + reason: failure.reason, + cause: failure.cause, + }) + : Errors[InvalidJwtError]({ + reason: failure.reason, + hint: failure.hint, + jwt: failure.jwt, + cause: failure.cause, + }), ) } diff --git a/src/middleware/postgres-admin/index.test.ts b/src/middleware/postgres-admin/index.test.ts index 726b582..05b8573 100644 --- a/src/middleware/postgres-admin/index.test.ts +++ b/src/middleware/postgres-admin/index.test.ts @@ -52,9 +52,9 @@ describe('withPostgresAdminClient', () => { const res = await handler(new Request('http://localhost'), seedContext()) expect(res.status).toBe(500) - expect(await res.json()).toEqual({ - message: expect.stringContaining('withPostgresAdminClient'), - code: 'ENV_ERROR', + expect(await res.json()).toMatchObject({ + code: 'MISSING_CONNECTION_STRING', + hint: expect.stringContaining('withPostgresAdminClient'), }) }) diff --git a/src/middleware/postgres/index.test.ts b/src/middleware/postgres/index.test.ts index f0cf411..463fa40 100644 --- a/src/middleware/postgres/index.test.ts +++ b/src/middleware/postgres/index.test.ts @@ -88,9 +88,9 @@ describe('withPostgresClient', () => { }) expect(res.status).toBe(500) - expect(await res.json()).toEqual({ - message: expect.stringContaining('SUPABASE_DB_URL'), - code: 'ENV_ERROR', + expect(await res.json()).toMatchObject({ + code: 'MISSING_CONNECTION_STRING', + hint: expect.stringContaining('SUPABASE_DB_URL'), }) }) @@ -218,9 +218,15 @@ describe('withPostgresClient', () => { }) expect(res.status).toBe(500) - const body = (await res.json()) as { message: string; code: string } + const body = (await res.json()) as { + message: string + code: string + hint: string + } expect(body.code).toBe('UNSUPPORTED_ROLE') - expect(body.message).toContain('withPostgresAdminClient') + expect(body.message).toContain('service_role') + // The message names the refusal; `hint` names the supported alternative. + expect(body.hint).toContain('withPostgresAdminClient') // Never silently downgraded to anon, and never actually used. expect(h.issued).not.toContain('set local role "anon"') expect(h.issued).not.toContain('set local role "service_role"') diff --git a/src/middleware/postgres/index.ts b/src/middleware/postgres/index.ts index af71517..6ba8d02 100644 --- a/src/middleware/postgres/index.ts +++ b/src/middleware/postgres/index.ts @@ -8,7 +8,8 @@ import { } from '../../core/postgres-pool.js' import type { PostgresApi } from '../../core/postgres-pool.js' import { compileTemplate, ident } from '../../core/sql.js' -import { UnsupportedRoleError } from '../../errors.js' +import { errorResponse } from '../../error-response.js' +import { Errors, UnsupportedRoleError } from '../../errors.js' export type { PostgresApi } // `ident` is exported here rather than only from core: it is the companion @@ -46,14 +47,12 @@ function resolveRole(claims: RequestClaims | null): string | Response { return requested } - const message = - requested === 'service_role' - ? "The caller's token carries the 'service_role' role. withPostgresClient will not assume it — that role bypasses RLS, which is the guarantee this middleware exists to provide. If bypassing RLS is intended, compose withPostgresAdminClient from '@supabase/server/middleware/postgres-admin'." - : typeof requested === 'string' - ? `The caller's token carries the role '${requested}', which withPostgresClient does not support yet — it assumes 'authenticated' or 'anon' only. Custom roles are on the roadmap; until then, issue tokens with one of the supported roles.` - : `The caller's token carries a 'role' claim that is not a string (${JSON.stringify(requested)}). withPostgresClient assumes 'authenticated' or 'anon' only and will not guess what a malformed claim meant.` - - return Response.json({ message, code: UnsupportedRoleError }, { status: 500 }) + return errorResponse( + Errors[UnsupportedRoleError]({ + requestedRole: requested, + supportedRoles: [...SUPPORTED_ROLES], + }), + ) } /** diff --git a/src/middleware/required-claims/index.test.ts b/src/middleware/required-claims/index.test.ts index 624c57e..a3d8369 100644 --- a/src/middleware/required-claims/index.test.ts +++ b/src/middleware/required-claims/index.test.ts @@ -12,7 +12,11 @@ import { import type { JSONWebKeySet } from 'jose' -import { EnvGenericError, InvalidCredentialsError } from '../../errors.js' +import { + InvalidJwtError, + JwksNotConfiguredError, + MissingCredentialsError, +} from '../../errors.js' import { withSupabase } from '../../with-supabase.js' import { withClaims } from '../claims/index.js' import { withPostgresClient } from '../postgres/index.js' @@ -95,7 +99,7 @@ describe('withRequiredClaims', () => { const res = await handler(requestWithToken()) expect(res.status).toBe(401) const body = await res.json() - expect(body.code).toBe(InvalidCredentialsError) + expect(body.code).toBe(MissingCredentialsError) expect(ran).toBe(false) }) @@ -117,7 +121,7 @@ describe('withRequiredClaims', () => { const res = await handler(requestWithToken(apikey)) expect(res.status).toBe(401) const body = await res.json() - expect(body.code).toBe(InvalidCredentialsError) + expect(body.code).toBe(MissingCredentialsError) expect(ran).toBe(false) } }) @@ -130,7 +134,7 @@ describe('withRequiredClaims', () => { const res = await handler(requestWithToken(foreignToken)) expect(res.status).toBe(401) const body = await res.json() - expect(body.code).toBe(InvalidCredentialsError) + expect(body.code).toBe(InvalidJwtError) }) it('short-circuits 401 for a malformed token', async () => { @@ -150,8 +154,10 @@ describe('withRequiredClaims', () => { const res = await handler(requestWithToken(rsToken)) expect(res.status).toBe(500) const body = await res.json() - expect(body.code).toBe(EnvGenericError) + expect(body.code).toBe(JwksNotConfiguredError) expect(body.message).toContain('JWKS') + // The hint names the middleware's own option, not `env.jwks`. + expect(body.hint).toContain('withRequiredClaims()') }) it('short-circuits 401 when neither a token nor a JWKS is present', async () => { @@ -165,7 +171,7 @@ describe('withRequiredClaims', () => { const res = await handler(requestWithToken()) expect(res.status).toBe(401) const body = await res.json() - expect(body.code).toBe(InvalidCredentialsError) + expect(body.code).toBe(MissingCredentialsError) }) describe('parity with withSupabase auth: "user"', () => { @@ -204,37 +210,37 @@ describe('withRequiredClaims', () => { expect(await gate.json()).toEqual(await supabase.json()) }) - it('missing token: both 401 INVALID_CREDENTIALS', async () => { + it('missing token: both 401 MISSING_CREDENTIALS', async () => { const { gate, supabase } = await both(undefined, jwks) for (const res of [gate, supabase]) { expect(res.status).toBe(401) - expect((await res.json()).code).toBe(InvalidCredentialsError) + expect((await res.json()).code).toBe(MissingCredentialsError) } }) - it('sb_* key in the Authorization slot: both 401 INVALID_CREDENTIALS', async () => { + it('sb_* key in the Authorization slot: both 401 MISSING_CREDENTIALS', async () => { const { gate, supabase } = await both('sb_secret_other', jwks) for (const res of [gate, supabase]) { expect(res.status).toBe(401) - expect((await res.json()).code).toBe(InvalidCredentialsError) + expect((await res.json()).code).toBe(MissingCredentialsError) } }) - it('token signed by an unknown key: both 401 INVALID_CREDENTIALS', async () => { + it('token signed by an unknown key: both 401 INVALID_JWT', async () => { const { gate, supabase } = await both(foreignToken, jwks) for (const res of [gate, supabase]) { expect(res.status).toBe(401) - expect((await res.json()).code).toBe(InvalidCredentialsError) + expect((await res.json()).code).toBe(InvalidJwtError) } }) - it('token present but no JWKS configured: both 500 ENV_ERROR', async () => { + it('token present but no JWKS configured: both 500 JWKS_NOT_CONFIGURED', async () => { vi.stubEnv('SUPABASE_JWKS', '') vi.stubEnv('SUPABASE_JWKS_URL', '') const { gate, supabase } = await both(rsToken, null) for (const res of [gate, supabase]) { expect(res.status).toBe(500) - expect((await res.json()).code).toBe(EnvGenericError) + expect((await res.json()).code).toBe(JwksNotConfiguredError) } }) }) diff --git a/src/middleware/required-claims/index.ts b/src/middleware/required-claims/index.ts index 553ee02..8ff4fb0 100644 --- a/src/middleware/required-claims/index.ts +++ b/src/middleware/required-claims/index.ts @@ -4,8 +4,16 @@ import type { JSONWebKeySet } from 'jose' import { extractCredentials } from '../../core/extract-credentials.js' import { resolveJwks } from '../../core/resolve-env.js' +import { classifyApiKey } from '../../core/utils/classify-credentials.js' import { verifyUserJwt } from '../../core/verify-user-jwt.js' -import { EnvGenericError, InvalidCredentialsError } from '../../errors.js' +import { errorResponse } from '../../error-response.js' +import { + Errors, + InvalidJwtError, + JwksFetchFailedError, + JwksNotConfiguredError, + MissingCredentialsError, +} from '../../errors.js' import type { JWTClaims } from '../../types.js' /** @@ -92,29 +100,42 @@ export const withRequiredClaims: Middleware< // header — they are API keys, not user JWTs, so they cannot pass a gate // that requires verified user claims. if (!token || token.startsWith('sb_')) { - return Response.json( - { message: 'Invalid credentials', code: InvalidCredentialsError }, - { status: 401 }, + const { apikey } = extractCredentials(req) + return errorResponse( + Errors[MissingCredentialsError]({ + authModes: ['user'], + received: { + // An `sb_*` value in Authorization is an API key, not a JWT — the + // header arrived, but carried nothing this gate can verify. + authorization: token ? 'api-key' : 'absent', + apikey: classifyApiKey(apikey), + }, + }), ) } const jwks = config?.jwks ?? resolveJwks() if (!jwks) { - return Response.json( - { - message: - 'A JWKS source is required to verify claims. Set SUPABASE_JWKS or SUPABASE_JWKS_URL, or pass `jwks` to withRequiredClaims.', - code: EnvGenericError, - }, - { status: 500 }, + return errorResponse( + Errors[JwksNotConfiguredError]({ middleware: 'withRequiredClaims' }), ) } const verified = await verifyUserJwt(token, jwks) - if (!verified) { - return Response.json( - { message: 'Invalid credentials', code: InvalidCredentialsError }, - { status: 401 }, + if (!verified.ok) { + const { failure } = verified + return errorResponse( + failure.kind === 'jwks-source' + ? Errors[JwksFetchFailedError]({ + reason: failure.reason, + cause: failure.cause, + }) + : Errors[InvalidJwtError]({ + reason: failure.reason, + hint: failure.hint, + jwt: failure.jwt, + cause: failure.cause, + }), ) } 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 3f4f6e4..5dc8a4c 100644 --- a/src/oauth-protected-resource/with-oauth-protected-resource.test.ts +++ b/src/oauth-protected-resource/with-oauth-protected-resource.test.ts @@ -726,7 +726,11 @@ describe('withOAuthProtectedResource - off-platform defaults fail loudly', () => req('GET', '/api/mcp/oauth-protected-resource'), ) await expect(call).rejects.toThrow(/resourceServer/) - await expect(call).rejects.toThrow(/withOAuthProtectedResource\(\)/) + // The message names what is missing; `hint` names how to supply it. + await expect(call).rejects.toMatchObject({ + code: MissingResourceServerError, + hint: expect.stringMatching(/withOAuthProtectedResource\(\)/), + }) }) it('a fully configured stack never reaches the env at all', async () => { diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index 15bb45b..1335342 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -3,7 +3,14 @@ import { defineMiddleware, getEnv } from '@supabase/middleware' import type { Entry, FetchHandler } from '@supabase/middleware' import { _resetAllowDeprecationWarned } from './core/utils/deprecation.js' -import { EnvError, MissingDefaultSecretKeyError } from './errors.js' +import { + EnvError, + ErrorCodeHeader, + JwksNotConfiguredError, + MissingCredentialsError, + MissingDefaultSecretKeyError, +} from './errors.js' +import type { WithSupabaseConfig } from './types.js' import { withClaims } from './middleware/claims/index.js' import { withPostgresClient } from './middleware/postgres/index.js' import { withOAuthProtectedResource } from './oauth-protected-resource/with-oauth-protected-resource.js' @@ -73,6 +80,101 @@ describe('withSupabase', () => { expect(body.code).toBeDefined() }) + describe('error response shape', () => { + async function errorResponse(config?: Partial) { + const handler = withSupabase( + { auth: 'user', env: baseEnv, ...config }, + async () => Response.json({ ok: true }), + ) + return handler(new Request('http://localhost')) + } + + it('returns the full diagnostic payload', async () => { + const body = await (await errorResponse()).json() + + expect(body).toEqual({ + source: '@supabase/server', + code: MissingCredentialsError, + message: expect.stringContaining('[@supabase/server] '), + hint: expect.stringContaining('Authorization: Bearer '), + docs: expect.stringContaining('error-handling.md#missing_credentials'), + details: { + acceptedAuthModes: ['user'], + received: { authorization: 'absent', apikey: 'absent' }, + }, + }) + }) + + it('keeps message and code at the top level for existing consumers', async () => { + const body = await (await errorResponse()).json() + expect(typeof body.message).toBe('string') + expect(body.code).toBe(MissingCredentialsError) + }) + + it('repeats the code in the x-supabase-server-error header', async () => { + const res = await errorResponse() + expect(res.headers.get(ErrorCodeHeader)).toBe(MissingCredentialsError) + }) + + it('exposes the code header to cross-origin callers', async () => { + const res = await errorResponse() + expect(res.headers.get('Access-Control-Expose-Headers')).toBe( + ErrorCodeHeader, + ) + }) + + it('appends to an existing Access-Control-Expose-Headers value', async () => { + const res = await errorResponse({ + cors: { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Expose-Headers': 'x-request-id', + }, + }, + }) + expect(res.headers.get('Access-Control-Expose-Headers')).toBe( + `x-request-id, ${ErrorCodeHeader}`, + ) + }) + + it('still sets the code header when CORS is disabled', async () => { + const res = await errorResponse({ cors: 'disabled' }) + expect(res.headers.get(ErrorCodeHeader)).toBe(MissingCredentialsError) + expect(res.headers.get('Access-Control-Expose-Headers')).toBeNull() + }) + + it('reports a missing JWKS as a 500, not a 401', async () => { + const handler = withSupabase({ auth: 'user', env: baseEnv }, async () => + Response.json({ ok: true }), + ) + const res = await handler( + new Request('http://localhost', { + headers: { authorization: 'Bearer header.payload.signature' }, + }), + ) + expect(res.status).toBe(500) + expect(res.headers.get(ErrorCodeHeader)).toBe(JwksNotConfiguredError) + expect((await res.json()).hint).toContain('SUPABASE_JWKS_URL') + }) + + it('carries the specific env code through a client-phase failure', async () => { + // The client middleware throws an EnvError; its code, hint, and details + // must survive rather than collapsing to a generic client error. + const handler = withSupabase( + { auth: 'none', env: { ...baseEnv, publishableKeys: {} } }, + async () => Response.json({ ok: true }), + ) + const res = await handler(new Request('http://localhost')) + expect(res.status).toBe(500) + const body = await res.json() + expect(body.code).toBe('MISSING_DEFAULT_PUBLISHABLE_KEY') + expect(body.hint).toContain('SUPABASE_PUBLISHABLE_KEY') + expect(res.headers.get(ErrorCodeHeader)).toBe( + 'MISSING_DEFAULT_PUBLISHABLE_KEY', + ) + }) + }) + it('adds CORS headers to success response', async () => { const handler = withSupabase({ auth: 'none', env: baseEnv }, async () => Response.json({ ok: true }), diff --git a/src/with-supabase.ts b/src/with-supabase.ts index 2eaa814..9a0ae42 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -1,6 +1,12 @@ import { addCorsHeaders, buildCorsHeaders, isCorsDisabled } from './cors.js' import { verifyAuth } from './core/verify-auth.js' -import { AuthError, CreateSupabaseClientError, EnvError } from './errors.js' +import { errorResponse } from './error-response.js' +import { + AuthError, + CreateSupabaseClientError, + EnvError, + ErrorCodeHeader, +} from './errors.js' import { withSupabaseAdminClient } from './middleware/admin-client/index.js' import { withSupabaseClient } from './middleware/client/index.js' import type { @@ -174,8 +180,24 @@ export function withSupabase( ) return async (req: Request, platformArg?: unknown) => { - const corsHeaders = () => - !isCorsDisabled(config.cors) ? buildCorsHeaders(config.cors) : {} + // Cross-origin browser code cannot read a non-safelisted response header + // unless it is named in Access-Control-Expose-Headers, so the error code + // header would be invisible in exactly the case it is most useful. + const errorHeaders = () => { + if (isCorsDisabled(config.cors)) return {} + const headers = buildCorsHeaders(config.cors) + const exposeKey = + Object.keys(headers).find( + (name) => name.toLowerCase() === 'access-control-expose-headers', + ) ?? 'Access-Control-Expose-Headers' + const exposed = headers[exposeKey] + return { + ...headers, + [exposeKey]: exposed + ? `${exposed}, ${ErrorCodeHeader}` + : ErrorCodeHeader, + } + } if (!isCorsDisabled(config.cors) && req.method === 'OPTIONS') { return new Response(null, { @@ -190,10 +212,7 @@ export function withSupabase( env: config.env, }) if (error) { - return Response.json( - { message: error.message, code: error.code }, - { status: error.status, headers: corsHeaders() }, - ) + return errorResponse(error, { headers: errorHeaders() }) } // Track whether the request has moved past the client entries: only @@ -240,15 +259,19 @@ export function withSupabase( const mapped = !inClientPhase ? null : e instanceof EnvError - ? new AuthError(e.message, e.code, 500) + ? // Keep the EnvError's code, hint, and details — it already names + // the exact variable at fault. + new AuthError(e.message, e.code, 500, { + hint: e.hint, + details: e.details, + docs: e.docs, + cause: e, + }) : e instanceof AuthError && e.code === CreateSupabaseClientError ? e : null if (!mapped) throw e - return Response.json( - { message: mapped.message, code: mapped.code }, - { status: mapped.status, headers: corsHeaders() }, - ) + return errorResponse(mapped, { headers: errorHeaders() }) } if (!isCorsDisabled(config.cors)) { From 517dbae4ca44d73bc6bfe55848a127c306b69b2f Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Thu, 27 Aug 2026 12:20:08 -0300 Subject: [PATCH 2/6] feat: add `errors: { detailed: false }` to trim the error response body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hint`, `docs`, and `details` are written for whoever is building against the endpoint, and not everyone wants them on the wire. `errors.detailed` (default `true`) reduces the body to `code` and `message` alone. Provenance survives the trim: `message` keeps its `[@supabase/server]` prefix, and the code is still sent as the `x-supabase-server-error` header — so the error stays identifiable without the `source` field. Response-only. The HTTP status is unaffected and the error object keeps `hint`, `docs`, and `details` in full, so `createSupabaseContext` callers and the framework adapters see everything. Documented as a verbosity control rather than a security boundary — `code` and `message` still name the failure specifically. Formatting the response by hand via `createSupabaseContext` remains the way to disclose nothing. --- docs/api-reference.md | 11 +++++++++ docs/error-handling.md | 24 +++++++++++++++++++ src/error-response.ts | 39 +++++++++++++++++++++++++++--- src/errors.ts | 11 +++++++++ src/index.ts | 2 ++ src/types.ts | 50 +++++++++++++++++++++++++++++++++++++++ src/with-supabase.test.ts | 40 +++++++++++++++++++++++++++++++ src/with-supabase.ts | 10 ++++++-- 8 files changed, 182 insertions(+), 5 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 197dc53..b5db2da 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -434,9 +434,20 @@ interface WithSupabaseConfig { env?: Partial cors?: boolean | Record // default: true supabaseOptions?: SupabaseClientOptions + errors?: ErrorResponseConfig } ``` +### ErrorResponseConfig + +```ts +interface ErrorResponseConfig { + detailed?: boolean // default: true +} +``` + +`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). + ### SupabaseEnv ```ts diff --git a/docs/error-handling.md b/docs/error-handling.md index aaf6bd7..f643bdf 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -40,6 +40,30 @@ The code is repeated in the `x-supabase-server-error` response header, and added Every layer that answers a request directly uses this shape: `withSupabase`, and the middleware that short-circuit (`withClaims`, `withRequiredClaims`, `withPostgresClient`). +## Trimming the response body + +`hint`, `docs`, and `details` are written for whoever is building against the endpoint. To keep them off the wire, set `errors: { detailed: false }` — the body reduces to `code` and `message`: + +```ts +withSupabase({ auth: 'user', errors: { detailed: false } }, handler) +``` + +``` +HTTP/1.1 401 Unauthorized +x-supabase-server-error: MISSING_CREDENTIALS +``` + +```json +{ + "code": "MISSING_CREDENTIALS", + "message": "[@supabase/server] No credentials found on the request. This endpoint accepts auth mode(s): \"user\"." +} +``` + +The status code and the `x-supabase-server-error` header are unaffected, and `message` keeps its `[@supabase/server]` prefix — so the error stays traceable without the `source` field. The **error object itself is untouched**: `createSupabaseContext` callers and the framework adapters still see `hint`, `docs`, and `details` in full. + +> This is a verbosity control, not a security boundary. `code` and `message` still describe the failure specifically. To disclose nothing, format the response yourself with `createSupabaseContext` (see [Custom error formatting](#custom-error-formatting)). + ## Error classes ``` diff --git a/src/error-response.ts b/src/error-response.ts index 05992a4..b7fd87b 100644 --- a/src/error-response.ts +++ b/src/error-response.ts @@ -1,4 +1,10 @@ -import { ErrorCodeHeader, type SupabaseServerError } from './errors.js' +import { + ErrorCodeHeader, + type ErrorPayload, + type MinimalErrorPayload, + type SupabaseServerError, +} from './errors.js' +import type { ErrorResponseConfig } from './types.js' /** * Renders a {@link SupabaseServerError} as the JSON error response every layer @@ -9,7 +15,7 @@ import { ErrorCodeHeader, type SupabaseServerError } from './errors.js' * same body, same `x-supabase-server-error` header, same status. * * @param error - The error to render. - * @param options - Extra headers, merged in ahead of the code header. + * @param options - Extra headers (CORS, typically) and body verbosity. * * @internal */ @@ -18,10 +24,37 @@ export function errorResponse( options?: { /** Merged in ahead of the code header — CORS headers, typically. */ headers?: Record + /** Body verbosity. @see {@link ErrorResponseConfig} */ + errors?: ErrorResponseConfig }, ): Response { - return Response.json(error.toJSON(), { + return Response.json(buildErrorBody(error, options?.errors), { status: error.status, headers: { ...options?.headers, [ErrorCodeHeader]: error.code }, }) } + +/** + * Builds the response body, honouring {@link ErrorResponseConfig}. + * + * `detailed: false` reduces the body to `code` and `message` — everything + * aimed at whoever is building against the endpoint (`hint`, `docs`, + * `details`) comes off the wire. Provenance survives regardless: `message` + * keeps its `[@supabase/server]` prefix, and the code is still sent as the + * `x-supabase-server-error` header. + * + * The error object itself is untouched, so callers reading it directly still + * get everything. + * + * @internal + */ +export function buildErrorBody( + error: SupabaseServerError, + errors?: ErrorResponseConfig, +): ErrorPayload | MinimalErrorPayload { + const payload = error.toJSON() + if (errors?.detailed !== false) return payload + + const { code, message } = payload + return { code, message } +} diff --git a/src/errors.ts b/src/errors.ts index b2b4a08..635fdd5 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -87,6 +87,17 @@ export interface ErrorPayload { details?: Record } +/** + * Reduced error body produced when `errors: { detailed: false }` is set. + * + * `message` keeps its `[@supabase/server]` prefix, so the error is still + * traceable back here even without the `source` field. + * + * @see {@link index.ErrorResponseConfig} + * @category Errors + */ +export type MinimalErrorPayload = Pick + /** * Base class for every error `@supabase/server` produces. * diff --git a/src/index.ts b/src/index.ts index 8c6ab02..c8070c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -113,6 +113,7 @@ export type { CreateAdminClientOptions, CreateContextClientOptions, Credentials, + ErrorResponseConfig, JWTClaims, SupabaseContext, SupabaseEnv, @@ -152,6 +153,7 @@ export type { ApiKeyFormat, AuthFailureContext, ErrorPayload, + MinimalErrorPayload, ReceivedCredentials, SupabaseServerErrorOptions, } from './errors.js' diff --git a/src/types.ts b/src/types.ts index 8db2313..0499c66 100644 --- a/src/types.ts +++ b/src/types.ts @@ -312,6 +312,56 @@ export interface WithSupabaseConfig { * ``` */ supabaseOptions?: SupabaseClientOptions + + /** + * How much of an error to include in the response body. + * + * @remarks Applies to the responses {@link withSupabase} produces. The error + * object itself is always fully populated, so {@link createSupabaseContext} + * and the framework adapters still see everything. + * + * @see {@link ErrorResponseConfig} + */ + errors?: ErrorResponseConfig +} + +/** + * Controls how much of an error {@link withSupabase} puts in the response body. + * + * @example Trimming the response + * ```ts + * // Full payload: source, code, message, hint, docs, details + * withSupabase({ auth: 'user' }, handler) + * + * // Trimmed: code and message only + * withSupabase({ auth: 'user', errors: { detailed: false } }, handler) + * ``` + * + * @category Types + */ +export interface ErrorResponseConfig { + /** + * Whether to include the diagnostic fields in the response body. + * + * `hint` names the likely misconfiguration, `details` reports the endpoint's + * accepted auth modes, which credential headers arrived, and the *names* of + * configured keys, and `docs` links the relevant reference. All three are + * aimed at whoever is building against the endpoint. + * + * Set to `false` to reduce the body to `code` and `message` alone. The + * `x-supabase-server-error` header and the HTTP status are unaffected, and + * `message` keeps its `[@supabase/server]` prefix — so the error stays + * traceable without the `source` field. + * + * @remarks This is a verbosity control, not a security boundary. `code` and + * `message` still describe the failure specifically (e.g. + * `JWKS_NOT_CONFIGURED`). Neither level ever includes key values or token + * payloads. To disclose nothing, format the response yourself with + * {@link createSupabaseContext}. + * + * @defaultValue `true` + */ + detailed?: boolean } /** diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index 1335342..1f95c3e 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -3,6 +3,7 @@ import { defineMiddleware, getEnv } from '@supabase/middleware' import type { Entry, FetchHandler } from '@supabase/middleware' import { _resetAllowDeprecationWarned } from './core/utils/deprecation.js' +import { createSupabaseContext } from './create-supabase-context.js' import { EnvError, ErrorCodeHeader, @@ -173,6 +174,45 @@ describe('withSupabase', () => { 'MISSING_DEFAULT_PUBLISHABLE_KEY', ) }) + + describe('errors: { detailed: false }', () => { + it('reduces the body to code and message alone', async () => { + const body = await ( + await errorResponse({ errors: { detailed: false } }) + ).json() + + expect(body).toEqual({ + code: MissingCredentialsError, + // Provenance survives in the prefix, without the `source` field. + message: expect.stringContaining('[@supabase/server] '), + }) + }) + + it('keeps the status and the code header', async () => { + const res = await errorResponse({ errors: { detailed: false } }) + expect(res.status).toBe(401) + expect(res.headers.get(ErrorCodeHeader)).toBe(MissingCredentialsError) + }) + + it('is detailed by default and when explicitly enabled', async () => { + for (const config of [{}, { errors: { detailed: true } }]) { + const body = await (await errorResponse(config)).json() + expect(body.hint).toBeDefined() + expect(body.details).toBeDefined() + } + }) + + it('leaves the error object itself fully populated', async () => { + // The trim is response-only — createSupabaseContext callers and the + // adapters read the error directly and must still see everything. + const { error } = await createSupabaseContext( + new Request('http://localhost'), + { auth: 'user', env: baseEnv, errors: { detailed: false } }, + ) + expect(error!.hint).toBeDefined() + expect(error!.details).toBeDefined() + }) + }) }) it('adds CORS headers to success response', async () => { diff --git a/src/with-supabase.ts b/src/with-supabase.ts index 9a0ae42..6255db6 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -212,7 +212,10 @@ export function withSupabase( env: config.env, }) if (error) { - return errorResponse(error, { headers: errorHeaders() }) + return errorResponse(error, { + headers: errorHeaders(), + errors: config.errors, + }) } // Track whether the request has moved past the client entries: only @@ -271,7 +274,10 @@ export function withSupabase( ? e : null if (!mapped) throw e - return errorResponse(mapped, { headers: errorHeaders() }) + return errorResponse(mapped, { + headers: errorHeaders(), + errors: config.errors, + }) } if (!isCorsDisabled(config.cors)) { From 21dc29cc2d5f865ca762eb6df30b8dae26f52e32 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Thu, 27 Aug 2026 13:49:04 -0300 Subject: [PATCH 3/6] feat: distinguish UNUSABLE_CREDENTIAL from MISSING_CREDENTIALS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #130: the top-level code was `MISSING_CREDENTIALS` even when a credential had arrived, just the wrong kind. `received. authorization: 'api-key'` and the hint carried the diagnosis, but `errors: { detailed: false }` strips both — leaving a caller who is demonstrably sending a key staring at a bare `MISSING_CREDENTIALS`. That mode makes the code the only thing a caller can rely on, so it has to be true standing alone. `UNUSABLE_CREDENTIAL` (401) now covers "a credential arrived that no accepted mode can use", partitioning the space exactly against `MISSING_CREDENTIALS` ("nothing arrived"). It has two shapes, named in the `message` so the diagnosis survives the trim: - wrong kind: an `sb_*` API key in the Authorization header - unreadable: wrong scheme, wrong casing, bare value, empty token The unreadable shapes had the same defect and are fixed with it — a `Basic` or lowercase-`bearer` header is not a missing credential either. Classification moves into one shared `diagnoseAuthorizationHeader`, since only the raw header separates "sent nothing" from "sent something unreadable" and both `verifyAuth` and the `withRequiredClaims` gate need that distinction. Previously the gate could not make it at all, so the two disagreed on every scheme case. A parity matrix over all six header shapes now pins gate and `withSupabase({ auth: 'user' })` to the same status and code. --- docs/api-reference.md | 11 ++- docs/error-handling.md | 16 +++- src/core/utils/authorization-header.ts | 89 ++++++++++++++++++++ src/core/verify-auth.test.ts | 21 +++-- src/core/verify-auth.ts | 79 ++++++----------- src/core/verify-credentials.test.ts | 8 +- src/core/verify-credentials.ts | 18 +++- src/errors.ts | 63 +++++++++++--- src/index.ts | 1 + src/middleware/required-claims/index.test.ts | 41 ++++++++- src/middleware/required-claims/index.ts | 62 +++++++++----- 11 files changed, 296 insertions(+), 113 deletions(-) create mode 100644 src/core/utils/authorization-header.ts diff --git a/docs/api-reference.md b/docs/api-reference.md index b5db2da..27169e5 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -221,7 +221,8 @@ The user-mode auth gate. Verifies the caller's Bearer token against the project Behavior: -- No `Authorization: Bearer` token, or an `sb_*` API key in that position: short-circuits with a 401 and code `MISSING_CREDENTIALS`. The handler never runs. +- No `Authorization` header: short-circuits with a 401 and code `MISSING_CREDENTIALS`. The handler never runs. +- An `sb_*` API key in the `Authorization` header: a 401 with code `UNUSABLE_CREDENTIAL` — a credential arrived, just not a user JWT. - Token present but invalid: a 401 with code `INVALID_JWT`, naming the specific reason. - Token present but no JWKS configured: short-circuits with a 500 and code `JWKS_NOT_CONFIGURED` — the same code `withSupabase`'s `user` mode reports, with a `hint` naming this middleware's `jwks` option. Verification is required; the middleware has no decode-only mode. - Remote JWKS unreachable: short-circuits with a 500 and code `JWKS_FETCH_FAILED`. @@ -653,7 +654,8 @@ interface SupabaseServerErrorOptions { | `MissingResourceServerError` | `'MISSING_RESOURCE_SERVER'` | `EnvError` | `withOAuthProtectedResource` cannot derive a `resourceServer` | | `MissingAuthorizationServerError` | `'MISSING_AUTHORIZATION_SERVER'` | `EnvError` | `withOAuthProtectedResource` cannot derive an authorization server | | `AuthGenericError` | `'AUTH_ERROR'` | `AuthError` | Generic auth error (401) | -| `MissingCredentialsError` | `'MISSING_CREDENTIALS'` | `AuthError` | Request carried no usable credentials (401) | +| `MissingCredentialsError` | `'MISSING_CREDENTIALS'` | `AuthError` | Request carried no credentials at all (401) | +| `UnusableCredentialError` | `'UNUSABLE_CREDENTIAL'` | `AuthError` | A credential arrived but cannot be used (401) | | `InvalidApiKeyError` | `'INVALID_API_KEY'` | `AuthError` | `apikey` matched no configured key (401) | | `InvalidJwtError` | `'INVALID_JWT'` | `AuthError` | JWT failed verification (401) | | `InvalidCredentialsError` | `'INVALID_CREDENTIALS'` | `AuthError` | Fallback credential failure (401) | @@ -681,6 +683,9 @@ const Errors: { [MissingResourceServerError]: () => EnvError [MissingAuthorizationServerError]: () => EnvError [MissingCredentialsError]: (context: AuthFailureContext) => AuthError + [UnusableCredentialError]: ( + context: PartialContext & { reason; hint }, + ) => AuthError [InvalidApiKeyError]: (context: AuthFailureContext) => AuthError [InvalidJwtError]: (context: PartialContext & JwtFailure) => AuthError [InvalidCredentialsError]: (context?: AuthFailureContext) => AuthError @@ -709,7 +714,7 @@ Non-sensitive diagnostics the auth pipeline passes to the factories. interface AuthFailureContext { authModes: readonly string[] received: { - authorization: 'bearer' | 'non-bearer-scheme' | 'absent' + authorization: 'bearer' | 'api-key' | 'non-bearer-scheme' | 'absent' apikey: 'absent' | 'publishable' | 'secret' | 'legacy-jwt' | 'unrecognized' } configuredKeyNames?: Record diff --git a/docs/error-handling.md b/docs/error-handling.md index f643bdf..5d56794 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -95,7 +95,8 @@ Thrown when authentication fails. `401` means the request's credentials are at f | Code | Status | Meaning | | --------------------------------------------------------------- | ------ | ------------------------------------------------------------------- | -| [`MISSING_CREDENTIALS`](#missing_credentials) | 401 | The request carried no usable credentials | +| [`MISSING_CREDENTIALS`](#missing_credentials) | 401 | The request carried no credentials at all | +| [`UNUSABLE_CREDENTIAL`](#unusable_credential) | 401 | A credential arrived, but not one any accepted mode can use | | [`INVALID_API_KEY`](#invalid_api_key) | 401 | An `apikey` was sent but matched no configured key | | [`INVALID_JWT`](#invalid_jwt) | 401 | A JWT was sent but failed verification | | [`INVALID_CREDENTIALS`](#invalid_credentials) | 401 | Fallback when nothing more specific applies | @@ -108,11 +109,20 @@ Thrown when authentication fails. `401` means the request's credentials are at f ### `MISSING_CREDENTIALS` -Neither an `apikey` header nor a usable `Authorization: Bearer` token was present, and no accepted auth mode allows that. +The request carried nothing: no `apikey` header, and no `Authorization` header at all. `details.acceptedAuthModes` lists what the endpoint accepts; `hint` tells you exactly which header to send for each. -Watch for `details.received.authorization` being `"non-bearer-scheme"`. Credentials are only read from `Authorization: Bearer ` — a wrong scheme, wrong casing (`bearer`), a bare token, or an `sb_*` API key in that header produces no user credential at all, and the `hint` will say which of those happened. +If something _did_ arrive but couldn't be used, the code is [`UNUSABLE_CREDENTIAL`](#unusable_credential) instead. The two partition the space exactly, so the code alone tells you which situation you're in — which matters when [`errors: { detailed: false }`](#trimming-the-response-body) strips `hint` and `details`. + +### `UNUSABLE_CREDENTIAL` + +A credential arrived, but not one any accepted auth mode can use. Two shapes: + +- **Wrong kind.** An `sb_*` API key in the `Authorization` header where a user JWT is required. The Supabase SDK sends the key in both the `apikey` and `Authorization` headers, so this is easy to hit by accident. `details.received.authorization` is `"api-key"`. +- **Unreadable.** A header this library can't read a bearer token out of — wrong scheme (`Basic …`), wrong casing (`bearer` — the scheme is case-sensitive), a bare value with no scheme, or `Bearer` with an empty token. `details.received.authorization` is `"non-bearer-scheme"`. + +The `message` names which one happened, so the diagnosis survives even with `hint` and `details` stripped. ### `INVALID_API_KEY` diff --git a/src/core/utils/authorization-header.ts b/src/core/utils/authorization-header.ts new file mode 100644 index 0000000..886df29 --- /dev/null +++ b/src/core/utils/authorization-header.ts @@ -0,0 +1,89 @@ +/** + * What the `Authorization` header carried, from the perspective of a layer that + * needs a user JWT out of it. + * + * @internal + */ +export type AuthorizationDiagnosis = + /** No `Authorization` header at all. */ + | { kind: 'absent' } + /** A bearer token that is not an `sb_*` API key — a JWT candidate. */ + | { kind: 'bearer' } + /** An `sb_*` API key, which the Supabase SDK forwards here too. */ + | { kind: 'api-key' } + /** Present, but no bearer token could be read out of it. */ + | { kind: 'unreadable'; reason: string; hint: string } + +/** + * Classifies the raw `Authorization` header. + * + * {@link extractCredentials} only reads `Authorization: Bearer `, so a + * wrong scheme, wrong casing, or a bare token silently produces no credential — + * which reads to the caller as "you sent nothing", the single most confusing way + * for auth to fail. This is the one place that distinction is worked out, so + * `verifyAuth` and the `withRequiredClaims` gate report an identical request + * identically. + * + * @param raw - The header value, or `null` when absent. + * + * @internal + */ +export function diagnoseAuthorizationHeader( + raw: string | null, +): AuthorizationDiagnosis { + if (!raw) return { kind: 'absent' } + + const [scheme = '', ...rest] = raw.split(' ') + + if (scheme === 'Bearer') { + const token = rest.join(' ').trim() + // Header values are trimmed in transit, so a trailing-space-only value + // arrives here as a bare "Bearer". + if (!token) { + return { + kind: 'unreadable', + reason: + 'the Authorization header used the `Bearer` scheme but carried an empty token', + hint: 'Put the JWT after `Bearer `, separated by a single space.', + } + } + // `sb_*` secrets ride this header alongside the apikey header; they are API + // keys, not user JWTs. + return token.startsWith('sb_') ? { kind: 'api-key' } : { kind: 'bearer' } + } + + if (scheme.toLowerCase() === 'bearer') { + return { + kind: 'unreadable', + reason: `the Authorization header used the scheme "${scheme}" rather than \`Bearer\``, + hint: 'The scheme is case-sensitive: it must be exactly `Bearer`, capitalised, followed by a single space and the JWT.', + } + } + + if (rest.length > 0) { + return { + kind: 'unreadable', + reason: `the Authorization header used the "${scheme}" scheme, not \`Bearer\``, + hint: 'Only `Authorization: Bearer ` is read as a user credential.', + } + } + + return { + kind: 'unreadable', + reason: 'the Authorization header carried a bare value with no scheme', + hint: 'It must be `Authorization: Bearer ` — the scheme is not optional.', + } +} + +/** + * Diagnosis for an `sb_*` API key found in the `Authorization` header. Shared so + * `verifyCredentials` and the claims gate word it the same way. + * + * @internal + */ +export const ApiKeyInAuthorizationHeader = { + reason: 'the Authorization header carried an sb_* API key, not a user JWT', + hint: + 'API keys belong in the `apikey` header. The Supabase SDK sends the key in both the `apikey` ' + + 'and `Authorization` headers, which is why this is easy to miss.', +} as const diff --git a/src/core/verify-auth.test.ts b/src/core/verify-auth.test.ts index dea0af6..009ad06 100644 --- a/src/core/verify-auth.test.ts +++ b/src/core/verify-auth.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { MissingCredentialsError } from '../errors.js' +import { MissingCredentialsError, UnusableCredentialError } from '../errors.js' import { verifyAuth } from './verify-auth.js' describe('verifyAuth', () => { @@ -45,9 +45,11 @@ describe('verifyAuth', () => { it('explains a non-Bearer scheme', async () => { const error = await failFor('Basic dXNlcjpwYXNz') - expect(error.code).toBe(MissingCredentialsError) - expect(error.hint).toContain('"Basic"') - expect(error.hint).toContain('not `Bearer`') + // A credential arrived, so this is not MISSING_CREDENTIALS. + expect(error.code).toBe(UnusableCredentialError) + expect(error.message).toContain('"Basic"') + expect(error.message).toContain('not `Bearer`') + expect(error.hint).toContain('Authorization: Bearer ') expect(error.details!.received).toMatchObject({ authorization: 'non-bearer-scheme', }) @@ -55,18 +57,21 @@ describe('verifyAuth', () => { it('explains a lowercased bearer scheme', async () => { const error = await failFor('bearer some.jwt.value') - expect(error.hint).toContain('"bearer"') - expect(error.hint).toContain('must be exactly `Bearer`') + expect(error.code).toBe(UnusableCredentialError) + expect(error.message).toContain('"bearer"') + expect(error.hint).toContain('case-sensitive') }) it('explains a bare token with no scheme', async () => { const error = await failFor('some.jwt.value') - expect(error.hint).toContain('no scheme') + expect(error.code).toBe(UnusableCredentialError) + expect(error.message).toContain('no scheme') }) it('explains an empty Bearer token', async () => { const error = await failFor('Bearer ') - expect(error.hint).toContain('empty token') + expect(error.code).toBe(UnusableCredentialError) + expect(error.message).toContain('empty token') }) it('leaves the error untouched when no Authorization header is sent', async () => { diff --git a/src/core/verify-auth.ts b/src/core/verify-auth.ts index 596e71d..5599c12 100644 --- a/src/core/verify-auth.ts +++ b/src/core/verify-auth.ts @@ -1,6 +1,12 @@ -import { withExtraDiagnostics, type AuthError } from '../errors.js' +import { + Errors, + MissingCredentialsError, + UnusableCredentialError, + type AuthError, +} from '../errors.js' import type { AuthModeWithKey, AuthResult, SupabaseEnv } from '../types.js' import { extractCredentials } from './extract-credentials.js' +import { diagnoseAuthorizationHeader } from './utils/authorization-header.js' import { verifyCredentials } from './verify-credentials.js' /** @@ -28,43 +34,6 @@ export interface VerifyAuthOptions { env?: Partial } -/** - * Explains an `Authorization` header that was present but yielded no token. - * - * {@link extractCredentials} only reads `Authorization: Bearer `, so a - * wrong scheme, wrong casing, or a bare token silently produces no credential - * at all. That reads as "you sent nothing", which is the single most confusing - * way for auth to fail — name it explicitly instead. - * - * @returns A hint sentence, or `null` when the header was genuinely absent or - * did carry a token. - * - * @internal - */ -function explainUnusableAuthorizationHeader(raw: string): string | null { - const [scheme = '', ...rest] = raw.split(' ') - - // Correct scheme, so the only way `extractCredentials` yielded nothing is an - // empty token. (Header values are trimmed in transit, so a trailing-space-only - // value arrives here as a bare "Bearer".) - if (scheme === 'Bearer') { - return 'The Authorization header used the `Bearer` scheme but carried an empty token.' - } - if (scheme.toLowerCase() === 'bearer') { - return ( - `The Authorization header used the scheme "${scheme}" — it must be exactly \`Bearer\`, ` + - 'capitalised, followed by a single space and the JWT.' - ) - } - if (rest.length > 0) { - return `The Authorization header used the "${scheme}" scheme, not \`Bearer\`, so no token was read.` - } - return ( - 'The Authorization header carried a bare value with no scheme. It must be ' + - '`Authorization: Bearer `.' - ) -} - /** * Extracts credentials from a request and verifies them in a single step. * @@ -106,24 +75,30 @@ export async function verifyAuth( const result = await verifyCredentials(credentials, options) if (result.error === null || credentials.token) return result - // Only reachable with the raw request in hand, so `verifyCredentials` can't - // report it — layer it on here. - const rawAuthorization = request.headers.get('authorization') - if (!rawAuthorization) return result + // Only reachable with the raw request in hand: `verifyCredentials` sees a + // null token and cannot tell "no header" from "header we couldn't read". + const diagnosis = diagnoseAuthorizationHeader( + request.headers.get('authorization'), + ) + if (diagnosis.kind !== 'unreadable') return result - const hint = explainUnusableAuthorizationHeader(rawAuthorization) - if (!hint) return result + // Restricted to MISSING_CREDENTIALS: any other code (a bad apikey, a + // misconfiguration) describes the failure better than the Authorization + // header being malformed. + if (result.error.code !== MissingCredentialsError) return result return { data: null, - error: withExtraDiagnostics(result.error, { - hint, - details: { - received: { - ...(result.error.details?.received as Record), - authorization: 'non-bearer-scheme', - }, - }, + error: Errors[UnusableCredentialError]({ + authModes: result.error.details?.acceptedAuthModes as + | readonly string[] + | undefined, + received: { + ...(result.error.details?.received as object), + authorization: 'non-bearer-scheme', + } as never, + reason: diagnosis.reason, + hint: diagnosis.hint, }), } } diff --git a/src/core/verify-credentials.test.ts b/src/core/verify-credentials.test.ts index 4cda2ef..36aac6c 100644 --- a/src/core/verify-credentials.test.ts +++ b/src/core/verify-credentials.test.ts @@ -22,6 +22,7 @@ import { JwksNotConfiguredError, MissingCredentialsError, NoKeysConfiguredError, + UnusableCredentialError, } from '../errors.js' function makeEnv(overrides?: Partial): Partial { @@ -427,7 +428,7 @@ describe('verifyCredentials', () => { expect(result.error!.status).toBe(401) }) - it('fails 401 for an sb_* value in the Authorization slot', async () => { + it('fails 401 UNUSABLE_CREDENTIAL for an sb_* value in the Authorization slot', async () => { // An API key can never pass user mode, JWKS or not. The header arrived // but carried no user credential, so this is the same class of failure // as sending nothing — and the hint says which mistake was made. @@ -437,9 +438,10 @@ describe('verifyCredentials', () => { env: makeEnv(), }) expect(result.error).not.toBeNull() - expect(result.error!.code).toBe(MissingCredentialsError) + expect(result.error!.code).toBe(UnusableCredentialError) expect(result.error!.status).toBe(401) - expect(result.error!.hint).toContain('sb_* API key') + expect(result.error!.message).toContain('sb_* API key') + expect(result.error!.hint).toContain('`apikey` header') expect(result.error!.details!.received).toMatchObject({ authorization: 'api-key', }) diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index e320e59..50b4115 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -8,6 +8,7 @@ import { JwksNotConfiguredError, MissingCredentialsError, NoKeysConfiguredError, + UnusableCredentialError, type AuthFailureContext, } from '../errors.js' import type { @@ -18,6 +19,7 @@ import type { SupabaseEnv, } from '../types.js' import { resolveEnv } from './resolve-env.js' +import { ApiKeyInAuthorizationHeader } from './utils/authorization-header.js' import { classifyApiKey } from './utils/classify-credentials.js' import { resolveAuthOption } from './utils/deprecation.js' import { timingSafeEqual } from './utils/timing-safe-equal.js' @@ -339,11 +341,19 @@ function explainFallthrough( }) } - // `api-key` and `non-bearer-scheme` mean the header arrived but carried - // nothing usable — the same situation as absent, and reported the same way so - // this matches what `withRequiredClaims` says for an identical request. const { authorization, apikey } = context.received - if (authorization !== 'bearer' && apikey === 'absent') { + + // An `sb_*` value in the Authorization slot is a credential that *did* + // arrive, so it is not "missing" — the distinction has to live in the code + // itself, since `errors: { detailed: false }` strips the hint that would + // otherwise explain it. + if (authorization === 'api-key' && apikey === 'absent') { + return Errors[UnusableCredentialError]({ + ...context, + ...ApiKeyInAuthorizationHeader, + }) + } + if (authorization === 'absent' && apikey === 'absent') { return Errors[MissingCredentialsError](context) } if (apikey !== 'absent') { diff --git a/src/errors.ts b/src/errors.ts index 635fdd5..ef0d086 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -471,6 +471,21 @@ export const AuthGenericError = 'AUTH_ERROR' */ export const MissingCredentialsError = 'MISSING_CREDENTIALS' +/** + * A credential *did* arrive, but not one any accepted auth mode can use — an + * `sb_*` API key in the `Authorization` header, or a header this library cannot + * read a bearer token out of (wrong scheme, wrong casing, bare value, empty + * token). + * + * @remarks Distinct from {@link MissingCredentialsError} on purpose. The two + * partition the "nothing authenticated" space exactly — nothing arrived vs. + * something arrived that could not be used — so the code stays true on its own + * when `errors: { detailed: false }` strips `hint` and `details`. + * + * @category Errors + */ +export const UnusableCredentialError = 'UNUSABLE_CREDENTIAL' + /** * An `apikey` header was present but matched none of the keys configured for * the accepted auth modes. @@ -706,30 +721,50 @@ function apiKeyHint(context: AuthFailureContext): string { } const AuthErrorMap = { - [MissingCredentialsError]: (context: AuthFailureContext): AuthError => { - const { authorization } = context.received - return new AuthError( - `No usable credentials found on the request. This endpoint accepts auth mode(s): ${quoteList(context.authModes)}.`, + [MissingCredentialsError]: (context: AuthFailureContext): AuthError => + new AuthError( + `No credentials found on the request. This endpoint accepts auth mode(s): ${quoteList(context.authModes)}.`, MissingCredentialsError, 401, + { + hint: sendOneOfHint(context.authModes), + details: { + acceptedAuthModes: context.authModes, + received: context.received, + }, + }, + ), + + [UnusableCredentialError]: ( + context: Partial & { + /** What arrived and why it can't be used. Follows "cannot use: ". */ + reason: string + /** How to send it correctly. */ + hint: string + }, + ): AuthError => + new AuthError( + `The request carried a credential this endpoint cannot use: ${context.reason}.` + + (context.authModes + ? ` Accepted auth mode(s): ${quoteList(context.authModes)}.` + : ''), + UnusableCredentialError, + 401, { hint: [ - authorization === 'non-bearer-scheme' && - 'The Authorization header was present but did not use the `Bearer` scheme, so no token was read.', - authorization === 'api-key' && - 'The Authorization header carried an sb_* API key, not a user JWT. API keys belong in the `apikey` header; ' + - 'the Supabase SDK sends them in both, which is why this is easy to miss.', - sendOneOfHint(context.authModes), + context.hint, + context.authModes && sendOneOfHint(context.authModes), ] .filter(Boolean) .join(' '), details: { - acceptedAuthModes: context.authModes, - received: context.received, + ...(context.authModes + ? { acceptedAuthModes: context.authModes } + : {}), + ...(context.received ? { received: context.received } : {}), }, }, - ) - }, + ), [InvalidApiKeyError]: (context: AuthFailureContext): AuthError => new AuthError( diff --git a/src/index.ts b/src/index.ts index c8070c7..62d5fc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -147,6 +147,7 @@ export { NoKeysConfiguredError, SupabaseServerError, UnsupportedRoleError, + UnusableCredentialError, } from './errors.js' export type { diff --git a/src/middleware/required-claims/index.test.ts b/src/middleware/required-claims/index.test.ts index a3d8369..492c747 100644 --- a/src/middleware/required-claims/index.test.ts +++ b/src/middleware/required-claims/index.test.ts @@ -16,6 +16,7 @@ import { InvalidJwtError, JwksNotConfiguredError, MissingCredentialsError, + UnusableCredentialError, } from '../../errors.js' import { withSupabase } from '../../with-supabase.js' import { withClaims } from '../claims/index.js' @@ -103,7 +104,7 @@ describe('withRequiredClaims', () => { expect(ran).toBe(false) }) - it('short-circuits 401 for an sb_* apikey in the Authorization header', async () => { + it('short-circuits 401 UNUSABLE_CREDENTIAL for an sb_* apikey in the Authorization header', async () => { let ran = false const handler = withRequiredClaims({ jwks }, async () => { ran = true @@ -121,7 +122,7 @@ describe('withRequiredClaims', () => { const res = await handler(requestWithToken(apikey)) expect(res.status).toBe(401) const body = await res.json() - expect(body.code).toBe(MissingCredentialsError) + expect(body.code).toBe(UnusableCredentialError) expect(ran).toBe(false) } }) @@ -218,11 +219,11 @@ describe('withRequiredClaims', () => { } }) - it('sb_* key in the Authorization slot: both 401 MISSING_CREDENTIALS', async () => { + it('sb_* key in the Authorization slot: both 401 UNUSABLE_CREDENTIAL', async () => { const { gate, supabase } = await both('sb_secret_other', jwks) for (const res of [gate, supabase]) { expect(res.status).toBe(401) - expect((await res.json()).code).toBe(MissingCredentialsError) + expect((await res.json()).code).toBe(UnusableCredentialError) } }) @@ -234,6 +235,38 @@ describe('withRequiredClaims', () => { } }) + // Every shape the Authorization header can arrive in, since only the raw + // header distinguishes "sent nothing" from "sent something unreadable" — + // and the two entry points read it through the same classifier. + it.each([ + ['no header', undefined, 'MISSING_CREDENTIALS'], + ['sb_* API key', 'Bearer sb_secret_other', 'UNUSABLE_CREDENTIAL'], + ['Basic scheme', 'Basic dXNlcjpwYXNz', 'UNUSABLE_CREDENTIAL'], + ['lowercased bearer', 'bearer a.b.c', 'UNUSABLE_CREDENTIAL'], + ['bare value, no scheme', 'a.b.c', 'UNUSABLE_CREDENTIAL'], + ['Bearer with empty token', 'Bearer', 'UNUSABLE_CREDENTIAL'], + ])( + 'Authorization %s: both 401 %s', + async (_label, authorization, expectedCode) => { + const req = () => + new Request('http://localhost', { + headers: authorization ? { Authorization: authorization } : {}, + }) + const gated = withRequiredClaims({ jwks }, async () => + Response.json({ ok: true }), + ) + const wrapped = withSupabase( + { auth: 'user', cors: 'disabled', env: supabaseEnv(jwks) }, + async () => Response.json({ ok: true }), + ) + + for (const res of [await gated(req()), await wrapped(req())]) { + expect(res.status).toBe(401) + expect((await res.json()).code).toBe(expectedCode) + } + }, + ) + it('token present but no JWKS configured: both 500 JWKS_NOT_CONFIGURED', async () => { vi.stubEnv('SUPABASE_JWKS', '') vi.stubEnv('SUPABASE_JWKS_URL', '') diff --git a/src/middleware/required-claims/index.ts b/src/middleware/required-claims/index.ts index 8ff4fb0..7360888 100644 --- a/src/middleware/required-claims/index.ts +++ b/src/middleware/required-claims/index.ts @@ -4,6 +4,10 @@ import type { JSONWebKeySet } from 'jose' import { extractCredentials } from '../../core/extract-credentials.js' import { resolveJwks } from '../../core/resolve-env.js' +import { + ApiKeyInAuthorizationHeader, + diagnoseAuthorizationHeader, +} from '../../core/utils/authorization-header.js' import { classifyApiKey } from '../../core/utils/classify-credentials.js' import { verifyUserJwt } from '../../core/verify-user-jwt.js' import { errorResponse } from '../../error-response.js' @@ -13,6 +17,7 @@ import { JwksFetchFailedError, JwksNotConfiguredError, MissingCredentialsError, + UnusableCredentialError, } from '../../errors.js' import type { JWTClaims } from '../../types.js' @@ -41,13 +46,14 @@ export interface WithRequiredClaimsConfig { * if present"; composing both is a compile-time conflict on the `jwtClaims` * key. * - * Behavior: - * - No `Authorization: Bearer` token (or an `sb_*` API key in that position, - * which is an API key rather than a user JWT) → short-circuits with a - * 401 JSON response (`{ message, code: 'INVALID_CREDENTIALS' }`, matching - * `withSupabase`'s error shape). The handler never runs. - * - Token present but invalid → the same 401. - * - Token present but no JWKS configured → short-circuits with a 500; + * Behavior — every short-circuit uses the standard error payload, with the same + * code `withSupabase({ auth: 'user' })` returns for an identical request: + * - No `Authorization: Bearer` token → 401 `MISSING_CREDENTIALS`. The handler + * never runs. + * - An `sb_*` API key in that position → 401 `UNUSABLE_CREDENTIAL`: a + * credential arrived, just not a user JWT. + * - Token present but invalid → 401 `INVALID_JWT`, naming the specific reason. + * - Token present but no JWKS configured → 500 `JWKS_NOT_CONFIGURED`; * verification is not optional and there is no decode-only mode. * * Because the contribution is non-null, gated handlers read `ctx.jwtClaims` @@ -95,22 +101,34 @@ export const withRequiredClaims: Middleware< >({ key: 'jwtClaims', run: (config) => async (req) => { - const { token } = extractCredentials(req) - // `sb_*` secrets ride the Authorization header alongside the apikey - // header — they are API keys, not user JWTs, so they cannot pass a gate - // that requires verified user claims. - if (!token || token.startsWith('sb_')) { - const { apikey } = extractCredentials(req) + const { token, apikey } = extractCredentials(req) + // Classified through the shared helper so an identical request gets an + // identical code here and from `withSupabase({ auth: 'user' })`. A + // credential that arrived but can't be used is not "missing" — and with + // `errors: { detailed: false }` the code is all the caller gets. + const diagnosis = diagnoseAuthorizationHeader( + req.headers.get('authorization'), + ) + if (diagnosis.kind !== 'bearer' || !token) { + const received = { + authorization: + diagnosis.kind === 'unreadable' + ? ('non-bearer-scheme' as const) + : diagnosis.kind === 'api-key' + ? ('api-key' as const) + : ('absent' as const), + apikey: classifyApiKey(apikey), + } return errorResponse( - Errors[MissingCredentialsError]({ - authModes: ['user'], - received: { - // An `sb_*` value in Authorization is an API key, not a JWT — the - // header arrived, but carried nothing this gate can verify. - authorization: token ? 'api-key' : 'absent', - apikey: classifyApiKey(apikey), - }, - }), + diagnosis.kind === 'absent' + ? Errors[MissingCredentialsError]({ authModes: ['user'], received }) + : Errors[UnusableCredentialError]({ + authModes: ['user'], + received, + ...(diagnosis.kind === 'unreadable' + ? { reason: diagnosis.reason, hint: diagnosis.hint } + : ApiKeyInAuthorizationHeader), + }), ) } From 3724c1cc0c4f79b884cfa7900aca07b7479e4ad1 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Fri, 28 Aug 2026 22:49:50 -0300 Subject: [PATCH 4/6] fix: preserve error cause when client creation fails --- src/middleware/admin-client/index.test.ts | 19 +++++++++++++++++++ src/middleware/admin-client/index.ts | 4 +++- src/middleware/client/index.test.ts | 16 ++++++++++++++++ src/middleware/client/index.ts | 4 +++- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/middleware/admin-client/index.test.ts b/src/middleware/admin-client/index.test.ts index a7a83fc..069f5b9 100644 --- a/src/middleware/admin-client/index.test.ts +++ b/src/middleware/admin-client/index.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { SupabaseClient } from '@supabase/supabase-js' import { + CreateSupabaseClientError, EnvError, MissingDefaultSecretKeyError, MissingSecretKeyError, @@ -119,4 +120,22 @@ describe('withSupabaseAdminClient', () => { expect(body.isClient).toBe(true) expect(body.hasSelect).toBe(true) }) + it('attaches the underlying failure as `cause` when client creation fails', async () => { + // A malformed URL passes env resolution but throws inside createClient — + // the hint on this error tells the reader to log `cause`, so it must be set. + const handler = pipeline( + [withSupabaseAdminClient({ env: { ...baseEnv, url: 'not-a-url' } })], + async (_req, ctx) => { + // The client is lazy — touching it is what triggers construction. + return Response.json({ ok: ctx.supabaseAdmin.auth !== undefined }) + }, + ) + + await expect( + handler(new Request('http://localhost')), + ).rejects.toMatchObject({ + code: CreateSupabaseClientError, + cause: expect.any(Error), + }) + }) }) diff --git a/src/middleware/admin-client/index.ts b/src/middleware/admin-client/index.ts index 0dc834b..f45c2bb 100644 --- a/src/middleware/admin-client/index.ts +++ b/src/middleware/admin-client/index.ts @@ -45,7 +45,9 @@ const base = defineMiddleware< supabaseOptions: config?.supabaseOptions, }) } catch (e) { - throw e instanceof EnvError ? e : Errors[CreateSupabaseClientError]() + throw e instanceof EnvError + ? e + : Errors[CreateSupabaseClientError]({ cause: e }) } }) return { supabaseAdmin } diff --git a/src/middleware/client/index.test.ts b/src/middleware/client/index.test.ts index 22beaf5..e65d085 100644 --- a/src/middleware/client/index.test.ts +++ b/src/middleware/client/index.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import type { SupabaseClient } from '@supabase/supabase-js' import { + CreateSupabaseClientError, EnvError, MissingPublishableKeyError, MissingSupabaseURLError, @@ -73,4 +74,19 @@ describe('withSupabaseClient', () => { handler(new Request('http://localhost')), ).rejects.toBeInstanceOf(EnvError) }) + it('attaches the underlying failure as `cause` when client creation fails', async () => { + // A malformed URL passes env resolution but throws inside createClient — + // the hint on this error tells the reader to log `cause`, so it must be set. + const handler = pipeline( + [withSupabaseClient({ env: { ...baseEnv, url: 'not-a-url' } })], + async () => Response.json({ ok: true }), + ) + + await expect( + handler(new Request('http://localhost')), + ).rejects.toMatchObject({ + code: CreateSupabaseClientError, + cause: expect.any(Error), + }) + }) }) diff --git a/src/middleware/client/index.ts b/src/middleware/client/index.ts index 79cbe72..180ccb0 100644 --- a/src/middleware/client/index.ts +++ b/src/middleware/client/index.ts @@ -49,7 +49,9 @@ const base = defineMiddleware< supabaseOptions: config?.supabaseOptions, }) } catch (e) { - throw e instanceof EnvError ? e : Errors[CreateSupabaseClientError]() + throw e instanceof EnvError + ? e + : Errors[CreateSupabaseClientError]({ cause: e }) } return { supabase } }, From 7d01b474c1ffcee378ec11b26966bd055f4c25e4 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Fri, 28 Aug 2026 22:59:24 -0300 Subject: [PATCH 5/6] fix: report API keys as UNUSABLE_CREDENTIAL on user-only endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #130: `supabase-js` sends the publishable key in both the `apikey` and `Authorization` headers, so an unauthenticated browser call to an `auth: 'user'` endpoint arrives with a key in each slot. The `apikey !== 'absent'` branch in `explainFallthrough` was read first, so the caller got `INVALID_API_KEY` — "check you are pointing at the right Supabase project" — for a key that was never going to be looked up. With `errors: { detailed: false }` the code is all they get, and it sent them hunting for a key mismatch that does not exist. `INVALID_API_KEY` means "matched none of the configured keys", which only says something when a mode was doing that lookup. It is now gated on an attempted `publishable` / `secret` mode; where no mode reads keys, a key in either header is `UNUSABLE_CREDENTIAL` — not wrong, just the wrong kind of credential. The apikey-header-only case had the same defect and is fixed with it: "matched no key configured for auth mode(s): "user"" described a lookup that never happened. The new diagnosis is shared as `apiKeyOnUserOnlyEndpoint`, so the `withRequiredClaims` gate stops answering with "API keys belong in the `apikey` header" for callers who already sent it there — that gate only ever accepts a user JWT, so moving the key would not help. It keeps the parity the gate is built for: an identical request, worded identically from both paths. `ApiKeyInAuthorizationHeader` still covers the case where the advice is right — a mixed `['user', 'publishable']` endpoint with a key in `Authorization` alone. --- src/core/utils/authorization-header.ts | 31 ++++++++++++++ src/core/verify-credentials.test.ts | 54 ++++++++++++++++++++++++- src/core/verify-credentials.ts | 44 +++++++++++++++++--- src/middleware/required-claims/index.ts | 10 ++++- 4 files changed, 130 insertions(+), 9 deletions(-) diff --git a/src/core/utils/authorization-header.ts b/src/core/utils/authorization-header.ts index 886df29..a308f3b 100644 --- a/src/core/utils/authorization-header.ts +++ b/src/core/utils/authorization-header.ts @@ -87,3 +87,34 @@ export const ApiKeyInAuthorizationHeader = { 'API keys belong in the `apikey` header. The Supabase SDK sends the key in both the `apikey` ' + 'and `Authorization` headers, which is why this is easy to miss.', } as const + +/** + * Diagnosis for Supabase API keys arriving at an endpoint that reads none — a + * `user`-only gate. supabase-js sends the publishable key in both the `apikey` + * and `Authorization` headers, so an unauthenticated client lands here with a + * key in each slot; reporting "your key matched nothing" would send the caller + * hunting for a key mismatch that does not exist. Shared so `verifyCredentials` + * and the claims gate word an identical request identically. + * + * @internal + */ +export function apiKeyOnUserOnlyEndpoint(slots: { + /** An `sb_*` value rode the `Authorization` header. */ + inAuthorization: boolean + /** An `apikey` header was present, whatever its format. */ + inApiKeyHeader: boolean +}): { reason: string; hint: string } { + return { + reason: slots.inApiKeyHeader + ? 'an apikey header, which no accepted auth mode reads' + : 'an sb_* API key in the Authorization header, which no accepted auth mode reads', + hint: + 'This endpoint authenticates a user, not a project — an API key can never satisfy it, ' + + 'whichever header it arrives in.' + + (slots.inAuthorization + ? ' supabase-js sends the publishable key in both the `apikey` and `Authorization` ' + + 'headers, so an unauthenticated client lands here even though a bearer token appears ' + + "to have been sent; a signed-in session's access token replaces it." + : ''), + } +} diff --git a/src/core/verify-credentials.test.ts b/src/core/verify-credentials.test.ts index 36aac6c..1c9f8dc 100644 --- a/src/core/verify-credentials.test.ts +++ b/src/core/verify-credentials.test.ts @@ -441,12 +441,64 @@ describe('verifyCredentials', () => { expect(result.error!.code).toBe(UnusableCredentialError) expect(result.error!.status).toBe(401) expect(result.error!.message).toContain('sb_* API key') - expect(result.error!.hint).toContain('`apikey` header') + expect(result.error!.hint).toContain('an API key can never satisfy it') expect(result.error!.details!.received).toMatchObject({ authorization: 'api-key', }) }) + it('fails 401 UNUSABLE_CREDENTIAL when supabase-js sends the key in both headers', async () => { + // supabase-js puts the publishable key in `apikey` *and* `Authorization`, + // so an unauthenticated browser call arrives with a key in each slot. + // A user-only endpoint reads neither: reporting INVALID_API_KEY would + // send the caller checking their project's keys for a mismatch that + // isn't there. The code has to carry that on its own, because + // `errors: { detailed: false }` strips the hint. + const creds: Credentials = { + token: 'sb_publishable_xyz', + apikey: 'sb_publishable_xyz', + } + const result = await verifyCredentials(creds, { + auth: 'user', + env: makeEnv(), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(UnusableCredentialError) + expect(result.error!.status).toBe(401) + expect(result.error!.hint).toContain('supabase-js') + expect(result.error!.details!.received).toMatchObject({ + authorization: 'api-key', + apikey: 'publishable', + }) + }) + + it('fails 401 UNUSABLE_CREDENTIAL for an apikey header alone on a user-only endpoint', async () => { + const creds: Credentials = { token: null, apikey: 'sb_publishable_xyz' } + const result = await verifyCredentials(creds, { + auth: 'user', + env: makeEnv(), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(UnusableCredentialError) + expect(result.error!.status).toBe(401) + }) + + it('still reports INVALID_API_KEY when a mode does read API keys', async () => { + // The key really did fail a lookup here, so naming the configured keys + // is the right next step. + const creds: Credentials = { + token: 'sb_publishable_nope', + apikey: 'sb_publishable_nope', + } + const result = await verifyCredentials(creds, { + auth: ['user', 'publishable'], + env: makeEnv(), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidApiKeyError) + expect(result.error!.status).toBe(401) + }) + it('another matching mode still wins over the config error', async () => { const creds: Credentials = { token: 'some.jwt.token', diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index 50b4115..c888c75 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -19,7 +19,10 @@ import type { SupabaseEnv, } from '../types.js' import { resolveEnv } from './resolve-env.js' -import { ApiKeyInAuthorizationHeader } from './utils/authorization-header.js' +import { + ApiKeyInAuthorizationHeader, + apiKeyOnUserOnlyEndpoint, +} from './utils/authorization-header.js' import { classifyApiKey } from './utils/classify-credentials.js' import { resolveAuthOption } from './utils/deprecation.js' import { timingSafeEqual } from './utils/timing-safe-equal.js' @@ -343,19 +346,48 @@ function explainFallthrough( const { authorization, apikey } = context.received + if (authorization === 'absent' && apikey === 'absent') { + return Errors[MissingCredentialsError](context) + } + + // Whether any attempted mode looks at API keys at all. INVALID_API_KEY means + // "matched none of the configured keys", which only says something when a + // mode was doing that lookup — on a `user`-only endpoint the key isn't wrong, + // it's the wrong kind of credential, and pointing at the project's keys sends + // the caller hunting for a mismatch that isn't there. + const acceptsApiKey = context.authModes.some( + (mode) => + mode === 'publishable' || + mode.startsWith('publishable:') || + mode === 'secret' || + mode.startsWith('secret:'), + ) + + // supabase-js sends the publishable key in both the `apikey` and + // `Authorization` headers, so an unauthenticated browser call to a `user` + // endpoint arrives with a key in each slot. Reading the `apikey` header first + // would report INVALID_API_KEY; this is UNUSABLE_CREDENTIAL, and the code has + // to carry that on its own since `errors: { detailed: false }` strips the + // hint that would otherwise explain it. + if (!acceptsApiKey && (authorization === 'api-key' || apikey !== 'absent')) { + return Errors[UnusableCredentialError]({ + ...context, + ...apiKeyOnUserOnlyEndpoint({ + inAuthorization: authorization === 'api-key', + inApiKeyHeader: apikey !== 'absent', + }), + }) + } + // An `sb_*` value in the Authorization slot is a credential that *did* // arrive, so it is not "missing" — the distinction has to live in the code - // itself, since `errors: { detailed: false }` strips the hint that would - // otherwise explain it. + // itself, for the same `detailed: false` reason. if (authorization === 'api-key' && apikey === 'absent') { return Errors[UnusableCredentialError]({ ...context, ...ApiKeyInAuthorizationHeader, }) } - if (authorization === 'absent' && apikey === 'absent') { - return Errors[MissingCredentialsError](context) - } if (apikey !== 'absent') { return Errors[InvalidApiKeyError](context) } diff --git a/src/middleware/required-claims/index.ts b/src/middleware/required-claims/index.ts index 7360888..3c6b448 100644 --- a/src/middleware/required-claims/index.ts +++ b/src/middleware/required-claims/index.ts @@ -5,7 +5,7 @@ import type { JSONWebKeySet } from 'jose' import { extractCredentials } from '../../core/extract-credentials.js' import { resolveJwks } from '../../core/resolve-env.js' import { - ApiKeyInAuthorizationHeader, + apiKeyOnUserOnlyEndpoint, diagnoseAuthorizationHeader, } from '../../core/utils/authorization-header.js' import { classifyApiKey } from '../../core/utils/classify-credentials.js' @@ -127,7 +127,13 @@ export const withRequiredClaims: Middleware< received, ...(diagnosis.kind === 'unreadable' ? { reason: diagnosis.reason, hint: diagnosis.hint } - : ApiKeyInAuthorizationHeader), + : // This gate only ever accepts a user JWT, so an API key is + // unusable here however it arrived — telling the caller to + // move it to the `apikey` header would not help. + apiKeyOnUserOnlyEndpoint({ + inAuthorization: true, + inApiKeyHeader: apikey !== null, + })), }), ) } From d17b901b00563125912dc985e7972df90ec08c45 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Fri, 28 Aug 2026 22:59:47 -0300 Subject: [PATCH 6/6] docs: add MissingConnectionStringError documentation and clarify credential error handling --- docs/api-reference.md | 2 ++ docs/error-handling.md | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 27169e5..36fdcae 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -653,6 +653,7 @@ interface SupabaseServerErrorOptions { | `MissingDefaultSecretKeyError` | `'MISSING_DEFAULT_SECRET_KEY'` | `EnvError` | No default secret key | | `MissingResourceServerError` | `'MISSING_RESOURCE_SERVER'` | `EnvError` | `withOAuthProtectedResource` cannot derive a `resourceServer` | | `MissingAuthorizationServerError` | `'MISSING_AUTHORIZATION_SERVER'` | `EnvError` | `withOAuthProtectedResource` cannot derive an authorization server | +| `MissingConnectionStringError` | `'MISSING_CONNECTION_STRING'` | `EnvError` | No Postgres connection string configured | | `AuthGenericError` | `'AUTH_ERROR'` | `AuthError` | Generic auth error (401) | | `MissingCredentialsError` | `'MISSING_CREDENTIALS'` | `AuthError` | Request carried no credentials at all (401) | | `UnusableCredentialError` | `'UNUSABLE_CREDENTIAL'` | `AuthError` | A credential arrived but cannot be used (401) | @@ -682,6 +683,7 @@ const Errors: { [MissingDefaultSecretKeyError]: (configuredKeyNames?) => EnvError [MissingResourceServerError]: () => EnvError [MissingAuthorizationServerError]: () => EnvError + [MissingConnectionStringError]: (middleware: string) => EnvError [MissingCredentialsError]: (context: AuthFailureContext) => AuthError [UnusableCredentialError]: ( context: PartialContext & { reason; hint }, diff --git a/docs/error-handling.md b/docs/error-handling.md index 5d56794..b4ed487 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -117,16 +117,17 @@ If something _did_ arrive but couldn't be used, the code is [`UNUSABLE_CREDENTIA ### `UNUSABLE_CREDENTIAL` -A credential arrived, but not one any accepted auth mode can use. Two shapes: +A credential arrived, but not one any accepted auth mode can use. Three shapes: - **Wrong kind.** An `sb_*` API key in the `Authorization` header where a user JWT is required. The Supabase SDK sends the key in both the `apikey` and `Authorization` headers, so this is easy to hit by accident. `details.received.authorization` is `"api-key"`. +- **API key to an endpoint that reads none.** Every accepted mode is `user`, so an API key can't satisfy it in _either_ header. This is what an unauthenticated `supabase-js` call to a `user`-only endpoint looks like: the publishable key rides both headers, but no session token does. It's reported here rather than as [`INVALID_API_KEY`](#invalid_api_key) — the key isn't wrong, it's the wrong kind of credential, and "check your project's keys" would send you hunting for a mismatch that doesn't exist. - **Unreadable.** A header this library can't read a bearer token out of — wrong scheme (`Basic …`), wrong casing (`bearer` — the scheme is case-sensitive), a bare value with no scheme, or `Bearer` with an empty token. `details.received.authorization` is `"non-bearer-scheme"`. -The `message` names which one happened, so the diagnosis survives even with `hint` and `details` stripped. +The `message` names which one happened, so the diagnosis survives even with `hint` and `details` stripped. `withRequiredClaims` and `withClaims` report an identical request identically — they only ever accept a user token, so the second shape is the one they hit. ### `INVALID_API_KEY` -An `apikey` header was present but matched none of the keys configured for the attempted modes. +An `apikey` header was present but matched none of the keys configured for the attempted modes. Only reported when a `publishable` or `secret` mode was actually attempted — on a `user`-only endpoint an API key is [`UNUSABLE_CREDENTIAL`](#unusable_credential) instead. The `hint` prioritises format mismatches, since sending the wrong _kind_ of key is the most common cause: @@ -158,7 +159,7 @@ A present-but-invalid JWT rejects immediately rather than falling through to the Fallback code, returned when a credential was present but no more specific code applies. -> **Changed in v1.6.** This used to be the only code returned for a failed request. The specific codes above now cover essentially every real failure, so match on those instead. `INVALID_CREDENTIALS` and `Errors[InvalidCredentialsError]()` remain exported and working. +> **Changed in v1.5.** This used to be the only code returned for a failed request. The specific codes above now cover essentially every real failure, so match on those instead. `INVALID_CREDENTIALS` and `Errors[InvalidCredentialsError]()` remain exported and working. ### `JWKS_NOT_CONFIGURED` @@ -217,6 +218,7 @@ Thrown when a required environment variable is missing or malformed. Always `sta | [`MISSING_DEFAULT_SECRET_KEY`](#missing_default_secret_key) | No default secret key found | | [`MISSING_RESOURCE_SERVER`](#missing_resource_server) | `withOAuthProtectedResource` cannot derive a `resourceServer` | | [`MISSING_AUTHORIZATION_SERVER`](#missing_authorization_server) | `withOAuthProtectedResource` cannot derive an authorization server | +| [`MISSING_CONNECTION_STRING`](#missing_connection_string) | No Postgres connection string is configured | | [`ENV_ERROR`](#env_error) | Generic environment error | ### `MISSING_SUPABASE_URL` @@ -249,6 +251,12 @@ Set `SUPABASE_SECRET_KEY`, or add a `"default"` entry to `SUPABASE_SECRET_KEYS`, As above for the authorization server. Pass `authorizationServer`, use `fromSupabaseUrl(...)` for Supabase Auth, or set `SUPABASE_PUBLIC_URL` / `SUPABASE_URL`. +### `MISSING_CONNECTION_STRING` + +`withPostgresClient` / `withPostgresAdminClient` have no Postgres connection string to connect with, so they short-circuit with a 500 before running the handler. + +Set `SUPABASE_DB_URL`, or pass `connectionString` to the middleware — `details.middleware` names which one asked. Supabase Edge Functions provide `SUPABASE_DB_URL` automatically; elsewhere, copy it from Project Settings → Database → Connection string. + ### `ENV_ERROR` Generic environment error. The default code when constructing an `EnvError` yourself.