diff --git a/README.md b/README.md index f64f677..79ac64d 100644 --- a/README.md +++ b/README.md @@ -400,19 +400,20 @@ export default { Automatically available in Supabase Edge Functions: -| Variable | Format | Description | -| --------------------------- | ------------------------------------------------------------- | ------------------------------------- | -| `SUPABASE_URL` | `https://.supabase.co` | Your project URL | -| `SUPABASE_PUBLISHABLE_KEYS` | `{"default":"sb_publishable_...","web":"sb_publishable_..."}` | Publishable API keys (named) | -| `SUPABASE_SECRET_KEYS` | `{"default":"sb_secret_...","web":"sb_secret_..."}` | Secret API keys (named) | -| `SUPABASE_JWKS` | `{"keys":[...]}` or `[...]` | JSON Web Key Set for JWT verification | +| Variable | Format | Description | +| --------------------------- | ------------------------------------------------------------- | -------------------------------------------- | +| `SUPABASE_URL` | `https://.supabase.co` | Your project URL | +| `SUPABASE_PUBLISHABLE_KEYS` | `{"default":"sb_publishable_...","web":"sb_publishable_..."}` | Publishable API keys (named) | +| `SUPABASE_SECRET_KEYS` | `{"default":"sb_secret_...","web":"sb_secret_..."}` | Secret API keys (named) | +| `SUPABASE_JWKS` | `{"keys":[...]}` or `[...]` | Inline JSON Web Key Set for JWT verification | Also supported (for local dev, self-hosted, or other runtimes): -| Variable | Format | Description | -| -------------------------- | -------------------- | ---------------------- | -| `SUPABASE_PUBLISHABLE_KEY` | `sb_publishable_...` | Single publishable key | -| `SUPABASE_SECRET_KEY` | `sb_secret_...` | Single secret key | +| Variable | Format | Description | +| -------------------------- | -------------------- | --------------------------------------------------------- | +| `SUPABASE_PUBLISHABLE_KEY` | `sb_publishable_...` | Single publishable key | +| `SUPABASE_SECRET_KEY` | `sb_secret_...` | Single secret key | +| `SUPABASE_JWKS_URL` | `https://...` | Remote JWKS endpoint (used when `SUPABASE_JWKS` is unset) | When both singular and plural forms are set, plural takes priority. diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 0af05d0..2a73af8 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -2,25 +2,25 @@ On Supabase Platform and Local Development (CLI), all variables are auto-provisioned — no configuration needed -| Variable | Format | Description | Available in | -| --------------------------- | ---------------------------------- | ------------------------------------- | --------------------------------- | -| `SUPABASE_URL` | `https://.supabase.co` | Your Supabase project URL | All | -| `SUPABASE_PUBLISHABLE_KEYS` | `{"default":"sb_publishable_..."}` | Named publishable keys as JSON object | All | -| `SUPABASE_SECRET_KEYS` | `{"default":"sb_secret_..."}` | Named secret keys as JSON object | All | -| `SUPABASE_JWKS` | `{"keys":[...]}` or `[...]` | JSON Web Key Set for JWT verification | All | -| `SUPABASE_PUBLISHABLE_KEY` | `sb_publishable_...` | Single publishable key (fallback) | Self-hosted, if manually exported | -| `SUPABASE_SECRET_KEY` | `sb_secret_...` | Single secret key (fallback) | Self-hosted, if manually exported | +| Variable | Format | Description | Available in | +| --------------------------- | ---------------------------------- | -------------------------------------------- | --------------------------------- | +| `SUPABASE_URL` | `https://.supabase.co` | Your Supabase project URL | All | +| `SUPABASE_PUBLISHABLE_KEYS` | `{"default":"sb_publishable_..."}` | Named publishable keys as JSON object | All | +| `SUPABASE_SECRET_KEYS` | `{"default":"sb_secret_..."}` | Named secret keys as JSON object | All | +| `SUPABASE_JWKS` | `{"keys":[...]}` or `[...]` | Inline JSON Web Key Set for JWT verification | All | +| `SUPABASE_PUBLISHABLE_KEY` | `sb_publishable_...` | Single publishable key (fallback) | Self-hosted, if manually exported | +| `SUPABASE_SECRET_KEY` | `sb_secret_...` | Single secret key (fallback) | Self-hosted, if manually exported | ## Non-Supabase environments (Node.js, Bun, Cloudflare, self-hosted) Set these based on which auth modes your app uses: -| Variable | Required when | -| -------------------------- | ----------------------------------------- | -| `SUPABASE_URL` | Always | -| `SUPABASE_SECRET_KEY` | `auth: 'secret'` or using `supabaseAdmin` | -| `SUPABASE_PUBLISHABLE_KEY` | `auth: 'publishable'` | -| `SUPABASE_JWKS` | `auth: 'user'` (JWT verification) | +| Variable | Required when | +| -------------------------------------- | ----------------------------------------- | +| `SUPABASE_URL` | Always | +| `SUPABASE_SECRET_KEY` | `auth: 'secret'` or using `supabaseAdmin` | +| `SUPABASE_PUBLISHABLE_KEY` | `auth: 'publishable'` | +| `SUPABASE_JWKS` or `SUPABASE_JWKS_URL` | `auth: 'user'` (JWT verification) | ### Minimal `.env` example @@ -75,19 +75,34 @@ The singular form is a convenience for the common case where you only have one k When both singular and plural forms are set, the plural form takes priority. -## JWKS format +## JWKS source -`SUPABASE_JWKS` accepts two formats: +JWT verification (`auth: 'user'`) needs a JWKS. There are two ways to provide one: ``` -# Standard JWKS format +# Inline JSON — standard JWKS format SUPABASE_JWKS={"keys":[{"kty":"RSA","n":"...","e":"AQAB"}]} -# Bare array (convenience) +# Inline JSON — bare array (convenience, wrapped as { keys: [...] }) SUPABASE_JWKS=[{"kty":"RSA","n":"...","e":"AQAB"}] + +# Remote JWKS endpoint — keys are fetched on demand and cached in memory. +# HTTPS is required for any non-loopback host; plain http:// is rejected +# (a MITM on the JWKS fetch could swap in an attacker-controlled key and +# forge JWTs that verify). http:// is allowed for loopback hosts only — +# `localhost`, `127.0.0.0/8`, `::1` — to support the local Supabase CLI. +SUPABASE_JWKS_URL=https://.supabase.co/auth/v1/.well-known/jwks.json + +# Local development against `supabase start`: +SUPABASE_JWKS_URL=http://localhost:54321/auth/v1/.well-known/jwks.json ``` -When `SUPABASE_JWKS` is not set, JWT verification (`auth: 'user'`) is unavailable. +### Resolution order + +1. `SUPABASE_JWKS` — when set, treated as authoritative inline JSON. +2. `SUPABASE_JWKS_URL` — only checked when `SUPABASE_JWKS` is unset or empty. + Must be `https://`, except loopback hosts may use `http://`. +3. Otherwise — `null`. JWT verification (`auth: 'user'`) is unavailable. ## Runtime-specific behavior @@ -176,7 +191,8 @@ interface SupabaseEnv { url: string publishableKeys: Record secretKeys: Record - jwks: JsonWebKeySet | null + // `URL` when SUPABASE_JWKS is a remote endpoint, `JsonWebKeySet` for inline keys + jwks: JsonWebKeySet | URL | null } ``` diff --git a/src/core/resolve-env.test.ts b/src/core/resolve-env.test.ts index 532c689..3a4afc0 100644 --- a/src/core/resolve-env.test.ts +++ b/src/core/resolve-env.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { resolveEnv } from './resolve-env.js' import { MissingSupabaseURLError } from '../errors.js' +import type { JsonWebKeySet } from '../types.js' describe('resolveEnv', () => { afterEach(() => { @@ -67,6 +68,111 @@ describe('resolveEnv', () => { expect(result.data!.jwks).toBeNull() }) + it('parses SUPABASE_JWKS_URL into a URL object', () => { + vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co') + vi.stubEnv( + 'SUPABASE_JWKS_URL', + 'https://test.supabase.co/auth/v1/.well-known/jwks.json', + ) + const result = resolveEnv() + expect(result.data!.jwks).toBeInstanceOf(URL) + expect((result.data!.jwks as URL).href).toBe( + 'https://test.supabase.co/auth/v1/.well-known/jwks.json', + ) + }) + + it.each([ + ['plain hostname', 'http://example.com/jwks.json'], + ['public IP', 'http://1.2.3.4/jwks.json'], + ['private IP (non-loopback)', 'http://10.0.0.1/jwks.json'], + ['localhost prefix attack', 'http://localhost.evil.com/jwks.json'], + ])( + 'rejects http SUPABASE_JWKS_URL on non-loopback host (%s)', + (_label, value) => { + vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co') + vi.stubEnv('SUPABASE_JWKS_URL', value) + const result = resolveEnv() + expect(result.data!.jwks).toBeNull() + }, + ) + + it.each([ + ['localhost', 'http://localhost:54321/auth/v1/.well-known/jwks.json'], + ['127.0.0.1', 'http://127.0.0.1:54321/auth/v1/jwks'], + ['127.x range', 'http://127.0.0.5/jwks.json'], + ['::1', 'http://[::1]:54321/jwks.json'], + ['*.localhost subdomain', 'http://api.localhost/jwks.json'], + ])( + 'allows http SUPABASE_JWKS_URL for loopback host (%s)', + (_label, value) => { + vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co') + vi.stubEnv('SUPABASE_JWKS_URL', value) + const result = resolveEnv() + expect(result.data!.jwks).toBeInstanceOf(URL) + }, + ) + + it.each([ + ['unclosed IPv6 bracket', 'https://[invalid'], + ['scheme only, no host', 'https://'], + ])('returns null for malformed SUPABASE_JWKS_URL (%s)', (_label, value) => { + vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co') + vi.stubEnv('SUPABASE_JWKS_URL', value) + const result = resolveEnv() + expect(result.data!.jwks).toBeNull() + }) + + it('trims whitespace around SUPABASE_JWKS_URL values', () => { + vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co') + vi.stubEnv('SUPABASE_JWKS_URL', ' https://example.com/jwks.json\n') + const result = resolveEnv() + expect(result.data!.jwks).toBeInstanceOf(URL) + expect((result.data!.jwks as URL).href).toBe( + 'https://example.com/jwks.json', + ) + }) + + it('rejects a URL value placed in SUPABASE_JWKS (mixed-type protection)', () => { + vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co') + vi.stubEnv('SUPABASE_JWKS', 'https://example.com/jwks.json') + const result = resolveEnv() + expect(result.data!.jwks).toBeNull() + }) + + it('SUPABASE_JWKS wins over SUPABASE_JWKS_URL when both are set', () => { + vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co') + const inline = { keys: [{ kty: 'RSA', n: 'inline', e: 'AQAB' }] } + vi.stubEnv('SUPABASE_JWKS', JSON.stringify(inline)) + vi.stubEnv('SUPABASE_JWKS_URL', 'https://example.com/jwks.json') + const result = resolveEnv() + expect(result.data!.jwks).toEqual(inline) + }) + + it('does not fall through to SUPABASE_JWKS_URL when SUPABASE_JWKS is malformed', () => { + vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co') + vi.stubEnv('SUPABASE_JWKS', 'not-json') + vi.stubEnv('SUPABASE_JWKS_URL', 'https://example.com/jwks.json') + const result = resolveEnv() + expect(result.data!.jwks).toBeNull() + }) + + it('falls through to SUPABASE_JWKS_URL when SUPABASE_JWKS is unset or empty', () => { + vi.stubEnv('SUPABASE_URL', 'https://test.supabase.co') + vi.stubEnv('SUPABASE_JWKS', '') + vi.stubEnv('SUPABASE_JWKS_URL', 'https://example.com/jwks.json') + const result = resolveEnv() + expect(result.data!.jwks).toBeInstanceOf(URL) + }) + + it('passes URL overrides through unchanged', () => { + const url = new URL('https://example.com/jwks.json') + const result = resolveEnv({ + url: 'https://test.supabase.co', + jwks: url, + }) + expect(result.data!.jwks).toBe(url) + }) + it.each([ ['a primitive', '1'], ['an empty object', '{}'], @@ -138,11 +244,12 @@ describe('resolveEnv', () => { default: 'sb_secret_fake_default_key_val', internal: 'sb_secret_fake_internal_key', }) - expect(result.data!.jwks!.keys).toHaveLength(2) - expect((result.data!.jwks!.keys[0] as Record).kid).toBe( + const jwks = result.data!.jwks as JsonWebKeySet + expect(jwks.keys).toHaveLength(2) + expect((jwks.keys[0] as Record).kid).toBe( 'cb770052-bdd3-4f5e-8d6f-8836046b7c93', ) - expect((result.data!.jwks!.keys[1] as Record).kid).toBe( + expect((jwks.keys[1] as Record).kid).toBe( '9a9933f7-e18f-4d6f-a791-9a992845a27b', ) }) diff --git a/src/core/resolve-env.ts b/src/core/resolve-env.ts index a27ae4f..28af954 100644 --- a/src/core/resolve-env.ts +++ b/src/core/resolve-env.ts @@ -58,16 +58,16 @@ function resolveKeys( } /** - * Parses a JWKS JSON string into a {@link JsonWebKeySet}. - * Accepts both `{ keys: [...] }` and bare `[...]` array formats. - * Returns `null` if the input is missing or malformed. + * Parses an inline JWKS JSON string. Accepts `{ keys: [...] }` or a bare + * array `[...]` (wrapped as `{ keys: [...] }`). Returns `null` for missing + * or malformed input. + * * @internal */ function parseJwks(raw: string | undefined): JsonWebKeySet | null { if (!raw) return null try { const parsed = JSON.parse(raw) - // Support both { keys: [...] } and bare array [...] formats if (Array.isArray(parsed)) return { keys: parsed } if (parsed?.keys && Array.isArray(parsed.keys)) return parsed as JsonWebKeySet @@ -77,12 +77,72 @@ function parseJwks(raw: string | undefined): JsonWebKeySet | null { } } +/** + * Returns true if the hostname is a loopback address — `localhost`, + * `*.localhost`, `127.0.0.0/8`, or `::1`. Browsers treat these as secure + * contexts because traffic never leaves the machine. + * + * @internal + */ +function isLoopbackHost(hostname: string): boolean { + if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true + // URL.hostname keeps the brackets for IPv6 literals (e.g. "[::1]"). + if (hostname === '[::1]') return true + if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) return true + return false +} + +/** + * Parses a JWKS endpoint URL. `https://` is always accepted. Plain `http://` + * is accepted only for loopback hosts (`localhost`, `127.0.0.0/8`, `::1`) so + * the Supabase CLI flow works against `http://localhost:54321`. For any + * other host, http is rejected: a MITM on the JWKS fetch could swap in an + * attacker-controlled key and forge JWTs that pass verification. Returns + * `null` for missing or malformed input. + * + * @internal + */ +function parseJwksUrl(raw: string | undefined): URL | null { + if (!raw) return null + const trimmed = raw.trim() + try { + const url = new URL(trimmed) + if (url.protocol === 'https:') return url + if (url.protocol === 'http:' && isLoopbackHost(url.hostname)) return url + return null + } catch { + return null + } +} + +/** + * Resolves the JWKS source from `SUPABASE_JWKS` (inline JSON) or + * `SUPABASE_JWKS_URL` (https endpoint). `SUPABASE_JWKS` wins when set; + * `SUPABASE_JWKS_URL` is only consulted if `SUPABASE_JWKS` is absent. Each + * variable is treated as authoritative — if set but malformed, the result is + * `null` and the other variable is *not* consulted as a fallback. + * + * @internal + */ +function resolveJwks(): JsonWebKeySet | URL | null { + const rawJwks = getEnvVar('SUPABASE_JWKS') + if (rawJwks && rawJwks.trim()) { + return parseJwks(rawJwks) + } + const rawJwksUrl = getEnvVar('SUPABASE_JWKS_URL') + if (rawJwksUrl && rawJwksUrl.trim()) { + return parseJwksUrl(rawJwksUrl) + } + return null +} + /** * Resolves Supabase environment configuration from runtime environment variables. * * Reads `SUPABASE_URL`, keys (`SUPABASE_PUBLISHABLE_KEYS` / `SUPABASE_SECRET_KEYS`), - * and `SUPABASE_JWKS`. Works across Deno, Node.js, and Bun. For Cloudflare Workers, - * use `overrides` or enable node-compat. + * and the JWKS source (`SUPABASE_JWKS` for inline keys, or `SUPABASE_JWKS_URL` + * for a remote endpoint). Works across Deno, Node.js, and Bun. For Cloudflare + * Workers, use `overrides` or enable node-compat. * * @param overrides - Partial values that take precedence over env vars. * @returns `{ data: SupabaseEnv, error: null }` on success, `{ data: null, error: EnvError }` on failure. @@ -116,7 +176,7 @@ export function resolveEnv( secretKeys: overrides?.secretKeys ?? resolveKeys('SUPABASE_SECRET_KEY', 'SUPABASE_SECRET_KEYS'), - jwks: overrides?.jwks ?? parseJwks(getEnvVar('SUPABASE_JWKS')), + jwks: overrides?.jwks ?? resolveJwks(), } return { data, error: null } diff --git a/src/core/verify-credentials.test.ts b/src/core/verify-credentials.test.ts index c5a4410..ef0bffb 100644 --- a/src/core/verify-credentials.test.ts +++ b/src/core/verify-credentials.test.ts @@ -1,5 +1,13 @@ import { exportJWK, generateKeyPair, SignJWT } from 'jose' -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest' import type { Credentials, JsonWebKeySet, SupabaseEnv } from '../types.js' import { verifyCredentials } from './verify-credentials.js' @@ -347,6 +355,156 @@ describe('verifyCredentials', () => { }) }) + describe('user mode with remote JWKS URL', () => { + let privateKey: CryptoKey + let jwks: JsonWebKeySet + let validToken: string + let fetchMock: ReturnType + + beforeAll(async () => { + const keyPair = await generateKeyPair('RS256') + privateKey = keyPair.privateKey + const publicJwk = await exportJWK(keyPair.publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + publicJwk.kid = 'remote-key-1' + jwks = { keys: [publicJwk] } + + validToken = await new SignJWT({ + sub: 'user-remote', + role: 'authenticated', + }) + .setProtectedHeader({ alg: 'RS256', kid: 'remote-key-1' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + }) + + beforeEach(() => { + fetchMock = vi.fn( + async () => + new Response(JSON.stringify(jwks), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('fetches keys from the URL and verifies a valid JWT', async () => { + const creds: Credentials = { token: validToken, apikey: null } + const result = await verifyCredentials(creds, { + auth: 'user', + env: makeEnv({ + jwks: new URL( + 'https://jwks-fetch-success.example/auth/v1/.well-known/jwks.json', + ), + }), + }) + expect(result.error).toBeNull() + expect(result.data!.userClaims!.id).toBe('user-remote') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('reuses the cached resolver for the same URL across requests', async () => { + // Distinct URL so jose's per-resolver cooldown is fresh for this test + const jwksUrl = new URL('https://jwks-cache.example/jwks.json') + const creds: Credentials = { token: validToken, apikey: null } + + const first = await verifyCredentials(creds, { + auth: 'user', + env: makeEnv({ jwks: jwksUrl }), + }) + const second = await verifyCredentials(creds, { + auth: 'user', + env: makeEnv({ jwks: jwksUrl }), + }) + + expect(first.error).toBeNull() + expect(second.error).toBeNull() + // jose's cooldownDuration (default 30s) keeps the second call from re-fetching. + // What we're guarding against is *re-creating* the resolver on every request, + // which would re-fetch every time. + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('rejects an invalid JWT verified against the remote JWKS', async () => { + const creds: Credentials = { token: 'garbage.jwt.token', apikey: null } + const result = await verifyCredentials(creds, { + auth: 'user', + env: makeEnv({ + jwks: new URL('https://jwks-bad-token.example/jwks.json'), + }), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + }) + + it('rejects when the remote JWKS endpoint fails', async () => { + fetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 })) + const creds: Credentials = { token: validToken, apikey: null } + const result = await verifyCredentials(creds, { + auth: 'user', + env: makeEnv({ + jwks: new URL('https://jwks-server-error.example/jwks.json'), + }), + }) + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + }) + + it('replaces the cached resolver when the URL changes', async () => { + // Second tenant with its own keypair. The resolver cache is single-slot + // keyed by URL string; if a refactor breaks that comparison, the second + // verify would (incorrectly) reuse the first URL's resolver and fail. + const keyPairB = await generateKeyPair('RS256') + const publicJwkB = await exportJWK(keyPairB.publicKey) + publicJwkB.alg = 'RS256' + publicJwkB.use = 'sig' + publicJwkB.kid = 'remote-key-b' + const jwksB: JsonWebKeySet = { keys: [publicJwkB] } + const tokenB = await new SignJWT({ + sub: 'user-remote-b', + role: 'authenticated', + }) + .setProtectedHeader({ alg: 'RS256', kid: 'remote-key-b' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(keyPairB.privateKey) + + const urlA = new URL('https://jwks-switch-a.example/jwks.json') + const urlB = new URL('https://jwks-switch-b.example/jwks.json') + + fetchMock.mockImplementation(async (input: URL | string) => { + const href = input instanceof URL ? input.href : String(input) + const body = href === urlB.href ? jwksB : jwks + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) + + const a = await verifyCredentials( + { token: validToken, apikey: null }, + { auth: 'user', env: makeEnv({ jwks: urlA }) }, + ) + const b = await verifyCredentials( + { token: tokenB, apikey: null }, + { auth: 'user', env: makeEnv({ jwks: urlB }) }, + ) + + expect(a.error).toBeNull() + expect(a.data!.userClaims!.id).toBe('user-remote') + expect(b.error).toBeNull() + expect(b.data!.userClaims!.id).toBe('user-remote-b') + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + }) + describe('parseAuthMode edge cases', () => { it('treats trailing colon as bare mode (default key)', async () => { const creds: Credentials = { diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index c91f07e..5c2d1e6 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -1,4 +1,9 @@ -import { createLocalJWKSet, jwtVerify } from 'jose' +import { + createLocalJWKSet, + createRemoteJWKSet, + jwtVerify, + type JWTVerifyGetKey, +} from 'jose' import { AuthError, Errors, InvalidCredentialsError } from '../errors.js' import type { @@ -6,6 +11,7 @@ import type { AuthModeWithKey, AuthResult, Credentials, + JsonWebKeySet, JWTClaims, SupabaseEnv, UserClaims, @@ -85,6 +91,30 @@ function jwtClaimsToUserClaims(jwtClaims: JWTClaims): UserClaims { const INVALID = Symbol('invalid') +let remoteJwksResolver: { url: string; resolver: JWTVerifyGetKey } | undefined = + undefined + +/** + * Returns a key resolver for the given JWKS source. + * + * For a {@link URL}, the underlying `createRemoteJWKSet` resolver is cached + * across requests so `jose`'s built-in cooldown / max-age caching is + * preserved. Local JWKS objects are wrapped on every call — they're trivially + * cheap and the object identity may change across requests. + * + * @internal + */ +function getJwksResolver(jwks: JsonWebKeySet | URL): JWTVerifyGetKey { + if (jwks instanceof URL) { + const url = jwks.toString() + if (remoteJwksResolver?.url !== url) { + remoteJwksResolver = { url, resolver: createRemoteJWKSet(jwks) } + } + return remoteJwksResolver.resolver + } + return createLocalJWKSet(jwks) +} + /** * Attempts to authenticate credentials against a single auth mode. * @@ -180,7 +210,7 @@ async function tryMode( if (!credentials.token) return null if (!env.jwks) return null try { - const jwkSet = createLocalJWKSet(env.jwks) + const jwkSet = getJwksResolver(env.jwks) const { payload } = await jwtVerify(credentials.token, jwkSet) if (typeof payload.sub !== 'string') { return INVALID diff --git a/src/types.ts b/src/types.ts index 1b8371e..438e11e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,11 +85,22 @@ export interface SupabaseEnv { secretKeys: Record /** - * JSON Web Key Set used for JWT verification. Sourced from `SUPABASE_JWKS`. - * Accepts both `{ keys: [...] }` and bare `[...]` array formats. + * JWKS source used for JWT verification. + * + * Sourced from one of (in priority order): + * - `SUPABASE_JWKS` — inline JSON. Resolves to a {@link JsonWebKeySet}. + * - `SUPABASE_JWKS_URL` — remote endpoint. Resolves to a {@link URL}; keys + * are fetched lazily and cached in memory (cooldown / max-age handled by + * `jose`). `https://` is always accepted; plain `http://` is accepted + * only for loopback hosts (`localhost`, `127.0.0.0/8`, `::1`) to support + * the Supabase CLI. Any other `http://` URL is rejected to prevent MITM + * swap-in of a forged signing key. + * * `null` when no JWKS is configured (JWT verification will be unavailable). + * Each env var is authoritative when set: a malformed value resolves to + * `null` rather than falling through to the other variable. */ - jwks: JsonWebKeySet | null + jwks: JsonWebKeySet | URL | null } /**