diff --git a/docs/error-handling.md b/docs/error-handling.md index 646512c..14b67cd 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -26,6 +26,7 @@ Thrown when authentication or authorization fails. Status is `401` for invalid c | 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 | diff --git a/src/core/verify-credentials.test.ts b/src/core/verify-credentials.test.ts index 0b15151..bd1085e 100644 --- a/src/core/verify-credentials.test.ts +++ b/src/core/verify-credentials.test.ts @@ -14,7 +14,7 @@ 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 { InvalidCredentialsError } from '../errors.js' +import { EnvGenericError, InvalidCredentialsError } from '../errors.js' function makeEnv(overrides?: Partial): Partial { return { @@ -381,6 +381,91 @@ describe('verifyCredentials', () => { }) }) + describe('user mode without a JWKS source', () => { + // `resolveEnv` falls back from a null `jwks` override to the env vars, so + // they are stubbed empty to make "no JWKS anywhere" explicit. + beforeEach(() => { + vi.stubEnv('SUPABASE_JWKS', '') + vi.stubEnv('SUPABASE_JWKS_URL', '') + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('fails 500 ENV_ERROR 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!.status).toBe(500) + expect(result.error!.message).toContain('JWKS') + }) + + it('fails 401 when no token is present', async () => { + // Missing credentials are the caller's problem and are reported before + // missing configuration. + const creds: Credentials = { token: null, 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!.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. + 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!.status).toBe(401) + }) + + it('another matching mode still wins over the config error', async () => { + const creds: Credentials = { + token: 'some.jwt.token', + apikey: 'sb_publishable_xyz', + } + const result = await verifyCredentials(creds, { + auth: ['user', 'publishable'], + env: makeEnv(), + }) + expect(result.error).toBeNull() + expect(result.data!.authMode).toBe('publishable') + }) + + it('fails 500 in a mode array when nothing else matches', async () => { + const creds: Credentials = { token: 'some.jwt.token', apikey: null } + const result = await verifyCredentials(creds, { + auth: ['user', 'publishable'], + env: makeEnv(), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(EnvGenericError) + expect(result.error!.status).toBe(500) + }) + + it('fails 401 when user mode is not among the allowed modes', async () => { + const creds: Credentials = { token: 'some.jwt.token', apikey: null } + const result = await verifyCredentials(creds, { + auth: 'publishable', + env: makeEnv(), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + expect(result.error!.status).toBe(401) + }) + }) + describe('user mode with remote JWKS URL', () => { let jwks: JSONWebKeySet let validTokens: string[] diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index 085f31f..f9fca96 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -1,4 +1,9 @@ -import { AuthError, Errors, InvalidCredentialsError } from '../errors.js' +import { + AuthError, + EnvGenericError, + Errors, + InvalidCredentialsError, +} from '../errors.js' import type { AuthMode, AuthModeWithKey, @@ -197,6 +202,12 @@ async function tryMode( * 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. + * * @param credentials - The credentials to verify (from {@link extractCredentials}). * @param options - Allowed auth modes and optional env overrides. * @returns `{ data: AuthResult, error: null }` on success, `{ data: null, error: AuthError }` on failure. @@ -241,6 +252,29 @@ export async function verifyCredentials( } } + // 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, + ), + } + } + return { data: null, error: Errors[InvalidCredentialsError](), diff --git a/src/middleware/required-claims/index.test.ts b/src/middleware/required-claims/index.test.ts index c6e516b..624c57e 100644 --- a/src/middleware/required-claims/index.test.ts +++ b/src/middleware/required-claims/index.test.ts @@ -167,6 +167,77 @@ describe('withRequiredClaims', () => { const body = await res.json() expect(body.code).toBe(InvalidCredentialsError) }) + + describe('parity with withSupabase auth: "user"', () => { + // Both gates share `verifyUserJwt`; these tests pin the rest of the + // contract — the same request yields the same status and error code + // through either entry point. + const supabaseEnv = (jwksSource: JSONWebKeySet | null) => ({ + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: jwksSource, + }) + + async function both( + token: string | undefined, + jwksSource: JSONWebKeySet | null, + ) { + const gated = withRequiredClaims( + jwksSource ? { jwks: jwksSource } : undefined, + async (_req, ctx) => Response.json({ sub: ctx.jwtClaims.sub }), + ) + const wrapped = withSupabase( + { auth: 'user', cors: 'disabled', env: supabaseEnv(jwksSource) }, + async (_req, ctx) => Response.json({ sub: ctx.jwtClaims!.sub }), + ) + return { + gate: await gated(requestWithToken(token)), + supabase: await wrapped(requestWithToken(token)), + } + } + + it('valid token: both run the handler with the same subject', async () => { + const { gate, supabase } = await both(rsToken, jwks) + expect(gate.status).toBe(200) + expect(supabase.status).toBe(200) + expect(await gate.json()).toEqual(await supabase.json()) + }) + + it('missing token: both 401 INVALID_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) + } + }) + + it('sb_* key in the Authorization slot: both 401 INVALID_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) + } + }) + + it('token signed by an unknown key: both 401 INVALID_CREDENTIALS', 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) + } + }) + + it('token present but no JWKS configured: both 500 ENV_ERROR', 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) + } + }) + }) }) describe('withRequiredClaims composition (type-level)', () => {