diff --git a/README.md b/README.md index 25f0ae5..8083be0 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ await fetch(refreshEndpoint, { | `"secret"` | Valid `default` secret key | Server-to-server, internal calls | | `"none"` | None | Open endpoints, wrappers that handle their own auth | -Array syntax (`auth: ["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). +Array syntax (`auth: ["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). Because `"none"` matches every request, it is only accepted on its own or as the last entry of a list (`auth: ["user", "none"]` — optional user). Named key validation: `auth: "publishable:web_app"` or `auth: "secret:automations"` validates against a specific named key in `SUPABASE_PUBLISHABLE_KEYS` or `SUPABASE_SECRET_KEYS`. Bare `auth: "secret"` (or `"publishable"`) matches only the `default` key; use the wildcard `auth: "secret:*"` to accept any key in the set. See [`docs/auth-modes.md`](docs/auth-modes.md). diff --git a/docs/api-reference.md b/docs/api-reference.md index 9881fd3..ef18cca 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -459,6 +459,33 @@ type AuthModeWithKey = AuthMode | `publishable:${string}` | `secret:${string}` Extended auth mode with named key support. Examples: `'publishable:web'`, `'secret:*'`, `'secret:internal'`. The bare form (`'publishable'` / `'secret'`) matches only the `default` key; `:*` accepts any key in the set. +### CredentialedAuthMode + +```ts +type CredentialedAuthMode = Exclude +``` + +Every `AuthModeWithKey` except `'none'`, keyed forms included. + +### AuthConfig + +```ts +type AuthConfig = + | 'none' + | CredentialedAuthMode + | [CredentialedAuthMode, ...CredentialedAuthMode[]] + | [CredentialedAuthMode, ...CredentialedAuthMode[], 'none'] +``` + +The accepted shape of the `auth` option. `'none'` matches every request, so the type allows it on its own or as the last entry of a list, and nowhere else — `['none']` says nothing that a bare `'none'` doesn't, and a mode placed after `'none'` can never be reached. A single mode needs no wrapping array: `'user'` and `['user']` are the same configuration. + +```ts +withSupabase({ auth: 'user' }, handler) // one mode +withSupabase({ auth: ['secret', 'user'] }, handler) // first match wins +withSupabase({ auth: ['user', 'none'] }, handler) // optional user +withSupabase({ auth: 'none' }, handler) // no credentials required +``` + ### Allow / AllowWithKey (deprecated aliases) `Allow` and `AllowWithKey` are kept as deprecated aliases for `AuthMode` and `AuthModeWithKey`. Prefer the `Auth*` names — the legacy ones will be removed in a future major release. @@ -480,7 +507,7 @@ interface SupabaseContext { ```ts interface WithSupabaseConfig { - auth?: AuthModeWithKey | AuthModeWithKey[] // default: 'user' + auth?: AuthConfig // default: 'user' /** @deprecated use `auth` instead — will be removed in a future major release */ allow?: AuthModeWithKey | AuthModeWithKey[] env?: Partial diff --git a/docs/auth-modes.md b/docs/auth-modes.md index b7e08f9..603326a 100644 --- a/docs/auth-modes.md +++ b/docs/auth-modes.md @@ -128,7 +128,7 @@ Use `none` for health checks, public APIs, or when you handle auth yourself insi ## Array syntax (multiple modes) -Accept multiple auth methods. Modes are tried in order — the first match wins. +Accept multiple auth methods. Modes are tried in order — the first match wins. The array is for lists only: a single mode is written unwrapped (`auth: 'user'`), though `auth: ['user']` means the same thing. ```ts import { withSupabase } from '@supabase/server' @@ -155,6 +155,15 @@ 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. +**Where `'none'` can go.** `'none'` matches every request, so it only earns its place as the **last** entry of a list — `auth: ['user', 'none']` is "a user if one is signed in, anonymous otherwise." Anything after it is unreachable, and a list of just `['none']` says nothing that a bare `auth: 'none'` doesn't. Both are type errors: + +```ts +withSupabase({ auth: ['user', 'none'] }, handler) // ✅ optional user +withSupabase({ auth: 'none' }, handler) // ✅ open endpoint +withSupabase({ auth: ['none'] }, handler) // ❌ use the bare 'none' +withSupabase({ auth: ['none', 'user'] }, handler) // ❌ 'user' is unreachable +``` + **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 `'publishable'`, `'secret'`, or `'none'`. The same rule applies on the API-key side: `'publishable'` 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 diff --git a/skills/supabase-server/SKILL.md b/skills/supabase-server/SKILL.md index e2a020d..9cbb266 100644 --- a/skills/supabase-server/SKILL.md +++ b/skills/supabase-server/SKILL.md @@ -207,6 +207,8 @@ Bare `auth: 'secret'` matches only the `default` key. Use `auth: 'secret:name'` **On `auth: ['user', 'none']`.** 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 `auth: 'none'` on its own. +**`'none'` goes last, or alone.** It matches every request, so the type accepts it only as the final entry of a list (`['user', 'none']`) or on its own (`'none'`). `['none']` and `['none', 'user']` are type errors — write the bare `'none'` for the first, and put `'none'` last for the second. + ## Edge Function recipes ### Function-to-function calls diff --git a/src/index.ts b/src/index.ts index 62d5fc6..a4e039b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -106,10 +106,12 @@ export type { export type { Allow, AllowWithKey, + AuthConfig, AuthMode, AuthModeWithKey, AuthResult, ClientAuth, + CredentialedAuthMode, CreateAdminClientOptions, CreateContextClientOptions, Credentials, diff --git a/src/types.test.ts b/src/types.test.ts new file mode 100644 index 0000000..2ff1d62 --- /dev/null +++ b/src/types.test.ts @@ -0,0 +1,68 @@ +import { describe, expectTypeOf, it } from 'vitest' + +import type { AuthConfig, WithSupabaseConfig } from './types.js' + +/** + * `AuthConfig` is enforced by `tsc`, not by vitest: the `@ts-expect-error` + * lines below fail `pnpm typecheck` if the type ever starts accepting an + * ordering it shouldn't, and fail it just as loudly if it starts rejecting + * one it should accept (an unused `@ts-expect-error` is itself an error). + */ +describe('AuthConfig', () => { + it('accepts a single mode unwrapped, keyed or not', () => { + expectTypeOf<'user'>().toExtend() + expectTypeOf<'secret'>().toExtend() + expectTypeOf<'publishable:mobile'>().toExtend() + expectTypeOf<'secret:*'>().toExtend() + }) + + it('accepts the wrapped form of a single mode too', () => { + const bare: AuthConfig = 'user' + const wrapped: AuthConfig = ['user'] + void bare + void wrapped + }) + + it('accepts an ordered list of credentialed modes', () => { + const ordered: AuthConfig = ['secret', 'user'] + const keyed: AuthConfig = ['user', 'publishable:web_app', 'secret:*'] + void ordered + void keyed + }) + + it('accepts bare "none", and "none" as the last entry of a list', () => { + const bare: AuthConfig = 'none' + const optionalUser: AuthConfig = ['user', 'none'] + const optionalAnything: AuthConfig = ['user', 'secret:*', 'none'] + void bare + void optionalUser + void optionalAnything + }) + + it('rejects "none" as a list of one — a bare "none" says the same thing', () => { + // @ts-expect-error "none" needs a credentialed mode before it + const only: AuthConfig = ['none'] + void only + }) + + it('rejects modes placed after "none", which can never be reached', () => { + // @ts-expect-error "none" matches every request, so nothing follows it + const unreachable: AuthConfig = ['none', 'user'] + void unreachable + // @ts-expect-error same, with the trailing entry in final position + const sandwiched: AuthConfig = ['user', 'none', 'secret'] + void sandwiched + }) + + it('rejects a repeated "none"', () => { + // @ts-expect-error only the last entry may be "none" + const twice: AuthConfig = ['user', 'none', 'none'] + void twice + }) + + it('is the type of the `auth` option', () => { + expectTypeOf().toEqualTypeOf< + AuthConfig | undefined + >() + }) +}) diff --git a/src/types.ts b/src/types.ts index fc3f054..26afffe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,6 +74,45 @@ export type AuthModeWithKey = */ export type AllowWithKey = AuthModeWithKey +/** + * An auth mode that requires a credential — every {@link AuthModeWithKey} + * except `"none"`, keyed forms included. + * + * @category Types + */ +export type CredentialedAuthMode = Exclude + +/** + * Accepted shape of the `auth` option: one mode, or an ordered list of modes + * tried left to right. + * + * `"none"` accepts every request, so it only carries meaning as the last + * entry of a list — the modes before it are the ones that can produce an + * identity, and anything after it is unreachable. On its own in a list + * (`["none"]`) it says nothing that a bare `auth: 'none'` doesn't. So the + * type accepts `"none"` alone or in final position behind at least one + * credentialed mode, and nowhere else. + * + * A single mode needs no wrapping array — `"user"` and `["user"]` are the + * same configuration, and the unwrapped form is the one the union names + * first, so it is what shows up in editor completions. + * + * @example Every accepted form + * ```ts + * withSupabase({ auth: 'user' }, handler) // one mode + * withSupabase({ auth: ['secret', 'user'] }, handler) // first match wins + * withSupabase({ auth: ['user', 'none'] }, handler) // optional user + * withSupabase({ auth: 'none' }, handler) // no credentials required + * ``` + * + * @category Types + */ +export type AuthConfig = + | 'none' + | CredentialedAuthMode + | [CredentialedAuthMode, ...CredentialedAuthMode[]] + | [CredentialedAuthMode, ...CredentialedAuthMode[], 'none'] + /** * Resolved Supabase environment configuration. * @@ -255,14 +294,19 @@ export interface WithSupabaseConfig { * A mode falls through only when its credential is absent; a present-but-invalid * JWT short-circuits the chain with `InvalidCredentialsError`. * + * `"none"` matches unconditionally, so it belongs last in a list or on its + * own — see {@link AuthConfig}. + * * @defaultValue `"user"` */ - auth?: AuthModeWithKey | AuthModeWithKey[] + auth?: AuthConfig /** * @deprecated Use {@link WithSupabaseConfig.auth} instead. The `allow` option * is kept for backward compatibility and will be removed in a future major release. - * When both `auth` and `allow` are provided, `auth` takes precedence. + * When both `auth` and `allow` are provided, `auth` takes precedence. It keeps + * the older, looser element type so code mid-migration still compiles; `auth` + * is where the {@link AuthConfig} ordering rule is enforced. */ allow?: AuthModeWithKey | AuthModeWithKey[] diff --git a/src/with-supabase.ts b/src/with-supabase.ts index 349d8a5..52835c7 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -13,7 +13,11 @@ import { } from './core/parts/projections.js' import { withSupabaseAdminClient } from './middleware/admin-client/index.js' import { withSupabaseClient } from './middleware/client/index.js' -import type { SupabaseContext, WithSupabaseConfig } from './types.js' +import type { + AuthModeWithKey, + SupabaseContext, + WithSupabaseConfig, +} from './types.js' // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyHandler = (req: Request, ctx: any) => Promise @@ -65,7 +69,12 @@ void entryIsSound /** Whether every configured auth mode requires credentials, so an unauthenticated request never passes the gate. */ function requiresCredentials(config: WithSupabaseConfig): boolean { const modes = config.auth ?? config.allow ?? 'user' - const list = Array.isArray(modes) ? modes : [modes] + // `auth` keeps `'none'` out of every position but the last; the deprecated + // `allow` still admits it anywhere, so this reads the widened element type + // and scans the whole list. + const list: readonly AuthModeWithKey[] = Array.isArray(modes) + ? modes + : [modes] return !list.includes('none') }