Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
87 changes: 86 additions & 1 deletion src/core/verify-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SupabaseEnv>): Partial<SupabaseEnv> {
return {
Expand Down Expand Up @@ -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[]
Expand Down
36 changes: 35 additions & 1 deletion src/core/verify-credentials.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { AuthError, Errors, InvalidCredentialsError } from '../errors.js'
import {
AuthError,
EnvGenericError,
Errors,
InvalidCredentialsError,
} from '../errors.js'
import type {
AuthMode,
AuthModeWithKey,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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](),
Expand Down
71 changes: 71 additions & 0 deletions src/middleware/required-claims/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
Loading