diff --git a/README.md b/README.md index 8a384d9..d63e1ec 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ await fetch(refreshEndpoint, { | `"secret"` | Valid secret key | Server-to-server, internal calls | | `"always"` | None | Open endpoints, wrappers that handle their own auth | -Array syntax (`allow: ["user", "secret"]`) accepts multiple auth methods — first match wins. +Array syntax (`allow: ["user", "secret"]`) accepts multiple auth methods — first match wins. An absent credential falls through to the next mode; a present-but-invalid JWT rejects the request (no silent downgrade). See [`docs/auth-modes.md`](docs/auth-modes.md). Named key validation: `allow: "public:web_app"` or `allow: "secret:automations"` validates against a specific named key in `SUPABASE_PUBLISHABLE_KEYS` or `SUPABASE_SECRET_KEYS`. diff --git a/docs/api-reference.md b/docs/api-reference.md index d5c636b..b360e03 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -291,17 +291,17 @@ 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 | -| `CreateSupabaseClientError` | `'CREATE_SUPABASE_CLIENT_ERROR'` | `AuthError` | Client creation failed after auth | +| 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 | --- diff --git a/docs/auth-modes.md b/docs/auth-modes.md index c6cd4af..36e2331 100644 --- a/docs/auth-modes.md +++ b/docs/auth-modes.md @@ -147,6 +147,8 @@ export default { A request with a valid JWT matches `'user'`. A request with a valid secret key matches `'secret'`. A request with neither is rejected. +**Fallthrough vs rejection.** A mode is only "tried" when its credential is actually present. A request with no `Authorization` header moves on to the next mode. But if a JWT _is_ present and fails verification (malformed, expired, wrong signature, or missing a `sub` claim), the request is rejected immediately with `InvalidCredentialsError` — it will not silently fall through to `'public'`, `'secret'`, or `'always'`. The same rule applies on the API-key side: `'public'` and `'secret'` fall through only when no `apikey` header is sent. This prevents a bad credential from being downgraded to a less-privileged auth mode. + ## Named key syntax When your project has multiple API keys (e.g., separate keys for web, mobile, and internal services), use the colon syntax to validate against a specific named key. @@ -198,6 +200,6 @@ withSupabase({ allow: ['user', 'public:web'] }, async (_req, ctx) => { 1. `extractCredentials(request)` reads `Authorization: Bearer ` and `apikey` from headers 2. Each mode in `allow` is tried in order against the extracted credentials -3. First match wins — returns an `AuthResult` with `authType`, `token`, `userClaims`, `claims`, and `keyName` +3. First match wins — returns an `AuthResult` with `authType`, `token`, `userClaims`, `claims`, and `keyName`. A mode falls through to the next only when its credential is absent; a credential that is present but invalid terminates the chain with `InvalidCredentialsError`. 4. The auth result is used to create scoped clients (`supabase` with the user's token, `supabaseAdmin` with the secret key) 5. Everything is bundled into a `SupabaseContext` and passed to your handler diff --git a/docs/error-handling.md b/docs/error-handling.md index 85d7bae..20d495f 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -21,11 +21,11 @@ Thrown when a required environment variable is missing or malformed. Always `sta Thrown when authentication or authorization fails. Status is `401` for invalid credentials, `500` for server-side auth failures. -| Code | Status | Meaning | -| ------------------------------ | ------ | ------------------------------------------- | -| `INVALID_CREDENTIALS` | 401 | No credential matched any allowed auth mode | -| `CREATE_SUPABASE_CLIENT_ERROR` | 500 | Auth succeeded but client creation failed | -| `AUTH_ERROR` | 401 | Generic authentication error | +| Code | Status | Meaning | +| ------------------------------ | ------ | ----------------------------------------------------------------------------------------- | +| `INVALID_CREDENTIALS` | 401 | No credential matched any allowed auth mode, or a JWT was present but failed verification | +| `CREATE_SUPABASE_CLIENT_ERROR` | 500 | Auth succeeded but client creation failed | +| `AUTH_ERROR` | 401 | Generic authentication error | ## How errors surface in each layer diff --git a/docs/security.md b/docs/security.md index 5aba948..6d5a02b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -60,6 +60,8 @@ JWT verification in `user` mode works as follows: If JWKS is not configured (`SUPABASE_JWKS` is missing or malformed), `user` mode is unavailable and will always reject requests. +**No silent downgrade.** When `user` is combined with other modes (e.g. `allow: ['user', 'public']`), a JWT that is present but fails verification rejects the request with `InvalidCredentialsError` — it does not fall through to the next mode. This prevents a bad token paired with a valid `apikey` (or with `'always'`) from being silently downgraded to a less-privileged auth mode. Requests that simply omit the `Authorization` header still fall through as expected. + ## CORS handling `withSupabase` handles CORS automatically: diff --git a/skills/supabase-server/SKILL.md b/skills/supabase-server/SKILL.md index 3919ae8..a8c1717 100644 --- a/skills/supabase-server/SKILL.md +++ b/skills/supabase-server/SKILL.md @@ -24,6 +24,7 @@ Server-side utilities for Supabase. Handles auth, client creation, and context i - Wraps fetch handlers with credential verification, CORS, and pre-configured Supabase clients - Supports 4 auth modes: `user` (JWT), `public` (publishable key), `secret` (secret key), `always` (none) +- Array syntax (`allow: ['user', 'secret']`) is first-match-wins. A present-but-invalid JWT rejects with `InvalidCredentialsError` — it does not silently downgrade to the next mode. - Provides composable core primitives for custom auth flows and framework integration - Includes a Hono adapter for per-route auth @@ -199,6 +200,8 @@ Use `allow: 'secret'` to accept any secret key, or `allow: 'secret:name'` to req **Never use `allow: 'always'` for endpoints that read or write user data without verifying who the caller is.** +**On `allow: ['user', 'always']`.** A stale or malformed JWT on such an endpoint is rejected with `InvalidCredentialsError` — it is not silently downgraded to anonymous. Callers that might hold a cached/expired token should either omit the `Authorization` header entirely or refresh before calling. If the goal is "anonymous unless a valid user is signed in," this is the correct behavior; if the goal is truly "accept anything," use `allow: 'always'` on its own. + ## Edge Function recipes ### Function-to-function calls diff --git a/src/core/verify-credentials.test.ts b/src/core/verify-credentials.test.ts index cd93be6..9b71675 100644 --- a/src/core/verify-credentials.test.ts +++ b/src/core/verify-credentials.test.ts @@ -416,4 +416,106 @@ describe('verifyCredentials', () => { expect(result.data!.authType).toBe('always') }) }) + + describe('invalid credential rejection (no silent fallthrough)', () => { + let jwks: JsonWebKeySet + + beforeAll(async () => { + const { publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + jwks = { keys: [publicJwk] } + }) + + it('rejects invalid JWT instead of falling through to always mode', async () => { + const creds: Credentials = { token: 'garbage.jwt.token', apikey: null } + const result = await verifyCredentials(creds, { + allow: ['user', 'always'], + env: makeEnv({ jwks }), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + }) + + it('rejects expired JWT instead of falling through to always mode', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + const expiredJwks = { keys: [publicJwk] } + + const expiredToken = await new SignJWT({ sub: 'user-123' }) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuedAt(Math.floor(Date.now() / 1000) - 7200) + .setExpirationTime(Math.floor(Date.now() / 1000) - 3600) + .sign(privateKey) + + const creds: Credentials = { token: expiredToken, apikey: null } + const result = await verifyCredentials(creds, { + allow: ['user', 'always'], + env: makeEnv({ jwks: expiredJwks }), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + }) + + it('falls through to always when no token is present', async () => { + const creds: Credentials = { token: null, apikey: null } + const result = await verifyCredentials(creds, { + allow: ['user', 'always'], + env: makeEnv({ jwks }), + }) + expect(result.error).toBeNull() + expect(result.data!.authType).toBe('always') + }) + + it('rejects invalid JWT even when public mode follows', async () => { + const creds: Credentials = { + token: 'garbage.jwt.token', + apikey: 'sb_publishable_xyz', + } + const result = await verifyCredentials(creds, { + allow: ['user', 'public'], + env: makeEnv({ jwks }), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + }) + + it('rejects invalid JWT instead of falling through to secret mode', async () => { + const creds: Credentials = { + token: 'garbage.jwt.token', + apikey: 'sb_secret_xyz', + } + const result = await verifyCredentials(creds, { + allow: ['user', 'secret'], + env: makeEnv({ jwks }), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + }) + + it('rejects JWT with missing sub claim', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + const noSubJwks = { keys: [publicJwk] } + + const noSubToken = await new SignJWT({ role: 'authenticated' }) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + const creds: Credentials = { token: noSubToken, apikey: null } + const result = await verifyCredentials(creds, { + allow: 'user', + env: makeEnv({ jwks: noSubJwks }), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + }) + }) }) diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index bce9254..c82ff14 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -73,16 +73,23 @@ function claimsToUserClaims(claims: JWTClaims): UserClaims { } } +const INVALID = Symbol('invalid') + /** * Attempts to authenticate credentials against a single auth mode. - * Returns the {@link AuthResult} on success, or `null` if the mode doesn't match. + * + * 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). + * * @internal */ async function tryMode( mode: AllowWithKey, credentials: Credentials, env: SupabaseEnv, -): Promise { +): Promise { const { base, keyName } = parseAllowMode(mode) switch (base) { @@ -166,7 +173,7 @@ async function tryMode( const jwkSet = createLocalJWKSet(env.jwks) const { payload } = await jwtVerify(credentials.token, jwkSet) if (typeof payload.sub !== 'string') { - return null + return INVALID } const claims = payload as unknown as JWTClaims return { @@ -177,7 +184,7 @@ async function tryMode( keyName: null, } } catch { - return null + return INVALID } } @@ -189,8 +196,11 @@ async function tryMode( /** * Verifies pre-extracted credentials against one or more allowed auth modes. * - * Tries each mode in order — first match wins. Use {@link verifyAuth} to extract - * and verify in a single call. + * 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 + * through to the next mode. Use {@link verifyAuth} to extract and verify in a + * single call. * * @param credentials - The credentials to verify (from {@link extractCredentials}). * @param options - Allowed auth modes and optional env overrides. @@ -225,6 +235,9 @@ export async function verifyCredentials( 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 } } diff --git a/src/types.ts b/src/types.ts index fa62617..7393d9e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,7 +16,10 @@ import type { * // Single mode * withSupabase({ allow: 'user' }, handler) * - * // Multiple modes — the first match wins + * // Multiple modes — the first match wins. + * // A mode is tried only when its credential is present; a JWT that is + * // present but fails verification rejects immediately rather than falling + * // through to the next mode. * withSupabase({ allow: ['user', 'public'] }, handler) * ``` */ @@ -213,6 +216,8 @@ export interface UserClaims { export interface WithSupabaseConfig { /** * Auth mode(s) to accept. Modes are tried in order — the first match wins. + * A mode falls through only when its credential is absent; a present-but-invalid + * JWT short-circuits the chain with `InvalidCredentialsError`. * * @defaultValue `"user"` */