mirror of
https://github.com/supabase/server.git
synced 2026-09-14 15:28:52 +08:00
main
6 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
65f870565a |
feat: compose withSupabase as a pipeline entry (#145)
* feat: compose withSupabase as a pipeline entry * fix: fixes after review * fix: more fixes from review |
||
|
|
f3274c7d14 | docs: mark middleware and oauth surfaces alpha (#141) | ||
|
|
b8b086c021 |
feat(errors): add self-identifying errors with hints and diagnostics (#130)
* feat: specific, self-identifying errors with hints and diagnostics
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.
* feat: add `errors: { detailed: false }` to trim the error response body
`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.
* feat: distinguish UNUSABLE_CREDENTIAL from MISSING_CREDENTIALS
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.
* fix: preserve error cause when client creation fails
* fix: report API keys as UNUSABLE_CREDENTIAL on user-only endpoints
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.
* docs: add MissingConnectionStringError documentation and clarify credential error handling
|
||
|
|
05a95b6dcb |
feat: add withRequiredClaims user-mode auth gate (#127)
* feat: add withRequiredClaims user-mode auth gate * chore: add export to readme * fix: add required-claims jsr export, note cors and sole-entry recipe |
||
|
|
996825886b | docs: state that withClaims is not an auth gate (#125) | ||
|
|
77656bae3b |
feat(middleware): ship withPostgresClient and withPostgresAdminClient (#115)
* refactor(middleware): rename withPostgres to withPostgresClient and harden it
Renames the export to sit alongside withSupabaseClient /
withSupabaseAdminClient, and extracts the pool into a shared core module so
the service-role companion can reuse it. Safe to rename now: the old name
exists only on 1.5.0-rc.* / beta, never on a stable release.
Three correctness fixes alongside it:
- The pool cache was keyed on nothing, so a second connectionString in the
same process silently queried the first database. Now keyed per string.
- The missing-connection-string 500 returned { error }, not the package's
standard { message, code }.
- An unguarded rollback in the catch could replace the caller's real error
with a connection error.
Adds unit coverage for each, plus a type-level check that composing without
an upstream jwtClaims stays a compile-time error.
* feat(middleware): add withPostgresAdminClient
Contributes ctx.postgresAdmin — a pg client that bypasses RLS, exported from
./middleware/postgres-admin. Queries run as-is under the connection-string
role: no claim injection, no role switch, no wrapping transaction.
Declares no upstream prerequisite, so unlike withPostgresClient it composes
under auth: 'secret' and auth: 'none'. Shares the pool cache with the scoped
half — same connection string, one pool. That is safe because everything the
scoped half sets is transaction-local, so a connection always returns clean.
Kept as a second middleware rather than a property on ctx.postgres:
defineMiddleware contributes exactly one ctx key, and the split keeps the RLS
bypass visible at the composition site.
* test(e2e): cover both postgres middleware against a real database
Adds /my-notes-pg and /all-notes-pg to the core Node app and the Deno edge
function, both running the identical unfiltered SELECT — one through
ctx.postgres, one through ctx.postgresAdmin. user2 sees none of user1's rows
through the scoped client and sees them through the admin one, which proves
claim injection, the role drop, and the bypass in a single contrast.
The edge function passes connectionString explicitly from E2E_DB_URL: the CLI
injects a SUPABASE_DB_URL addressing the database by container name, and
Deno's DNS resolver rejects the underscores in it. The Node app still covers
the SUPABASE_DB_URL default path.
* docs: document the postgres middleware pair
Adds docs/postgres.md covering both halves, the SQL each query runs, the two
composition paths, table grants, the RLS bypass and why it is a separate
middleware, and guidance to write policies with the auth.* helpers rather
than reading request.jwt.claim.* directly.
Wires both subpaths into typedoc entryPoints — without which neither export
reached api-docs/ — and adds README sections, Exports and env-var rows, and
api-reference entries.
* fix(middleware): discard the connection when a rollback fails
pg-pool only removes a client when release() is given a truthy argument, so
the previous release() returned a connection whose transaction could not be
unwound straight back to the pool — potentially still inside the caller's
transaction with their role set.
That was survivable while the pool served one middleware. It is not now that
withPostgresAdminClient shares it: that middleware begins no transaction and
sets up no session state, so it would silently inherit the leftover role on
the next checkout.
* fix(middleware): refuse unsupported roles instead of downgrading to anon
withPostgresClient silently mapped every role that was not 'authenticated'
to 'anon'. For a forged service_role that was the intent, but Supabase also
supports custom roles via the role claim, and RLS applies to those normally —
so a legitimate `role: manager` token was being answered with zero rows and
no indication that the role was the reason.
Now only 'authenticated' and 'anon' are assumed, and anything else
short-circuits with a 500 and code UNSUPPORTED_ROLE before the handler runs
or a connection is checked out. service_role gets a message pointing at
withPostgresAdminClient; other roles are named in the error.
Custom roles remain unsupported — the reason is that PostgREST connects as the
unprivileged authenticator, where `grant <role> to authenticator` is itself the
authorization, while we connect as postgres and have no such boundary to lean
on. Documented, and tracked separately.
Also hoists the per-request claims serialization out of the per-query path.
* docs: list every subpath in the README exports table
The table covered 8 of 13 entry points. Adding the postgres pair made the
omission look deliberate rather than incidental — a reader could reasonably
conclude withClaims has no subpath, which matters because it is the documented
prerequisite for composing withPostgresClient standalone.
* feat(middleware): make query a tagged template, add queryRaw and ident
`query` now takes a tagged template only, so every interpolation becomes a
bind parameter and can never alter the shape of the statement. `queryRaw(text,
params)` keeps the string form — it is fully safe with params, and it is the
only path that works for query builders and codegen emitting `{ sql,
parameters }`, or for SQL that has to interpolate an identifier.
Passing a plain string to `query` throws, naming `queryRaw`. The two calls
differ only in their brackets, so refusing beats reinterpreting: the string's
first character would otherwise be read as the whole template and a
one-character query would be sent.
`ident()` quotes identifiers, which can never be bind parameters — `select $1
from notes` selects a literal, not a column. It is implemented directly rather
than wrapping `pg.escapeIdentifier`: that top-level export only exists from
pg 8.11, while the peer range is `^8.0.0`, so a wrapper would be a runtime
TypeError on 8.0-8.5. It also rejects empty names and NUL bytes, which pg
passes straight through to a confusing server-side error.
`set local role` now quotes the role via `ident()`. The role is already
constrained to the SUPPORTED_ROLES allowlist, so this changes nothing today —
it keeps the interpolation safe if that list widens to the custom roles the
docstring promises.
Follows the prior art: Prisma shipped the dual overload and reversed it,
Slonik refuses plain strings outright, and postgres.js requires the tag with
`sql.unsafe` as the named escape hatch.
The e2e edge function built its query by interpolating a column list. As a
`query` tag that would have compiled to `select $1 from notes` and returned
the literal string for every row — valid SQL, wrong rows, no error. It now
uses `queryRaw`, with a comment explaining why.
* fix: refuse non-string role claims instead of downgrading to anon
* chore: keep prettier off the release-please changelog
---------
Co-authored-by: Katerina Skroumpelou <sk.katherine@gmail.com>
|