diff --git a/docs/security.md b/docs/security.md index da7110a..b8647c9 100644 --- a/docs/security.md +++ b/docs/security.md @@ -60,11 +60,15 @@ JWT verification in `user` mode works as follows: 1. The `Authorization: Bearer ` header is extracted from the request 2. The token is verified against the JWKS from the `SUPABASE_JWKS` environment variable 3. Verification uses `jose`'s `jwtVerify` with a **local** key set — there are no network calls to a JWKS endpoint -4. The token must contain a `sub` (subject) claim to be considered valid -5. On success, the decoded claims are available as `ctx.userClaims` and `ctx.jwtClaims` +4. If `audience` is configured, the token's `aud` claim must match +5. If `issuer` is configured, the token's `iss` claim must match +6. The token must contain a `sub` (subject) claim to be considered valid +7. On success, the decoded claims are available as `ctx.userClaims` and `ctx.jwtClaims` If JWKS is not configured (`SUPABASE_JWKS` is missing or malformed), `user` mode is unavailable and will always reject requests. +**Audience and issuer validation.** In setups where multiple services share the same signing keys, a JWT minted by one service could be accepted by another. Passing `audience` and `issuer` options (e.g. `withSupabase({ auth: 'user', issuer: fromSupabaseUrl(SUPABASE_URL) })` or `withRequiredClaims({ issuer: fromSupabaseUrl(SUPABASE_URL) })`) prevents this by rejecting tokens that weren't issued for your specific service. Both are optional for backward compatibility but recommended in multi-service deployments. + **No silent downgrade.** When `user` is combined with other modes (e.g. `auth: ['user', 'publishable']`), a JWT that is present but fails verification rejects the request with `InvalidCredentialsError` — it does not fall through to the next mode. This prevents a bad token paired with a valid `apikey` (or with `'none'`) from being silently downgraded to a less-privileged auth mode. Requests that simply omit the `Authorization` header still fall through as expected. ## CORS handling diff --git a/src/core/parts/gate.ts b/src/core/parts/gate.ts index 7d2e115..ef9153f 100644 --- a/src/core/parts/gate.ts +++ b/src/core/parts/gate.ts @@ -37,6 +37,8 @@ export const withAuthGate: Middleware< const { data, error } = await verifyAuth(req, { auth: config.auth, allow: config.allow, + audience: config.audience, + issuer: config.issuer, env: config.env, }) if (error) return errorResponse(error, { errors: config.errors }) diff --git a/src/core/verify-auth.ts b/src/core/verify-auth.ts index 5599c12..be2cfec 100644 --- a/src/core/verify-auth.ts +++ b/src/core/verify-auth.ts @@ -29,6 +29,10 @@ export interface VerifyAuthOptions { * provided, `auth` wins. */ allow?: AuthModeWithKey | AuthModeWithKey[] + /** Expected JWT audience (`aud`) claim to validate. Applies to `user` mode only. */ + audience?: string | string[] + /** Expected JWT issuer (`iss`) claim to validate. Applies to `user` mode only. */ + issuer?: string | string[] /** Optional environment overrides (passed through to {@link resolveEnv}). */ env?: Partial diff --git a/src/core/verify-credentials.test.ts b/src/core/verify-credentials.test.ts index 3b4081b..4f585d5 100644 --- a/src/core/verify-credentials.test.ts +++ b/src/core/verify-credentials.test.ts @@ -289,17 +289,18 @@ describe('verifyCredentials', () => { describe('user mode', () => { let jwks: JSONWebKeySet let validTokens: string[] + let privateKey: CryptoKey + let jwtSecret: CryptoKey | Uint8Array beforeAll(async () => { - // Asymmetric JWK - const { privateKey, publicKey } = await generateKeyPair('RS256') - const publicJwk = await exportJWK(publicKey) + const keyPair = await generateKeyPair('RS256') + privateKey = keyPair.privateKey + const publicJwk = await exportJWK(keyPair.publicKey) publicJwk.alg = 'RS256' publicJwk.use = 'sig' publicJwk.kid = 'asymmetric-key-id' - // Symmetric Shared Secret JWK - const jwtSecret = await generateSecret('HS256', { + jwtSecret = await generateSecret('HS256', { extractable: true, }) const symmetricJwk = await exportJWK(jwtSecret) @@ -347,6 +348,134 @@ describe('verifyCredentials', () => { } }) + it('succeeds when JWT audience and issuer match configured values', async () => { + const token = await new SignJWT({ sub: 'user-123' }) + .setProtectedHeader({ alg: 'RS256', kid: 'asymmetric-key-id' }) + .setAudience('https://test.supabase.co') + .setIssuer('https://test.supabase.co/auth/v1') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + const result = await verifyCredentials( + { token, apikey: null }, + { + auth: 'user', + audience: 'https://test.supabase.co', + issuer: 'https://test.supabase.co/auth/v1', + env: makeEnv({ jwks }), + }, + ) + + expect(result.error).toBeNull() + expect(result.data!.jwtClaims!.aud).toBe('https://test.supabase.co') + expect(result.data!.jwtClaims!.iss).toBe( + 'https://test.supabase.co/auth/v1', + ) + }) + + it.each([ + [ + 'audience', + 'https://wrong.supabase.co', + 'https://test.supabase.co/auth/v1', + ], + [ + 'issuer', + 'https://test.supabase.co', + 'https://wrong.supabase.co/auth/v1', + ], + ])( + 'fails when configured JWT %s does not match', + async (_label, audience, issuer) => { + const token = await new SignJWT({ sub: 'user-123' }) + .setProtectedHeader({ alg: 'RS256', kid: 'asymmetric-key-id' }) + .setAudience('https://test.supabase.co') + .setIssuer('https://test.supabase.co/auth/v1') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + const result = await verifyCredentials( + { token, apikey: null }, + { + auth: 'user', + audience, + issuer, + env: makeEnv({ jwks }), + }, + ) + + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidJwtError) + }, + ) + + it.each([ + ['audience', ''], + ['issuer', ''], + ['audience', ['']], + ['issuer', ['']], + ])('fails when JWT %s option is empty', async (field, value) => { + const token = await new SignJWT({ sub: 'user-123' }) + .setProtectedHeader({ alg: 'RS256', kid: 'asymmetric-key-id' }) + .setAudience('https://test.supabase.co') + .setIssuer('https://test.supabase.co/auth/v1') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + const result = await verifyCredentials( + { token, apikey: null }, + { + auth: 'user', + [field]: value, + env: makeEnv({ jwks }), + }, + ) + + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidJwtError) + expect(result.error!.message).toContain( + `the configured "${field}" option is empty`, + ) + }) + + it('supports audience and issuer validation with symmetric HS256 keys', async () => { + const token = await new SignJWT({ sub: 'user-123' }) + .setProtectedHeader({ + alg: 'HS256', + kid: 'symmetric-shared-secret-key-id', + }) + .setAudience('https://test.supabase.co') + .setIssuer('https://test.supabase.co/auth/v1') + .setIssuedAt() + .setExpirationTime('1h') + .sign(jwtSecret) + + const matchResult = await verifyCredentials( + { token, apikey: null }, + { + auth: 'user', + audience: 'https://test.supabase.co', + issuer: 'https://test.supabase.co/auth/v1', + env: makeEnv({ jwks }), + }, + ) + expect(matchResult.error).toBeNull() + + const mismatchResult = await verifyCredentials( + { token, apikey: null }, + { + auth: 'user', + audience: 'https://other.supabase.co', + env: makeEnv({ jwks }), + }, + ) + expect(mismatchResult.error).not.toBeNull() + expect(mismatchResult.error!.code).toBe(InvalidJwtError) + }) + it('fails with invalid JWT', async () => { const creds: Credentials = { token: 'invalid.jwt.token', apikey: null } const result = await verifyCredentials(creds, { diff --git a/src/core/verify-credentials.ts b/src/core/verify-credentials.ts index 3b517ab..7794d47 100644 --- a/src/core/verify-credentials.ts +++ b/src/core/verify-credentials.ts @@ -50,6 +50,10 @@ export interface VerifyCredentialsOptions { * both are provided, `auth` wins. */ allow?: AuthModeWithKey | AuthModeWithKey[] + /** Expected JWT audience (`aud`) claim to validate. Applies to `user` mode only. */ + audience?: string | string[] + /** Expected JWT issuer (`iss`) claim to validate. Applies to `user` mode only. */ + issuer?: string | string[] /** Optional environment overrides (passed through to {@link resolveEnv}). */ env?: Partial @@ -183,6 +187,7 @@ async function tryMode( mode: AuthModeWithKey, credentials: Credentials, env: SupabaseEnv, + options?: VerifyCredentialsOptions, ): Promise { const { base, keyName } = parseAuthMode(mode) @@ -265,7 +270,10 @@ async function tryMode( return { kind: 'skip', skip: { reason: 'jwks-not-configured' } } } - const verified = await verifyUserJwt(credentials.token, env.jwks) + const verified = await verifyUserJwt(credentials.token, env.jwks, { + audience: options?.audience, + issuer: options?.issuer, + }) if (!verified.ok) { const { failure } = verified return { @@ -507,7 +515,7 @@ export async function verifyCredentials( const skips: ModeSkip[] = [] for (const mode of modes) { - const outcome = await tryMode(mode, credentials, env) + const outcome = await tryMode(mode, credentials, env, options) if (outcome.kind === 'match') { return { data: outcome.auth, error: null } } diff --git a/src/core/verify-user-jwt.ts b/src/core/verify-user-jwt.ts index 00e6767..35efd27 100644 --- a/src/core/verify-user-jwt.ts +++ b/src/core/verify-user-jwt.ts @@ -7,6 +7,7 @@ import { jwtVerify, type JWTPayload, type JWTVerifyGetKey, + type JWTVerifyOptions, } from 'jose' import type { JWTClaims, UserClaims } from '../types.js' @@ -183,6 +184,12 @@ const MalformedTokenHint = 'The Authorization header must carry a compact JWS — three base64url segments separated by dots. ' + 'Check the token was not truncated, URL-encoded, or wrapped in quotes.' +/** @internal */ +export interface VerifyUserJwtOptions { + audience?: string | string[] + issuer?: string | string[] +} + /** * Verifies a user JWT against the project JWKS — the single verification core * shared by `verifyCredentials`'s `user` mode and the `withClaims` / @@ -205,6 +212,7 @@ const MalformedTokenHint = export async function verifyUserJwt( token: string, jwks: JSONWebKeySet | URL, + options?: VerifyUserJwtOptions, ): Promise { let alg: string | undefined let kid: string | undefined @@ -244,10 +252,46 @@ export async function verifyUserJwt( } } + if ( + options?.audience === '' || + (options?.audience as unknown) === null || + (Array.isArray(options?.audience) && options.audience.some((a) => !a)) + ) { + return { + ok: false, + failure: { + kind: 'token', + reason: 'the configured "audience" option is empty', + hint: 'Pass a non-empty string or array, or omit the option to skip audience validation.', + jwt, + }, + } + } + if ( + options?.issuer === '' || + (options?.issuer as unknown) === null || + (Array.isArray(options?.issuer) && options.issuer.some((i) => !i)) + ) { + return { + ok: false, + failure: { + kind: 'token', + reason: 'the configured "issuer" option is empty', + hint: 'Pass a non-empty string or array, or omit the option to skip issuer validation.', + jwt, + }, + } + } + try { const jwkResolver = getJwksResolver(jwks) let payload: JWTPayload | null = null + const verifyOptions: JWTVerifyOptions = { + audience: options?.audience, + issuer: options?.issuer, + } + // Symmetric algorithm requires importing the shared secret if (alg === 'HS256') { // A remote resolver fetches only from inside `jwtVerify`; its `jwks()` @@ -277,10 +321,10 @@ export async function verifyUserJwt( } const sharedSecret = await importJWK(jwk, 'HS256') - const verify = await jwtVerify(token, sharedSecret) + const verify = await jwtVerify(token, sharedSecret, verifyOptions) payload = verify.payload } else { - const verify = await jwtVerify(token, jwkResolver) + const verify = await jwtVerify(token, jwkResolver, verifyOptions) payload = verify.payload } diff --git a/src/create-supabase-context.ts b/src/create-supabase-context.ts index c3759f3..5bdf8cb 100644 --- a/src/create-supabase-context.ts +++ b/src/create-supabase-context.ts @@ -46,6 +46,8 @@ export async function createSupabaseContext( const { data: auth, error } = await verifyAuth(request, { auth: options?.auth, allow: options?.allow, + audience: options?.audience, + issuer: options?.issuer, env: options?.env, }) if (error) { diff --git a/src/middleware/claims/index.test.ts b/src/middleware/claims/index.test.ts index e1d24f2..94df9d4 100644 --- a/src/middleware/claims/index.test.ts +++ b/src/middleware/claims/index.test.ts @@ -157,4 +157,53 @@ describe('withClaims', () => { message: expect.stringMatching(/^\[@supabase\/server\]/), }) }) + + it('respects audience and issuer options in withClaims', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + publicJwk.kid = 'claims-aud-test' + const testJwks = { keys: [publicJwk] } + + const validToken = await new SignJWT({ + sub: 'user-123', + role: 'authenticated', + }) + .setProtectedHeader({ alg: 'RS256', kid: 'claims-aud-test' }) + .setAudience('expected-audience') + .setIssuer('expected-issuer') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + const matchHandler = withClaims( + { + jwks: testJwks, + audience: 'expected-audience', + issuer: 'expected-issuer', + }, + async (_req, ctx) => Response.json({ claims: ctx.jwtClaims }), + ) + const matchRes = await matchHandler(requestWithToken(validToken)) + expect(matchRes.status).toBe(200) + + const mismatchHandler = withClaims( + { jwks: testJwks, audience: 'wrong-audience' }, + async (_req, ctx) => Response.json({ claims: ctx.jwtClaims }), + ) + const mismatchRes = await mismatchHandler(requestWithToken(validToken)) + expect(mismatchRes.status).toBe(401) + + const emptyAudHandler = withClaims( + { jwks: testJwks, audience: '' }, + async (_req, ctx) => Response.json({ claims: ctx.jwtClaims }), + ) + const emptyAudRes = await emptyAudHandler(requestWithToken(validToken)) + expect(emptyAudRes.status).toBe(401) + const emptyAudBody = await emptyAudRes.json() + expect(emptyAudBody.message).toContain( + 'the configured "audience" option is empty', + ) + }) }) diff --git a/src/middleware/claims/index.ts b/src/middleware/claims/index.ts index 57667f3..4bc12bc 100644 --- a/src/middleware/claims/index.ts +++ b/src/middleware/claims/index.ts @@ -30,6 +30,10 @@ export interface WithClaimsConfig extends ShortCircuitConfig { * (https endpoint) from the environment. */ jwks?: JSONWebKeySet | URL + /** Expected JWT audience (`aud`) claim to validate. Applies to `user` mode only. */ + audience?: string | string[] + /** Expected JWT issuer (`iss`) claim to validate. Applies to `user` mode only. */ + issuer?: string | string[] } /** @@ -108,7 +112,10 @@ export const withClaims: Middleware< ) } - const verified = await verifyUserJwt(token, jwks) + const verified = await verifyUserJwt(token, jwks, { + audience: config?.audience, + issuer: config?.issuer, + }) if (!verified.ok) { const { failure } = verified return errorResponse( diff --git a/src/middleware/required-claims/index.test.ts b/src/middleware/required-claims/index.test.ts index eefaefd..0899352 100644 --- a/src/middleware/required-claims/index.test.ts +++ b/src/middleware/required-claims/index.test.ts @@ -292,6 +292,55 @@ describe('withRequiredClaims', () => { expect((await res.json()).code).toBe(JwksNotConfiguredError) } }) + + it('respects audience and issuer options in withRequiredClaims', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + publicJwk.kid = 'req-claims-aud-test' + const testJwks = { keys: [publicJwk] } + + const validToken = await new SignJWT({ + sub: 'user-123', + role: 'authenticated', + }) + .setProtectedHeader({ alg: 'RS256', kid: 'req-claims-aud-test' }) + .setAudience('expected-audience') + .setIssuer('expected-issuer') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + const matchHandler = withRequiredClaims( + { + jwks: testJwks, + audience: 'expected-audience', + issuer: 'expected-issuer', + }, + async (_req, ctx) => Response.json({ sub: ctx.jwtClaims.sub }), + ) + const matchRes = await matchHandler(requestWithToken(validToken)) + expect(matchRes.status).toBe(200) + + const mismatchHandler = withRequiredClaims( + { jwks: testJwks, audience: 'wrong-audience' }, + async (_req, ctx) => Response.json({ sub: ctx.jwtClaims.sub }), + ) + const mismatchRes = await mismatchHandler(requestWithToken(validToken)) + expect(mismatchRes.status).toBe(401) + + const emptyAudHandler = withRequiredClaims( + { jwks: testJwks, audience: '' }, + async (_req, ctx) => Response.json({ sub: ctx.jwtClaims.sub }), + ) + const emptyAudRes = await emptyAudHandler(requestWithToken(validToken)) + expect(emptyAudRes.status).toBe(401) + const emptyAudBody = await emptyAudRes.json() + expect(emptyAudBody.message).toContain( + 'the configured "audience" option is empty', + ) + }) }) }) diff --git a/src/middleware/required-claims/index.ts b/src/middleware/required-claims/index.ts index 0777f86..682c05f 100644 --- a/src/middleware/required-claims/index.ts +++ b/src/middleware/required-claims/index.ts @@ -37,6 +37,10 @@ export interface WithRequiredClaimsConfig extends ShortCircuitConfig { * (https endpoint) from the environment. */ jwks?: JSONWebKeySet | URL + /** Expected JWT audience (`aud`) claim to validate. Applies to `user` mode only. */ + audience?: string | string[] + /** Expected JWT issuer (`iss`) claim to validate. Applies to `user` mode only. */ + issuer?: string | string[] } /** @@ -155,7 +159,10 @@ export const withRequiredClaims: Middleware< ) } - const verified = await verifyUserJwt(token, jwks) + const verified = await verifyUserJwt(token, jwks, { + audience: config?.audience, + issuer: config?.issuer, + }) if (!verified.ok) { const { failure } = verified return errorResponse( diff --git a/src/types.ts b/src/types.ts index dad4b34..af85280 100644 --- a/src/types.ts +++ b/src/types.ts @@ -309,6 +309,10 @@ export interface WithSupabaseConfig extends ShortCircuitConfig { * is where the {@link AuthConfig} ordering rule is enforced. */ allow?: AuthModeWithKey | AuthModeWithKey[] + /** Expected JWT audience (`aud`) claim to validate. Applies to `user` mode only. */ + audience?: string | string[] + /** Expected JWT issuer (`iss`) claim to validate. Applies to `user` mode only. */ + issuer?: string | string[] /** * Override auto-detected environment variables. Useful for testing diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index 5b05e47..65492f7 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { defineMiddleware, getEnv, pipeline } from '@supabase/middleware' import type { FetchHandler } from '@supabase/middleware' +import { exportJWK, generateKeyPair, SignJWT } from 'jose' import { _resetAllowDeprecationWarned } from './core/utils/deprecation.js' import { createSupabaseContext } from './create-supabase-context.js' @@ -847,3 +848,78 @@ describe('withSupabase config without a middleware option', () => { ) }) }) + +describe('withSupabase audience and issuer validation', () => { + it('validates audience and issuer when configured', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + publicJwk.kid = 'with-supabase-aud-test' + const testJwks = { keys: [publicJwk] } + + const token = await new SignJWT({ + sub: 'user-123', + role: 'authenticated', + }) + .setProtectedHeader({ alg: 'RS256', kid: 'with-supabase-aud-test' }) + .setAudience('expected-audience') + .setIssuer('expected-issuer') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + const matchHandler = withSupabase( + { + auth: 'user', + audience: 'expected-audience', + issuer: 'expected-issuer', + env: { ...baseEnv, jwks: testJwks }, + }, + async (_req, ctx) => Response.json({ sub: ctx.userClaims?.id }), + ) + + const matchRes = await matchHandler( + new Request('http://localhost', { + headers: { authorization: `Bearer ${token}` }, + }), + ) + expect(matchRes.status).toBe(200) + + const mismatchHandler = withSupabase( + { + auth: 'user', + audience: 'wrong-audience', + env: { ...baseEnv, jwks: testJwks }, + }, + async (_req, ctx) => Response.json({ sub: ctx.userClaims?.id }), + ) + + const mismatchRes = await mismatchHandler( + new Request('http://localhost', { + headers: { authorization: `Bearer ${token}` }, + }), + ) + expect(mismatchRes.status).toBe(401) + + const emptyAudHandler = withSupabase( + { + auth: 'user', + audience: '', + env: { ...baseEnv, jwks: testJwks }, + }, + async (_req, ctx) => Response.json({ sub: ctx.userClaims?.id }), + ) + + const emptyAudRes = await emptyAudHandler( + new Request('http://localhost', { + headers: { authorization: `Bearer ${token}` }, + }), + ) + expect(emptyAudRes.status).toBe(401) + const emptyAudBody = await emptyAudRes.json() + expect(emptyAudBody.message).toContain( + 'the configured "audience" option is empty', + ) + }) +})