diff --git a/jsr.json b/jsr.json index 4507190..6bdb95e 100644 --- a/jsr.json +++ b/jsr.json @@ -8,17 +8,14 @@ "./adapters/hono": "./src/adapters/hono/index.ts", "./adapters/h3": "./src/adapters/h3/index.ts", "./adapters/elysia": "./src/adapters/elysia/index.ts", - "./adapters/nestjs": "./src/adapters/nestjs/index.ts" + "./adapters/nestjs": "./src/adapters/nestjs/index.ts", + "./middleware/client": "./src/middleware/client/index.ts", + "./middleware/admin-client": "./src/middleware/admin-client/index.ts", + "./middleware/postgres": "./src/middleware/postgres/index.ts", + "./middleware/claims": "./src/middleware/claims/index.ts" }, "publish": { - "include": [ - "src/**/*.ts", - "README.md", - "LICENSE" - ], - "exclude": [ - "src/**/*.test.ts", - "src/**/*.spec.ts" - ] + "include": ["src/**/*.ts", "README.md", "LICENSE"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] } } diff --git a/package.json b/package.json index 2b56f77..4cd6eed 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,46 @@ "default": "./dist/adapters/nestjs/index.cjs" } }, + "./middleware/client": { + "import": { + "types": "./dist/middleware/client/index.d.mts", + "default": "./dist/middleware/client/index.mjs" + }, + "require": { + "types": "./dist/middleware/client/index.d.cts", + "default": "./dist/middleware/client/index.cjs" + } + }, + "./middleware/admin-client": { + "import": { + "types": "./dist/middleware/admin-client/index.d.mts", + "default": "./dist/middleware/admin-client/index.mjs" + }, + "require": { + "types": "./dist/middleware/admin-client/index.d.cts", + "default": "./dist/middleware/admin-client/index.cjs" + } + }, + "./middleware/postgres": { + "import": { + "types": "./dist/middleware/postgres/index.d.mts", + "default": "./dist/middleware/postgres/index.mjs" + }, + "require": { + "types": "./dist/middleware/postgres/index.d.cts", + "default": "./dist/middleware/postgres/index.cjs" + } + }, + "./middleware/claims": { + "import": { + "types": "./dist/middleware/claims/index.d.mts", + "default": "./dist/middleware/claims/index.mjs" + }, + "require": { + "types": "./dist/middleware/claims/index.d.cts", + "default": "./dist/middleware/claims/index.cjs" + } + }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", @@ -129,7 +169,8 @@ "@supabase/supabase-js": "^2.0.0", "elysia": "^1.4.0", "h3": "^2.0.0", - "hono": "^4.0.0" + "hono": "^4.0.0", + "pg": "^8.0.0" }, "peerDependenciesMeta": { "@nestjs/common": { @@ -143,6 +184,9 @@ }, "elysia": { "optional": true + }, + "pg": { + "optional": true } }, "devDependencies": { @@ -157,6 +201,7 @@ "@supabase/supabase-js": "^2.105.4", "@swc/core": "^1.15.33", "@types/node": "^26.0.1", + "@types/pg": "^8.11.0", "@types/supertest": "^7.2.0", "elysia": "^1.4.0", "eslint": "^10.0.2", @@ -177,6 +222,7 @@ "vitest": "^4.1.0" }, "dependencies": { + "@supabase/middleware": "^0.3.0", "jose": "^6.2.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1943c15..8b92054 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,15 @@ importers: .: dependencies: + '@supabase/middleware': + specifier: ^0.3.0 + version: 0.3.0 jose: specifier: ^6.2.0 version: 6.2.0 + pg: + specifier: ^8.0.0 + version: 8.22.0 devDependencies: '@arethetypeswrong/cli': specifier: ^0.18.4 @@ -48,6 +54,9 @@ importers: '@types/node': specifier: ^26.0.1 version: 26.0.1 + '@types/pg': + specifier: ^8.11.0 + version: 8.20.0 '@types/supertest': specifier: ^7.2.0 version: 7.2.0 @@ -851,6 +860,10 @@ packages: resolution: {integrity: sha512-ADIkJYH5w7HbnGVAAlCbyKoLF5QdfyezBLfYXpUqhxZOacK6YepOvnP/8p4p+50bhTPWp6VhDxu19KO7e/qU2g==} engines: {node: '>=20.0.0'} + '@supabase/middleware@0.3.0': + resolution: {integrity: sha512-JN+dUr7Fyx96jfCUEXpzPEIpmkkogxHfII+fx7wWIiAUFI8C0lObDlzKIqrSVEKiFEK6CMsHWcqRarBQ8azCtw==} + engines: {node: '>=22'} + '@supabase/phoenix@0.4.2': resolution: {integrity: sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==} @@ -1009,6 +1022,9 @@ packages: '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/superagent@8.1.9': resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} @@ -2003,8 +2019,8 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.18: - resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2091,6 +2107,40 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2112,10 +2162,26 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true - postcss@8.5.26: - resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2358,6 +2424,9 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -2681,6 +2750,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3336,6 +3409,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@supabase/middleware@0.3.0': + dependencies: + std-env: 4.2.0 + '@supabase/phoenix@0.4.2': {} '@supabase/postgrest-js@2.106.0': @@ -3467,6 +3544,12 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/pg@8.20.0': + dependencies: + '@types/node': 25.3.0 + pg-protocol: 1.15.0 + pg-types: 2.2.0 + '@types/superagent@8.1.9': dependencies: '@types/cookiejar': 2.1.5 @@ -4487,7 +4570,7 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.18: {} + nanoid@3.3.17: {} natural-compare@1.4.0: {} @@ -4564,6 +4647,41 @@ snapshots: pathe@2.0.3: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -4590,12 +4708,22 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.2.0 - postcss@8.5.26: + postcss@8.5.25: dependencies: - nanoid: 3.3.18 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} prettier@3.8.1: {} @@ -4861,6 +4989,8 @@ snapshots: std-env@4.1.0: {} + std-env@4.2.0: {} + streamsearch@1.1.0: {} string-width@4.2.3: @@ -5090,7 +5220,7 @@ snapshots: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 - postcss: 8.5.26 + postcss: 8.5.25 rollup: 4.62.4 tinyglobby: 0.2.17 optionalDependencies: @@ -5147,6 +5277,8 @@ snapshots: wrappy@1.0.2: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yaml@2.8.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d474d52..5c65c3b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ minimumReleaseAgeExclude: - '@esbuild/*' blockExoticSubdeps: true allowBuilds: + '@supabase/middleware': true '@nestjs/core': false '@swc/core': false esbuild: false diff --git a/src/core/resolve-env.ts b/src/core/resolve-env.ts index 7d4bcaa..ac6f482 100644 --- a/src/core/resolve-env.ts +++ b/src/core/resolve-env.ts @@ -126,7 +126,7 @@ function parseJwksUrl(raw: string | undefined): URL | null { * * @internal */ -function resolveJwks(): JSONWebKeySet | URL | null { +export function resolveJwks(): JSONWebKeySet | URL | null { const rawJwks = getEnvVar('SUPABASE_JWKS') if (rawJwks && rawJwks.trim()) { return parseJwks(rawJwks) diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index 3f2f735..085f31f 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -1,27 +1,17 @@ -import { - createLocalJWKSet, - createRemoteJWKSet, - decodeProtectedHeader, - importJWK, - JSONWebKeySet, - JWTPayload, - jwtVerify, - type JWTVerifyGetKey, -} from 'jose' - import { AuthError, Errors, InvalidCredentialsError } from '../errors.js' import type { AuthMode, AuthModeWithKey, AuthResult, Credentials, - JWTClaims, SupabaseEnv, - UserClaims, } from '../types.js' import { resolveEnv } from './resolve-env.js' import { resolveAuthOption } from './utils/deprecation.js' import { timingSafeEqual } from './utils/timing-safe-equal.js' +import { verifyUserJwt } from './verify-user-jwt.js' + +export type { JwksResolver } from './verify-user-jwt.js' /** * Options for {@link verifyCredentials}. @@ -79,63 +69,8 @@ function parseAuthMode(mode: AuthModeWithKey): { return { base, keyName } } -/** - * Converts raw {@link JWTClaims} (snake_case) to a normalized {@link UserClaims} (camelCase). - * @internal - */ -function jwtClaimsToUserClaims(jwtClaims: JWTClaims): UserClaims { - return { - id: jwtClaims.sub, - role: jwtClaims.role, - email: jwtClaims.email, - appMetadata: jwtClaims.app_metadata, - userMetadata: jwtClaims.user_metadata, - } -} - const INVALID = Symbol('invalid') -/** - * A JWKS key resolver with an accessor for the cached key set. - * @category Primitives - */ -export type JwksResolver = JWTVerifyGetKey & { - jwks: () => JSONWebKeySet | undefined -} -let remoteJwksResolver: { url: string; resolver: JwksResolver } | 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): JwksResolver { - if (jwks instanceof URL) { - const url = jwks.toString() - if (remoteJwksResolver?.url !== url) { - remoteJwksResolver = { url, resolver: createRemoteJWKSet(jwks) } - } - return remoteJwksResolver.resolver - } - - const localJwkSet = createLocalJWKSet(jwks) - function localJwtVerifyGetKey(...args: Parameters) { - return localJwkSet(...args) - } - - const localJwksResolver: JwksResolver = Object.assign(localJwtVerifyGetKey, { - jwks: () => jwks, - }) - - return localJwksResolver -} - /** * Attempts to authenticate credentials against a single auth mode. * @@ -235,46 +170,17 @@ async function tryMode( // JWT verification. if (credentials.token.startsWith('sb_')) return null if (!env.jwks) return null - try { - const jwkResolver = getJwksResolver(env.jwks) - const { alg, kid } = decodeProtectedHeader(credentials.token) - if (!alg || !kid) { - return INVALID - } - - let payload: JWTPayload | null = null - - // Symmetric algorithm requires importing the shared secret - if (alg === 'HS256') { - const jwk = jwkResolver - .jwks() - ?.keys.find((key) => key.alg === alg && key.kid === kid) - if (!jwk) { - return INVALID - } - const sharedSecret = await importJWK(jwk, 'HS256') - - const verify = await jwtVerify(credentials.token, sharedSecret) - payload = verify.payload - } else { - const verify = await jwtVerify(credentials.token, jwkResolver) - payload = verify.payload - } - - if (typeof payload.sub !== 'string') { - return INVALID - } - const jwtClaims = payload as unknown as JWTClaims - return { - authMode: 'user', - token: credentials.token, - userClaims: jwtClaimsToUserClaims(jwtClaims), - jwtClaims, - keyName: null, - } - } catch { + const verified = await verifyUserJwt(credentials.token, env.jwks) + if (!verified) { return INVALID } + return { + authMode: 'user', + token: credentials.token, + userClaims: verified.userClaims, + jwtClaims: verified.jwtClaims, + keyName: null, + } } default: diff --git a/src/core/verify-user-jwt.ts b/src/core/verify-user-jwt.ts new file mode 100644 index 0000000..ecc54a3 --- /dev/null +++ b/src/core/verify-user-jwt.ts @@ -0,0 +1,122 @@ +import { + createLocalJWKSet, + createRemoteJWKSet, + decodeProtectedHeader, + importJWK, + JSONWebKeySet, + jwtVerify, + type JWTPayload, + type JWTVerifyGetKey, +} from 'jose' + +import type { JWTClaims, UserClaims } from '../types.js' + +/** + * Converts raw {@link JWTClaims} (snake_case) to a normalized {@link UserClaims} (camelCase). + * @internal + */ +export function jwtClaimsToUserClaims(jwtClaims: JWTClaims): UserClaims { + return { + id: jwtClaims.sub, + role: jwtClaims.role, + email: jwtClaims.email, + appMetadata: jwtClaims.app_metadata, + userMetadata: jwtClaims.user_metadata, + } +} + +/** + * A JWKS key resolver with an accessor for the cached key set. + * @category Primitives + */ +export type JwksResolver = JWTVerifyGetKey & { + jwks: () => JSONWebKeySet | undefined +} + +let remoteJwksResolver: { url: string; resolver: JwksResolver } | 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): JwksResolver { + if (jwks instanceof URL) { + const url = jwks.toString() + if (remoteJwksResolver?.url !== url) { + remoteJwksResolver = { url, resolver: createRemoteJWKSet(jwks) } + } + return remoteJwksResolver.resolver + } + + const localJwkSet = createLocalJWKSet(jwks) + function localJwtVerifyGetKey(...args: Parameters) { + return localJwkSet(...args) + } + + const localJwksResolver: JwksResolver = Object.assign(localJwtVerifyGetKey, { + jwks: () => jwks, + }) + + return localJwksResolver +} + +/** + * Verifies a user JWT against the project JWKS — the single verification core + * shared by `verifyCredentials`'s `user` mode and the `withClaims` middleware. + * + * Handles both asymmetric keys (resolved through the JWKS) and the `HS256` + * shared-secret case (imported from the matching JWK). A payload without a + * string `sub` is rejected — a user token always identifies a subject. + * + * @param token - The bearer token to verify. + * @param jwks - JWKS source: an inline key set or a remote JWKS URL. + * @returns The decoded claims on success, `null` when verification fails. + * + * @internal + */ +export async function verifyUserJwt( + token: string, + jwks: JSONWebKeySet | URL, +): Promise<{ jwtClaims: JWTClaims; userClaims: UserClaims } | null> { + try { + const jwkResolver = getJwksResolver(jwks) + const { alg, kid } = decodeProtectedHeader(token) + if (!alg || !kid) { + return null + } + + let payload: JWTPayload | null = null + + // Symmetric algorithm requires importing the shared secret + if (alg === 'HS256') { + const jwk = jwkResolver + .jwks() + ?.keys.find((key) => key.alg === alg && key.kid === kid) + if (!jwk) { + return null + } + const sharedSecret = await importJWK(jwk, 'HS256') + + const verify = await jwtVerify(token, sharedSecret) + payload = verify.payload + } else { + const verify = await jwtVerify(token, jwkResolver) + payload = verify.payload + } + + if (typeof payload.sub !== 'string') { + return null + } + const jwtClaims = payload as unknown as JWTClaims + return { jwtClaims, userClaims: jwtClaimsToUserClaims(jwtClaims) } + } catch { + return null + } +} diff --git a/src/middleware/admin-client/index.test.ts b/src/middleware/admin-client/index.test.ts new file mode 100644 index 0000000..f752bee --- /dev/null +++ b/src/middleware/admin-client/index.test.ts @@ -0,0 +1,66 @@ +import { pipeline } from '@supabase/middleware' +import { describe, expect, it } from 'vitest' + +import type { SupabaseClient } from '@supabase/supabase-js' + +import { + EnvError, + MissingDefaultSecretKeyError, + MissingSecretKeyError, +} from '../../errors.js' +import { withSupabaseAdminClient } from './index.js' + +const baseEnv = { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, +} + +describe('withSupabaseAdminClient', () => { + it('contributes ctx.supabaseAdmin in a standalone pipeline', async () => { + let seen: SupabaseClient | undefined + const handler = pipeline( + [withSupabaseAdminClient({ env: baseEnv })], + async (_req, ctx) => { + seen = ctx.supabaseAdmin + return Response.json({ ok: true }) + }, + ) + + const res = await handler(new Request('http://localhost')) + expect(res.status).toBe(200) + expect(seen).toBeDefined() + expect(typeof seen!.from).toBe('function') + }) + + it("selects the matched secret key from an upstream withSupabase context's authKeyName", async () => { + const handler = pipeline([withSupabaseAdminClient({ env: baseEnv })], () => + Promise.resolve(Response.json({ ok: true })), + ) + + // authKeyName 'internal' is not in the key set — the throw proves the + // named key is what the middleware asked for. + await expect( + handler(new Request('http://localhost'), { + [Symbol.for('@supabase/middleware:context')]: true, + authMode: 'secret', + authKeyName: 'internal', + } as never), + ).rejects.toMatchObject({ code: MissingSecretKeyError }) + }) + + it('throws EnvError when no secret key exists', async () => { + const handler = pipeline( + [withSupabaseAdminClient({ env: { ...baseEnv, secretKeys: {} } })], + async () => Response.json({ ok: true }), + ) + + await expect( + handler(new Request('http://localhost')), + ).rejects.toMatchObject({ code: MissingDefaultSecretKeyError }) + await expect( + handler(new Request('http://localhost')), + ).rejects.toBeInstanceOf(EnvError) + }) +}) diff --git a/src/middleware/admin-client/index.ts b/src/middleware/admin-client/index.ts new file mode 100644 index 0000000..4ba3f57 --- /dev/null +++ b/src/middleware/admin-client/index.ts @@ -0,0 +1,87 @@ +import { defineMiddleware } from '@supabase/middleware' +import type { Entry } from '@supabase/middleware' +import type { SupabaseClient } from '@supabase/supabase-js' + +import { createAdminClient } from '../../core/create-admin-client.js' +import { CreateSupabaseClientError, EnvError, Errors } from '../../errors.js' +import type { AuthMode, CreateAdminClientOptions } from '../../types.js' + +/** + * Configuration for {@link withSupabaseAdminClient} — the same environment and + * client options `createAdminClient` accepts, minus the per-request auth + * identity (which is read from the upstream context). + * + * @category Middleware + */ +export type WithSupabaseAdminClientConfig = Omit< + CreateAdminClientOptions, + 'auth' +> + +/** Auth keys an upstream `withSupabase` seeds onto the context. @internal */ +interface UpstreamAuth { + authMode?: AuthMode + authKeyName?: string +} + +const base = defineMiddleware< + 'supabaseAdmin', + WithSupabaseAdminClientConfig | void, + Record, + SupabaseClient +>({ + key: 'supabaseAdmin', + run: (config) => async (_req, ctx) => { + const upstream = (ctx ?? {}) as UpstreamAuth + // Under `withSupabase`, use the secret key the request matched; standalone + // (or in other modes), the default secret key. + const keyName = + upstream.authMode === 'secret' ? upstream.authKeyName : undefined + + let supabaseAdmin: SupabaseClient + try { + supabaseAdmin = createAdminClient({ + auth: { keyName }, + env: config?.env, + supabaseOptions: config?.supabaseOptions, + }) + } catch (e) { + throw e instanceof EnvError ? e : Errors[CreateSupabaseClientError]() + } + return { supabaseAdmin } + }, +}) + +/** + * Contributes `ctx.supabaseAdmin` — an admin Supabase client that bypasses + * Row-Level Security, authenticated with a secret key. This is the same + * middleware `withSupabase` composes internally to build its context. + * + * @throws {@link index.EnvError} When `SUPABASE_URL` or the secret key is + * missing — composing wrappers (like `withSupabase`) map this to a 500 + * response; standalone pipelines see it as a thrown error. + * + * @example Standalone pipeline + * ```ts + * import { pipeline } from '@supabase/middleware' + * import { withSupabaseAdminClient } from '@supabase/server/middleware/admin-client' + * + * export default { + * fetch: pipeline([withSupabaseAdminClient()], async (req, ctx) => { + * await ctx.supabaseAdmin.from('audit_log').insert({ action: 'ping' }) + * return Response.json({ ok: true }) + * }), + * } + * ``` + * + * @category Middleware + */ +export function withSupabaseAdminClient( + config?: WithSupabaseAdminClientConfig, +): Entry<'supabaseAdmin', Record, SupabaseClient> { + return base(config) as unknown as Entry< + 'supabaseAdmin', + Record, + SupabaseClient + > +} diff --git a/src/middleware/claims/index.test.ts b/src/middleware/claims/index.test.ts new file mode 100644 index 0000000..728216e --- /dev/null +++ b/src/middleware/claims/index.test.ts @@ -0,0 +1,140 @@ +import { exportJWK, generateKeyPair, generateSecret, SignJWT } from 'jose' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import type { JSONWebKeySet } from 'jose' + +import { InvalidCredentialsError } from '../../errors.js' +import { withClaims } from './index.js' + +describe('withClaims', () => { + let jwks: JSONWebKeySet + let rsToken: string + let hsToken: string + let foreignToken: string + + beforeAll(async () => { + // Asymmetric JWK + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + publicJwk.kid = 'asymmetric-key-id' + + // Symmetric Shared Secret JWK + const jwtSecret = await generateSecret('HS256', { extractable: true }) + const symmetricJwk = await exportJWK(jwtSecret) + symmetricJwk.alg = 'HS256' + symmetricJwk.kid = 'symmetric-shared-secret-key-id' + + jwks = { keys: [publicJwk, symmetricJwk] } + + const signWith = ( + key: CryptoKey | Uint8Array, + alg: string, + kid: string, + ) => + new SignJWT({ sub: 'user-123', role: 'authenticated' }) + .setProtectedHeader({ alg, kid }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(key) + + rsToken = await signWith(privateKey, 'RS256', publicJwk.kid!) + hsToken = await signWith(jwtSecret, 'HS256', symmetricJwk.kid!) + + // Signed by a key that is NOT in the JWKS — verification must fail. + const { privateKey: foreignKey } = await generateKeyPair('RS256') + foreignToken = await signWith(foreignKey, 'RS256', publicJwk.kid!) + }) + + function requestWithToken(token?: string): Request { + return new Request('http://localhost', { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + } + + it('contributes JWKS-verified claims for a valid token', async () => { + for (const token of [() => rsToken, () => hsToken]) { + let seen: unknown + const handler = withClaims({ jwks }, async (_req, ctx) => { + seen = ctx.jwtClaims + return Response.json({ ok: true }) + }) + + const res = await handler(requestWithToken(token())) + expect(res.status).toBe(200) + expect(seen).toMatchObject({ sub: 'user-123', role: 'authenticated' }) + } + }) + + it('short-circuits 401 for a token signed by an unknown key', async () => { + const handler = withClaims({ jwks }, async () => + Response.json({ ok: true }), + ) + + const res = await handler(requestWithToken(foreignToken)) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.code).toBe(InvalidCredentialsError) + }) + + it('short-circuits 401 for a malformed token', async () => { + const handler = withClaims({ jwks }, async () => + Response.json({ ok: true }), + ) + + const res = await handler(requestWithToken('not-a-jwt')) + expect(res.status).toBe(401) + }) + + it('contributes null when no Authorization header is present', async () => { + let seen: unknown = 'unset' + const handler = withClaims({ jwks }, async (_req, ctx) => { + seen = ctx.jwtClaims + return Response.json({ ok: true }) + }) + + // Called bare, the way a runtime invokes a fetch entry — no prerequisites. + const res = await handler(requestWithToken()) + expect(res.status).toBe(200) + expect(seen).toBeNull() + }) + + it('contributes null for an sb_* apikey in the Authorization header', async () => { + let seen: unknown = 'unset' + const handler = withClaims({ jwks }, async (_req, ctx) => { + seen = ctx.jwtClaims + return Response.json({ ok: true }) + }) + + const apikeys = [ + 'sb_publishable_xyz', + 'sb_secret_xyz', + 'sb_temp_xyz', + 'sb_something', + ] + + for (const apikey of apikeys) { + seen = 'unset' + + const res = await handler(requestWithToken(apikey)) + expect(res.status).toBe(200) + expect(seen).toBeNull() + } + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('short-circuits 500 when a token is present but no JWKS is configured', async () => { + vi.stubEnv('SUPABASE_JWKS', '') + vi.stubEnv('SUPABASE_JWKS_URL', '') + const handler = withClaims(async () => Response.json({ ok: true })) + + const res = await handler(requestWithToken(rsToken)) + expect(res.status).toBe(500) + const body = await res.json() + expect(body.message).toContain('JWKS') + }) +}) diff --git a/src/middleware/claims/index.ts b/src/middleware/claims/index.ts new file mode 100644 index 0000000..9fd8cbf --- /dev/null +++ b/src/middleware/claims/index.ts @@ -0,0 +1,102 @@ +import { defineMiddleware } from '@supabase/middleware' +import type { Middleware } from '@supabase/middleware' +import type { JSONWebKeySet } from 'jose' + +import { extractCredentials } from '../../core/extract-credentials.js' +import { resolveJwks } from '../../core/resolve-env.js' +import { verifyUserJwt } from '../../core/verify-user-jwt.js' +import { EnvGenericError, InvalidCredentialsError } from '../../errors.js' +import type { JWTClaims } from '../../types.js' + +/** + * Configuration for {@link withClaims}. + * + * @category Middleware + */ +export interface WithClaimsConfig { + /** + * JWKS source used to verify tokens: an inline key set or a remote JWKS + * URL. Defaults to `SUPABASE_JWKS` (inline JSON) or `SUPABASE_JWKS_URL` + * (https endpoint) from the environment. + */ + jwks?: JSONWebKeySet | URL +} + +/** + * Contributes `ctx.jwtClaims` by verifying the caller's Bearer token against + * the project JWKS — the same verification core `withSupabase` uses for its + * `user` auth mode. + * + * Use this when composing a standalone `pipeline([...], handler)` that is + * **not** wrapped by `withSupabase` — for example a Supabase-agnostic Edge + * Function that still wants the caller's verified claims available to a + * downstream middleware such as `withPostgres`. Inside `withSupabase`, the + * context already carries `jwtClaims`, so `withClaims` is unnecessary. + * + * Behavior: + * - No `Authorization: Bearer` token (or an `sb_*` API key in that position) + * → contributes `null`; the request proceeds as anonymous. + * - Token present but invalid → short-circuits with a 401 JSON response + * (`{ message, code }`, matching `withSupabase`'s error shape). + * - Token present but no JWKS configured → short-circuits with a 500 — + * verification is not optional; there is no decode-only mode. + * + * @example Standalone pipeline + * ```ts + * import { pipeline } from '@supabase/middleware' + * import { withClaims } from '@supabase/server/middleware/claims' + * import { withPostgres } from '@supabase/server/middleware/postgres' + * + * export default { + * fetch: pipeline([withClaims(), withPostgres()], async (req, ctx) => { + * const rows = await ctx.postgres.query('select id, title from posts') + * return Response.json({ rows, caller: ctx.jwtClaims?.sub ?? 'anon' }) + * }), + * } + * ``` + * + * @category Middleware + */ +export const withClaims: Middleware< + 'jwtClaims', + WithClaimsConfig | void, + Record, + JWTClaims | null +> = defineMiddleware< + 'jwtClaims', + WithClaimsConfig | void, + Record, + JWTClaims | null +>({ + key: 'jwtClaims', + run: (config) => async (req) => { + const { token } = extractCredentials(req) + // `sb_*` secrets ride the Authorization header alongside the apikey + // header — they are API keys, not user JWTs. + if (!token || token.startsWith('sb_')) { + return { jwtClaims: null } + } + + const jwks = config?.jwks ?? resolveJwks() + if (!jwks) { + return Response.json( + { + message: + 'A JWKS source is required to verify claims. Set SUPABASE_JWKS or SUPABASE_JWKS_URL, or pass `jwks` to withClaims.', + code: EnvGenericError, + }, + { status: 500 }, + ) + } + + const verified = await verifyUserJwt(token, jwks) + if (!verified) { + return Response.json( + { message: 'Invalid credentials', code: InvalidCredentialsError }, + { status: 401 }, + ) + } + + return { jwtClaims: verified.jwtClaims } + }, +}) diff --git a/src/middleware/client/index.test.ts b/src/middleware/client/index.test.ts new file mode 100644 index 0000000..22beaf5 --- /dev/null +++ b/src/middleware/client/index.test.ts @@ -0,0 +1,76 @@ +import { pipeline } from '@supabase/middleware' +import { describe, expect, it } from 'vitest' + +import type { SupabaseClient } from '@supabase/supabase-js' + +import { + EnvError, + MissingPublishableKeyError, + MissingSupabaseURLError, +} from '../../errors.js' +import { withSupabaseClient } from './index.js' + +const baseEnv = { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz', web: 'sb_publishable_web' }, + secretKeys: { default: 'sb_secret_xyz' }, + jwks: null, +} + +describe('withSupabaseClient', () => { + it('contributes ctx.supabase in a standalone pipeline', async () => { + let seen: SupabaseClient | undefined + const handler = pipeline( + [withSupabaseClient({ env: baseEnv })], + async (_req, ctx) => { + seen = ctx.supabase + return Response.json({ ok: true }) + }, + ) + + const res = await handler(new Request('http://localhost')) + expect(res.status).toBe(200) + expect(seen).toBeDefined() + expect(typeof seen!.from).toBe('function') + }) + + it("selects the matched publishable key from an upstream withSupabase context's authKeyName", async () => { + const handler = pipeline( + [ + withSupabaseClient({ + env: { + ...baseEnv, + publishableKeys: { default: 'sb_publishable_xyz' }, + }, + }), + ], + async () => Response.json({ ok: true }), + ) + + // authKeyName 'web' is not in the key set — the throw proves the named + // key is what the middleware asked for. + await expect( + handler(new Request('http://localhost'), { + [Symbol.for('@supabase/middleware:context')]: true, + authMode: 'publishable', + authKeyName: 'web', + } as never), + ).rejects.toMatchObject({ code: MissingPublishableKeyError }) + }) + + it('throws EnvError when SUPABASE_URL is missing', async () => { + const handler = pipeline( + [withSupabaseClient({ env: { ...baseEnv, url: '' } })], + async () => Response.json({ ok: true }), + ) + + await expect( + handler(new Request('http://localhost')), + ).rejects.toMatchObject({ + code: MissingSupabaseURLError, + }) + await expect( + handler(new Request('http://localhost')), + ).rejects.toBeInstanceOf(EnvError) + }) +}) diff --git a/src/middleware/client/index.ts b/src/middleware/client/index.ts new file mode 100644 index 0000000..44a8e4b --- /dev/null +++ b/src/middleware/client/index.ts @@ -0,0 +1,99 @@ +import { defineMiddleware } from '@supabase/middleware' +import type { Entry } from '@supabase/middleware' +import type { SupabaseClient } from '@supabase/supabase-js' + +import { createContextClient } from '../../core/create-context-client.js' +import { extractCredentials } from '../../core/extract-credentials.js' +import { CreateSupabaseClientError, EnvError, Errors } from '../../errors.js' +import type { AuthMode, CreateContextClientOptions } from '../../types.js' + +/** + * Configuration for {@link withSupabaseClient} — the same environment and + * client options `createContextClient` accepts, minus the per-request auth + * identity (which is read from the request and the upstream context). + * + * @category Middleware + */ +export type WithSupabaseClientConfig = Omit + +/** Auth keys an upstream `withSupabase` seeds onto the context. @internal */ +interface UpstreamAuth { + authMode?: AuthMode + authKeyName?: string +} + +const base = defineMiddleware< + 'supabase', + WithSupabaseClientConfig | void, + Record, + SupabaseClient +>({ + key: 'supabase', + run: (config) => async (req, ctx) => { + const upstream = (ctx ?? {}) as UpstreamAuth + const { token: bearer } = extractCredentials(req) + // `sb_*` secrets ride the Authorization header alongside the apikey + // header — never attach them as a user token. + const rawToken = bearer && !bearer.startsWith('sb_') ? bearer : undefined + // Under `withSupabase`, mirror verified auth exactly: the bearer token is + // attached only when it was verified (`user` mode), and the publishable + // key is the one the request matched. Standalone, attach the raw bearer — + // PostgREST verifies it — and use the default publishable key. + const token = + upstream.authMode === undefined || upstream.authMode === 'user' + ? rawToken + : undefined + const keyName = + upstream.authMode === 'publishable' ? upstream.authKeyName : undefined + + let supabase: SupabaseClient + try { + supabase = createContextClient({ + auth: { token, keyName }, + env: config?.env, + supabaseOptions: config?.supabaseOptions, + }) + } catch (e) { + throw e instanceof EnvError ? e : Errors[CreateSupabaseClientError]() + } + return { supabase } + }, +}) + +/** + * Contributes `ctx.supabase` — a Supabase client scoped to the caller's + * identity, so Row-Level Security policies apply. This is the same middleware + * `withSupabase` composes internally to build its context. + * + * Standalone, the caller's Bearer token (when present) is attached unverified — + * PostgREST verifies it on every query. Compose {@link claims.withClaims} + * upstream when the pipeline itself needs verified claims. + * + * @throws {@link index.EnvError} When `SUPABASE_URL` or the publishable key is + * missing — composing wrappers (like `withSupabase`) map this to a 500 + * response; standalone pipelines see it as a thrown error. + * + * @example Standalone pipeline + * ```ts + * import { pipeline } from '@supabase/middleware' + * import { withSupabaseClient } from '@supabase/server/middleware/client' + * + * export default { + * fetch: pipeline([withSupabaseClient()], async (req, ctx) => { + * const { data } = await ctx.supabase.from('posts').select('id, title') + * return Response.json(data) + * }), + * } + * ``` + * + * @category Middleware + */ +export function withSupabaseClient( + config?: WithSupabaseClientConfig, +): Entry<'supabase', Record, SupabaseClient> { + return base(config) as unknown as Entry< + 'supabase', + Record, + SupabaseClient + > +} diff --git a/src/middleware/postgres/index.test.ts b/src/middleware/postgres/index.test.ts new file mode 100644 index 0000000..a7757b3 --- /dev/null +++ b/src/middleware/postgres/index.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Shared mock state, hoisted so the vi.mock factory can close over it. +const h = vi.hoisted(() => { + const issued: string[] = [] + const clientQuery = vi.fn(async (text: string) => { + issued.push(text) + return { rows: [{ ok: true }] } + }) + const release = vi.fn() + const connect = vi.fn(async () => ({ query: clientQuery, release })) + return { issued, clientQuery, release, connect } +}) + +vi.mock('pg', () => { + class Pool { + connect = h.connect + } + return { default: { Pool }, Pool } +}) + +const { seedContext } = await import('@supabase/middleware') +const { withPostgres } = await import('./index.js') + +describe('withPostgres', () => { + beforeEach(() => { + h.issued.length = 0 + h.clientQuery.mockClear() + h.connect.mockClear() + h.release.mockClear() + // The connection-string default reads the importable getEnv, which falls + // back to the host env in tests. + vi.stubEnv('SUPABASE_DB_URL', 'postgres://localhost/test') + }) + afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + }) + + it('returns 500 when no connection string is available', async () => { + vi.stubEnv('SUPABASE_DB_URL', undefined) + const handler = withPostgres({ connectionString: undefined }, async () => + Response.json({ ok: true }), + ) + + const res = await handler(new Request('http://localhost'), { + ...seedContext(), + jwtClaims: null, + }) + + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ error: 'no SUPABASE_DB_URL' }) + }) + + it('injects the caller claims and drops to the authenticated role', async () => { + const handler = withPostgres(async (_req, ctx) => { + await ctx.postgres.query('select 1') + return Response.json({ ok: true }) + }) + + await handler(new Request('http://localhost'), { + ...seedContext(), + jwtClaims: { sub: 'u1', role: 'authenticated' }, + }) + + expect(h.issued).toEqual([ + 'begin', + `select set_config('request.jwt.claims', $1, true)`, + 'set local role authenticated', + 'select 1', + 'commit', + ]) + expect(h.release).toHaveBeenCalled() + }) + + it('clamps any non-authenticated role (incl. a forged service_role) to anon', async () => { + const handler = withPostgres(async (_req, ctx) => { + await ctx.postgres.query('select 1') + return Response.json({ ok: true }) + }) + + await handler(new Request('http://localhost'), { + ...seedContext(), + jwtClaims: { sub: 'attacker', role: 'service_role' }, + }) + + expect(h.issued).toContain('set local role anon') + expect(h.issued).not.toContain('set local role service_role') + }) + + it('rolls back when the query throws', async () => { + // begin, set_config, set role succeed; the user query throws. + h.clientQuery + .mockImplementationOnce(async (t: string) => { + h.issued.push(t) + return { rows: [] } + }) + .mockImplementationOnce(async (t: string) => { + h.issued.push(t) + return { rows: [] } + }) + .mockImplementationOnce(async (t: string) => { + h.issued.push(t) + return { rows: [] } + }) + .mockImplementationOnce(async () => { + throw new Error('boom') + }) + + const handler = withPostgres(async (_req, ctx) => { + await ctx.postgres.query('select bad') + return Response.json({ ok: true }) + }) + + await expect( + handler(new Request('http://localhost'), { + ...seedContext(), + jwtClaims: { role: 'authenticated' }, + }), + ).rejects.toThrow('boom') + + expect(h.issued).toContain('rollback') + expect(h.release).toHaveBeenCalled() + }) + + it('appends a grants hint to permission-denied (42501) errors', async () => { + // begin, set_config, set role succeed; the user query hits missing grants. + h.clientQuery + .mockImplementationOnce(async (t: string) => { + h.issued.push(t) + return { rows: [] } + }) + .mockImplementationOnce(async (t: string) => { + h.issued.push(t) + return { rows: [] } + }) + .mockImplementationOnce(async (t: string) => { + h.issued.push(t) + return { rows: [] } + }) + .mockImplementationOnce(async () => { + const err = new Error('permission denied for table notes') as Error & { + code: string + } + err.code = '42501' + throw err + }) + + const handler = withPostgres(async (_req, ctx) => { + await ctx.postgres.query('select * from notes') + return Response.json({ ok: true }) + }) + + await expect( + handler(new Request('http://localhost'), { + ...seedContext(), + jwtClaims: { role: 'authenticated' }, + }), + ).rejects.toThrow( + /permission denied for table notes \(RLS-scoped queries run as the caller's role 'authenticated'/, + ) + + expect(h.issued).toContain('rollback') + expect(h.release).toHaveBeenCalled() + }) +}) diff --git a/src/middleware/postgres/index.ts b/src/middleware/postgres/index.ts new file mode 100644 index 0000000..8e4e05e --- /dev/null +++ b/src/middleware/postgres/index.ts @@ -0,0 +1,141 @@ +import { defineMiddleware, getEnv } from '@supabase/middleware' +import type { Middleware } from '@supabase/middleware' +import pg from 'pg' + +const { Pool } = pg + +// One pool per process, lazily created (config or SUPABASE_DB_URL). +let pool: pg.Pool | undefined +function getPool(connectionString: string): pg.Pool { + if (!pool) pool = new Pool({ connectionString, max: 4 }) + return pool +} + +/** + * Minimal claims shape {@link withPostgres} needs on the upstream context. + * + * Satisfied both by `withSupabase`'s JWKS-verified `ctx.jwtClaims` and by the + * standalone `withClaims` middleware — `withPostgres` only reads `role` and + * serializes the whole object into `request.jwt.claims`. + */ +interface RequestClaims { + role?: string + [key: string]: unknown +} + +/** + * The `ctx.postgres` client contributed by {@link withPostgres}. + * + * @category Middleware + */ +export interface PostgresApi { + /** Run a query inside the caller's RLS-scoped transaction. */ + query>( + text: string, + params?: unknown[], + ): Promise +} + +/** + * Configuration for {@link withPostgres}. + * + * @category Middleware + */ +export interface WithPostgresConfig { + /** Defaults to `getEnv('SUPABASE_DB_URL')` (from `@supabase/middleware`). */ + connectionString?: string +} + +/** + * Contributes `ctx.postgres` — an RLS-scoped `pg` client, the safe version of + * "authenticate, then query as the user". Every query runs in its own short + * transaction that injects the caller's claims and drops to their role, exactly + * like PostgREST: + * + * ```sql + * begin; + * select set_config('request.jwt.claims', $claims, true); -- auth.uid() resolves + * set local role authenticated; -- RLS now enforces + * + * commit; + * ``` + * + * Everything is transaction-local, so nothing leaks onto the pooled connection. + * + * Reads the caller's claims from `ctx.jwtClaims`, which `withSupabase` already + * populates (JWKS-verified) — so inside `withSupabase` you compose it directly: + * + * ```ts + * withSupabase({ auth: 'user', middleware: [withPostgres()] }, handler) + * ``` + * + * Standalone (no `withSupabase`), pair it with `withClaims` so `ctx.jwtClaims` + * is present before it runs. + * + * > **Table grants.** Queries run as `authenticated` or `anon`, so those + * > roles need explicit table privileges (e.g. `grant select, insert on + * > to authenticated`) in addition to RLS policies. A missing grant + * > fails with `permission denied` (SQLSTATE 42501) before RLS is consulted. + * + * > **Runtime note.** `pg` needs raw TCP, so this runs on Node/Deno (including + * > the Supabase Edge runtime), **not** on Workers-style isolates. + * + * @category Middleware + */ +export const withPostgres: Middleware< + 'postgres', + WithPostgresConfig | void, + { jwtClaims: RequestClaims | null }, + PostgresApi +> = defineMiddleware< + 'postgres', + WithPostgresConfig | void, + { jwtClaims: RequestClaims | null }, + PostgresApi +>({ + key: 'postgres', + run: (config) => async (_req, ctx) => { + const connectionString = + config?.connectionString ?? getEnv('SUPABASE_DB_URL') + if (!connectionString) { + return Response.json({ error: 'no SUPABASE_DB_URL' }, { status: 500 }) + } + + const p = getPool(connectionString) + const claims = ctx.jwtClaims + // Clamp the role — a token can never flip the client into an RLS-bypassing + // role. service_role is deliberately not reachable here. + const role = claims?.role === 'authenticated' ? 'authenticated' : 'anon' + + const api: PostgresApi = { + async query>( + text: string, + params?: unknown[], + ) { + const client = await p.connect() + try { + await client.query('begin') + await client.query( + `select set_config('request.jwt.claims', $1, true)`, + [JSON.stringify(claims ?? {})], + ) + await client.query(`set local role ${role}`) // role is a clamped literal + const res = await client.query(text, params) + await client.query('commit') + return res.rows as T[] + } catch (e) { + await client.query('rollback') + // 42501 insufficient_privilege: the role lacks table grants. + if (e instanceof Error && (e as { code?: string }).code === '42501') { + e.message += ` (RLS-scoped queries run as the caller's role '${role}' — grant that role the table privileges it needs, e.g. "grant select on
to ${role}")` + } + throw e + } finally { + client.release() + } + }, + } + + return { postgres: api } + }, +}) diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index 87fd317..ea260d8 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineMiddleware, getEnv } from '@supabase/middleware' import { _resetAllowDeprecationWarned } from './core/utils/deprecation.js' +import { EnvError } from './errors.js' import { withSupabase } from './with-supabase.js' const baseEnv = { @@ -158,6 +160,226 @@ describe('withSupabase', () => { }) }) + describe('middleware', () => { + it('composes middleware after the Supabase context is established', async () => { + const withFlag = defineMiddleware< + 'flag', + void, + Record, + boolean + >({ + key: 'flag', + run: () => async () => ({ flag: true }), + }) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, middleware: [withFlag()] }, + async (_req, ctx) => + Response.json({ authMode: ctx.authMode, flag: ctx.flag }), + ) + + const res = await handler(new Request('http://localhost')) + const body = await res.json() + expect(body.authMode).toBe('none') + expect(body.flag).toBe(true) + }) + + it('middleware receives the Supabase context at runtime', async () => { + let capturedHasSupabase = false + + const withCapture = defineMiddleware< + 'captured', + void, + Record, + boolean + >({ + key: 'captured', + run: () => async (_req, ctx) => { + capturedHasSupabase = !!(ctx as { supabase?: unknown }).supabase + return { captured: capturedHasSupabase } + }, + }) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, middleware: [withCapture()] }, + async (_, ctx) => Response.json({ captured: ctx.captured }), + ) + + const res = await handler(new Request('http://localhost')) + const body = await res.json() + expect(body.captured).toBe(capturedHasSupabase) + expect(capturedHasSupabase).toBe(true) + }) + + it('middleware can short-circuit before the handler', async () => { + const withBlock = defineMiddleware< + 'blocked', + void, + Record, + true + >({ + key: 'blocked', + run: () => async () => new Response('blocked', { status: 403 }), + }) + + const innerHandler = vi.fn(async () => Response.json({ ok: true })) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, middleware: [withBlock()] }, + innerHandler, + ) + + const res = await handler(new Request('http://localhost')) + expect(res.status).toBe(403) + expect(innerHandler).not.toHaveBeenCalled() + }) + + it('middleware run in array order (first = outermost, runs first on request)', async () => { + const order: string[] = [] + + const withA = defineMiddleware<'a', void, Record, true>({ + key: 'a', + run: () => async () => { + order.push('a') + return { a: true as const } + }, + }) + const withB = defineMiddleware<'b', void, Record, true>({ + key: 'b', + run: () => async () => { + order.push('b') + return { b: true as const } + }, + }) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, middleware: [withA(), withB()] }, + async (_req, ctx) => Response.json({ a: ctx.a, b: ctx.b }), + ) + + const res = await handler(new Request('http://localhost')) + const body = await res.json() + expect(order).toEqual(['a', 'b']) + expect(body).toEqual({ a: true, b: true }) + }) + + it('middleware run in array order with shared ctx dependency', async () => { + const withFirst = defineMiddleware< + 'a', + void, + Record, + string + >({ + key: 'a', + run: () => async () => ({ a: 'http://localhost' as const }), + }) + const withSecond = defineMiddleware<'b', void, { a: string }, URL>({ + key: 'b', + run: () => async (_req, ctx) => { + const url = URL.parse(ctx.a) + url!.pathname = '/supabase' + + return { b: url! } + }, + }) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, middleware: [withFirst(), withSecond()] }, + async (_req, ctx) => Response.json({ a: ctx.a, b: ctx.b }), + ) + + const res = await handler(new Request('http://localhost')) + const body = await res.json() + expect(body).toEqual({ + a: 'http://localhost', + b: 'http://localhost/supabase', + }) + + // Check reverse order must breaks dependency chain + const handlerReverse = withSupabase( + { auth: 'none', env: baseEnv, middleware: [withSecond(), withFirst()] }, + async (_req, ctx) => Response.json({ a: ctx.a, b: ctx.b }), + ) + + expect(handlerReverse(new Request('http://localhost'))).rejects.toThrow( + "Cannot set properties of null (setting 'pathname')", + ) + }) + + it("forwards the host's second fetch argument to getEnv as platform env", async () => { + const withReadEnv = defineMiddleware< + 'bindingValue', + void, + Record, + string | undefined + >({ + key: 'bindingValue', + run: () => async () => ({ + bindingValue: getEnv('WITH_SUPABASE_TEST_BINDING'), + }), + }) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, middleware: [withReadEnv()] }, + async (_req, ctx) => Response.json({ bindingValue: ctx.bindingValue }), + ) + + // Simulate a Workers-style invocation: fetch(request, env). + const res = await handler(new Request('http://localhost'), { + WITH_SUPABASE_TEST_BINDING: 'from-platform', + }) + const body = await res.json() + expect(body).toEqual({ bindingValue: 'from-platform' }) + }) + + it('CORS headers still apply when middleware are present', async () => { + const withNoop = defineMiddleware< + 'noop', + void, + Record, + true + >({ + key: 'noop', + run: () => async () => ({ noop: true as const }), + }) + + const handler = withSupabase( + { auth: 'none', env: baseEnv, middleware: [withNoop()] }, + async () => Response.json({ ok: true }), + ) + + const res = await handler(new Request('http://localhost')) + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*') + }) + }) + + describe('client construction errors', () => { + it('maps client-construction EnvError to a 500 JSON response', async () => { + const handler = withSupabase( + { + auth: 'none', + env: { ...baseEnv, publishableKeys: {} }, + }, + async () => Response.json({ ok: true }), + ) + + const res = await handler(new Request('http://localhost')) + expect(res.status).toBe(500) + const body = await res.json() + expect(body.code).toBe('MISSING_DEFAULT_PUBLISHABLE_KEY') + }) + + it('lets EnvError thrown by the handler propagate instead of mapping it', async () => { + const handler = withSupabase({ auth: 'none', env: baseEnv }, async () => { + throw new EnvError('handler-level env failure') + }) + + await expect(handler(new Request('http://localhost'))).rejects.toThrow( + 'handler-level env failure', + ) + }) + }) + describe('allow → auth deprecation', () => { beforeEach(() => { _resetAllowDeprecationWarned() diff --git a/src/with-supabase.ts b/src/with-supabase.ts index a8dc72e..f428cb4 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -1,6 +1,30 @@ import { addCorsHeaders, buildCorsHeaders, isCorsDisabled } from './cors.js' -import { createSupabaseContext } from './create-supabase-context.js' +import { verifyAuth } from './core/verify-auth.js' +import { AuthError, CreateSupabaseClientError, EnvError } from './errors.js' +import { withSupabaseAdminClient } from './middleware/admin-client/index.js' +import { withSupabaseClient } from './middleware/client/index.js' import type { SupabaseContext, WithSupabaseConfig } from './types.js' +import { seedContext } from '@supabase/middleware' +import type { Entry } from '@supabase/middleware' + +type AnyEntry = Entry +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyHandler = (req: Request, ctx: any) => Promise + +/** + * Accumulate the ctx contributions of a middleware tuple — same logic as + * `pipeline`'s internal `Accumulate`, seeded from `object` (the engine reserves + * no ctx keys; see implementation note below). + */ +type MiddlewareCtx = + Entries extends readonly [ + Entry, + ...infer Rest, + ] + ? Rest extends readonly AnyEntry[] + ? { [P in Key]: Contribution } & MiddlewareCtx + : { [P in Key]: Contribution } + : object /** * Wraps a request handler with Supabase auth, client creation, and CORS handling. @@ -11,7 +35,10 @@ import type { SupabaseContext, WithSupabaseConfig } from './types.js' * * @param config - Auth modes, CORS, and environment overrides. See {@link WithSupabaseConfig}. * @param handler - Receives the `Request` and a fully-initialized {@link SupabaseContext}. - * @returns A `(req: Request) => Promise` fetch handler. + * @returns A fetch handler. The optional second parameter is the host's + * platform argument (a Workers `env`, a Deno `ServeHandlerInfo`) — when the + * runtime supplies one, it is captured as the platform env behind + * `@supabase/middleware`'s `getEnv` for any composed middleware. * * @category Middleware * @@ -19,6 +46,7 @@ import type { SupabaseContext, WithSupabaseConfig } from './types.js' * ```ts * import { withSupabase } from '@supabase/server' * + * // Without middleware — existing API, unchanged. * export default { * fetch: withSupabase({ auth: 'user' }, async (req, ctx) => { * const { data } = await ctx.supabase.rpc('get_my_profile') @@ -28,10 +56,85 @@ import type { SupabaseContext, WithSupabaseConfig } from './types.js' * ``` */ export function withSupabase( - config: WithSupabaseConfig, + config: WithSupabaseConfig & { middleware?: never }, handler: (req: Request, ctx: SupabaseContext) => Promise, -): (req: Request) => Promise { - return async (req: Request) => { +): (req: Request, platformArg?: unknown) => Promise + +/** + * Variant that accepts a `middleware` array — each `withFoo(config)` call + * returns an `Entry` from `@supabase/middleware`. Middleware run **after** + * the Supabase context is established; they receive `ctx.supabase`, + * `ctx.userClaims`, etc. already present and contribute their own typed keys + * on top. (This is the server leg of a Plugin: the package's middleware goes + * here; its client namespace goes in `createClient`'s `plugins` array.) + * + * @example + * ```ts + * import { withSupabase } from '@supabase/server' + * import { withGuestbook } from '@supabase/plugin-guestbook/server' + * import { withRateLimit } from '@supabase/plugin-rate-limit/server' + * + * export default { + * fetch: withSupabase( + * { auth: 'user', middleware: [withRateLimit({ rpm: 100 }), withGuestbook()] }, + * async (req, ctx) => { + * ctx.supabase // from @supabase/server + * ctx.rateLimit // from withRateLimit + * ctx.guestbook // from withGuestbook + * return Response.json(await ctx.guestbook.list()) + * }, + * ), + * } + * ``` + * + * **Type note.** `MiddlewareCtx` accumulates the key contributions of + * the middleware array. Middleware that declare `In` prerequisites on + * Supabase-provided keys (`supabase`, `userClaims`, …) satisfy those at runtime + * (the Supabase context is merged before the middleware run) but not at the + * type level — a full implementation would widen the prerequisite-validation + * seed to include `SupabaseContext`. Ordering and collision checks within the + * middleware array work normally via `@supabase/middleware`'s runtime chain. + */ +export function withSupabase< + Database = unknown, + const Entries extends readonly AnyEntry[] = readonly AnyEntry[], +>( + config: WithSupabaseConfig & { middleware: Entries }, + handler: ( + req: Request, + ctx: SupabaseContext & MiddlewareCtx, + ) => Promise, +): (req: Request, platformArg?: unknown) => Promise + +export function withSupabase( + config: WithSupabaseConfig & { middleware?: readonly AnyEntry[] }, + handler: AnyHandler, +): (req: Request, platformArg?: unknown) => Promise { + // withSupabase runs on the engine: the context clients are the same public + // middleware anyone can compose (`./middleware/client`, + // `./middleware/admin-client`), folded around the user's middleware and + // handler — the same fold as pipeline's reduceRight, but without calling + // pipeline() so we supply the seeded ctx ourselves. + const clientEntries: readonly AnyEntry[] = [ + withSupabaseClient({ + env: config.env, + supabaseOptions: config.supabaseOptions, + }) as AnyEntry, + withSupabaseAdminClient({ + env: config.env, + supabaseOptions: config.supabaseOptions, + }) as AnyEntry, + ] + // The user's middleware and handler fold once at wrap time. + const userComposed = (config.middleware ?? []).reduceRight( + (h, entry) => entry(h), + handler, + ) + + return async (req: Request, platformArg?: unknown) => { + const corsHeaders = () => + !isCorsDisabled(config.cors) ? buildCorsHeaders(config.cors) : {} + if (!isCorsDisabled(config.cors) && req.method === 'OPTIONS') { return new Response(null, { status: 204, @@ -39,23 +142,66 @@ export function withSupabase( }) } - const { data: ctx, error } = await createSupabaseContext( - req, - config, - ) + const { data: auth, error } = await verifyAuth(req, { + auth: config.auth, + allow: config.allow, + env: config.env, + }) if (error) { return Response.json( { message: error.message, code: error.code }, - { - status: error.status, - headers: !isCorsDisabled(config.cors) - ? buildCorsHeaders(config.cors) - : {}, - }, + { status: error.status, headers: corsHeaders() }, ) } - const response = await handler(req, ctx) + // Track whether the request has moved past client construction: only + // failures from the two client entries map to the historical JSON error + // responses — user middleware and handler throws propagate unchanged, + // exactly as before the rewrite. + let inClientPhase = true + const markUserPhase: AnyHandler = (r, ctx) => { + inClientPhase = false + return userComposed(r, ctx) + } + const composed = clientEntries.reduceRight( + (h, entry) => entry(h), + markUserPhase, + ) + + let response: Response + try { + // seedContext() stamps the engine's context marker so middleware entries + // recognise this as an upstream context, and captures the host's second + // fetch argument (a Workers `env`, a Deno `ServeHandlerInfo`) as the + // platform env behind the engine's importable getEnv — without the + // forward, Workers bindings would be invisible to middleware. The + // verified auth identity is seeded alongside it; the client middleware + // read `authMode` / `authKeyName` to mirror the verified credentials. + response = await composed(req, { + ...seedContext(platformArg), + userClaims: auth.userClaims, + jwtClaims: auth.jwtClaims, + authMode: auth.authMode, + authKeyName: auth.keyName ?? undefined, + }) + } catch (e) { + // Client construction failures keep their historical response shape: + // EnvError (missing URL / keys) and the client middleware's + // CreateSupabaseClientError map to the same JSON errors + // createSupabaseContext produced. + const mapped = !inClientPhase + ? null + : e instanceof EnvError + ? new AuthError(e.message, e.code, 500) + : e instanceof AuthError && e.code === CreateSupabaseClientError + ? e + : null + if (!mapped) throw e + return Response.json( + { message: mapped.message, code: mapped.code }, + { status: mapped.status, headers: corsHeaders() }, + ) + } if (!isCorsDisabled(config.cors)) { return addCorsHeaders(response, config.cors) diff --git a/tsdown.config.ts b/tsdown.config.ts index 118680c..d3e7282 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -9,8 +9,19 @@ export default defineConfig({ 'src/adapters/h3/index.ts', 'src/adapters/elysia/index.ts', 'src/adapters/nestjs/index.ts', + 'src/middleware/postgres/index.ts', + 'src/middleware/claims/index.ts', + 'src/middleware/client/index.ts', + 'src/middleware/admin-client/index.ts', ], format: ['esm', 'cjs'], dts: true, - external: ['@supabase/supabase-js', 'hono', 'h3', 'elysia', '@nestjs/common'], + external: [ + '@supabase/supabase-js', + 'hono', + 'h3', + 'elysia', + '@nestjs/common', + 'pg', + ], })