diff --git a/.changeset/cuddly-radios-move.md b/.changeset/cuddly-radios-move.md new file mode 100644 index 000000000..3caeb69d8 --- /dev/null +++ b/.changeset/cuddly-radios-move.md @@ -0,0 +1,7 @@ +--- +'@powersync/service-module-postgres': minor +'@powersync/service-core': minor +'@powersync/service-image': minor +--- + +Drop support for legacy Supabase keys via app.settings.jwt_secret. diff --git a/.changeset/pretty-apricots-collect.md b/.changeset/pretty-apricots-collect.md new file mode 100644 index 000000000..e0051b3a8 --- /dev/null +++ b/.changeset/pretty-apricots-collect.md @@ -0,0 +1,7 @@ +--- +'@powersync/service-module-postgres': minor +'@powersync/service-core': minor +'@powersync/service-image': minor +--- + +Add automatic support for Supabase JWT Signing Keys. diff --git a/modules/module-postgres/src/auth/SupabaseKeyCollector.ts b/modules/module-postgres/src/auth/SupabaseKeyCollector.ts deleted file mode 100644 index bee0a11ce..000000000 --- a/modules/module-postgres/src/auth/SupabaseKeyCollector.ts +++ /dev/null @@ -1,83 +0,0 @@ -import * as lib_postgres from '@powersync/lib-service-postgres'; -import { auth, KeyResult } from '@powersync/service-core'; -import * as pgwire from '@powersync/service-jpgwire'; -import * as jose from 'jose'; - -import * as types from '../types/types.js'; -import { AuthorizationError, ErrorCode } from '@powersync/lib-services-framework'; -import { getApplicationName } from '../utils/application-name.js'; - -/** - * Fetches key from the Supabase database. - * - * Unfortunately, despite the JWTs containing a kid, we have no way to lookup that kid - * before receiving a valid token. - * - * @deprecated Supabase is removing support for "app.settings.jwt_secret". This is likely to not function anymore, except in some self-hosted setups. - */ -export class SupabaseKeyCollector implements auth.KeyCollector { - private pool: pgwire.PgClient; - - private keyOptions: auth.KeyOptions = { - requiresAudience: ['authenticated'], - maxLifetimeSeconds: 86400 * 7 + 1200 // 1 week + 20 minutes margin - }; - - constructor(connectionConfig: types.ResolvedConnectionConfig) { - this.pool = pgwire.connectPgWirePool(connectionConfig, { - // To avoid overloading the source database with open connections, - // limit to a single connection, and close the connection shortly - // after using it. - idleTimeout: 5_000, - maxSize: 1, - applicationName: getApplicationName() - }); - } - - shutdown() { - return this.pool.end(); - } - - async getKeys(): Promise { - let row: { jwt_secret: string }; - try { - const rows = pgwire.pgwireRows( - await lib_postgres.retriedQuery(this.pool, `SELECT current_setting('app.settings.jwt_secret') as jwt_secret`) - ); - row = rows[0] as any; - } catch (e) { - if (e.message?.includes('unrecognized configuration parameter')) { - throw new AuthorizationError( - ErrorCode.PSYNC_S2201, - 'No JWT secret found in Supabase database. Manually configure the secret.' - ); - } else { - throw e; - } - } - const secret = row?.jwt_secret as string | undefined; - if (secret == null) { - return { - keys: [], - errors: [ - new AuthorizationError( - ErrorCode.PSYNC_S2201, - 'No JWT secret found in Supabase database. Manually configure the secret.' - ) - ] - }; - } else { - const key: jose.JWK = { - kty: 'oct', - alg: 'HS256', - // While the secret is valid base64, the base64-encoded form is the secret value. - k: Buffer.from(secret, 'utf8').toString('base64url') - }; - const imported = await auth.KeySpec.importKey(key, this.keyOptions); - return { - keys: [imported], - errors: [] - }; - } - } -} diff --git a/modules/module-postgres/src/module/PostgresModule.ts b/modules/module-postgres/src/module/PostgresModule.ts index 41e7b00e2..d762110c0 100644 --- a/modules/module-postgres/src/module/PostgresModule.ts +++ b/modules/module-postgres/src/module/PostgresModule.ts @@ -1,6 +1,6 @@ +import { baseUri, NormalizedBasePostgresConnectionConfig } from '@powersync/lib-service-postgres'; import { api, - auth, ConfigurationFileSyncRulesProvider, ConnectionTestResult, modules, @@ -8,8 +8,8 @@ import { system } from '@powersync/service-core'; import * as jpgwire from '@powersync/service-jpgwire'; +import { ReplicationMetric } from '@powersync/service-types'; import { PostgresRouteAPIAdapter } from '../api/PostgresRouteAPIAdapter.js'; -import { SupabaseKeyCollector } from '../auth/SupabaseKeyCollector.js'; import { ConnectionManagerFactory } from '../replication/ConnectionManagerFactory.js'; import { PgManager } from '../replication/PgManager.js'; import { PostgresErrorRateLimiter } from '../replication/PostgresErrorRateLimiter.js'; @@ -18,8 +18,6 @@ import { PUBLICATION_NAME } from '../replication/WalStream.js'; import { WalStreamReplicator } from '../replication/WalStreamReplicator.js'; import * as types from '../types/types.js'; import { PostgresConnectionConfig } from '../types/types.js'; -import { baseUri, NormalizedBasePostgresConnectionConfig } from '@powersync/lib-service-postgres'; -import { ReplicationMetric } from '@powersync/service-types'; import { getApplicationName } from '../utils/application-name.js'; export class PostgresModule extends replication.ReplicationModule { @@ -32,19 +30,6 @@ export class PostgresModule extends replication.ReplicationModule { - const client_auth = context.configuration.base_config.client_auth; - - if (client_auth?.supabase && client_auth?.supabase_jwt_secret == null) { - // Only use the deprecated SupabaseKeyCollector when there is no - // secret hardcoded. Hardcoded secrets are handled elsewhere, using - // StaticSupabaseKeyCollector. - - // Support for SupabaseKeyCollector is deprecated and support will be - // completely removed by Supabase soon. We can keep support a while - // longer for self-hosted setups, before also removing that on our side. - this.registerSupabaseAuth(context); - } - // Record replicated bytes using global jpgwire metrics. Only registered if this module is replicating if (context.replicationEngine) { jpgwire.setMetricsRecorder({ @@ -110,32 +95,6 @@ export class PostgresModule extends replication.ReplicationModule { - if (baseConfig.type != types.POSTGRES_CONNECTION_TYPE) { - return; - } - try { - return this.resolveConfig(types.PostgresConnectionConfig.decode(baseConfig as any)); - } catch (ex) { - this.logger.warn('Failed to decode configuration.', ex); - } - }) - .filter((c) => !!c) - .forEach((config) => { - const keyCollector = new SupabaseKeyCollector(config!); - context.lifeCycleEngine.withLifecycle(keyCollector, { - // Close the internal pool - stop: (collector) => collector.shutdown() - }); - configuration.client_keystore.collector.add(new auth.CachedKeyCollector(keyCollector)); - }); - } - async testConnection(config: PostgresConnectionConfig): Promise { this.decodeConfig(config); const normalizedConfig = this.resolveConfig(this.decodedConfig!); diff --git a/packages/service-core/src/auth/KeyStore.ts b/packages/service-core/src/auth/KeyStore.ts index 6e48159c5..46f450362 100644 --- a/packages/service-core/src/auth/KeyStore.ts +++ b/packages/service-core/src/auth/KeyStore.ts @@ -4,7 +4,7 @@ import secs from '../util/secs.js'; import { JwtPayload } from './JwtPayload.js'; import { KeyCollector } from './KeyCollector.js'; import { KeyOptions, KeySpec, SUPPORTED_ALGORITHMS } from './KeySpec.js'; -import { mapAuthError } from './utils.js'; +import { debugKeyNotFound, mapAuthError, SupabaseAuthDetails, tokenDebugDetails } from './utils.js'; /** * KeyStore to get keys and verify tokens. @@ -39,6 +39,29 @@ export class KeyStore { */ collector: Collector; + /** + * For debug purposes only. + * + * This is very Supabase-specific, but we need the info on this level. For example, + * we want to detect cases where a Supabase token is used, but Supabase auth is not enabled + * (no Supabase collector configured). + */ + supabaseAuthDebug: { + /** + * This can be populated without jwksEnabled, but not the other way around. + */ + jwksDetails: SupabaseAuthDetails | null; + jwksEnabled: boolean; + /** + * This can be enabled without jwksDetails populated. + */ + sharedSecretEnabled: boolean; + } = { + jwksDetails: null, + jwksEnabled: false, + sharedSecretEnabled: false + }; + constructor(collector: Collector) { this.collector = collector; } @@ -131,7 +154,7 @@ export class KeyStore { if (!key.matchesAlgorithm(header.alg)) { throw new AuthorizationError(ErrorCode.PSYNC_S2101, `Unexpected token algorithm ${header.alg}`, { configurationDetails: `Key kid: ${key.source.kid}, alg: ${key.source.alg}, kty: ${key.source.kty}` - // Token details automatically populated elsewhere + // tokenDetails automatically populated higher up the stack }); } return key; @@ -165,12 +188,13 @@ export class KeyStore { logger.error(`Failed to refresh keys`, e); }); + const details = debugKeyNotFound(this, keys, token); + throw new AuthorizationError( ErrorCode.PSYNC_S2101, 'Could not find an appropriate key in the keystore. The key is missing or no key matched the token KID', { - configurationDetails: `Known keys: ${keys.map((key) => key.description).join(', ')}` - // tokenDetails automatically populated later + ...details } ); } diff --git a/packages/service-core/src/auth/RemoteJWKSCollector.ts b/packages/service-core/src/auth/RemoteJWKSCollector.ts index 34984c8f2..63ba9cbfb 100644 --- a/packages/service-core/src/auth/RemoteJWKSCollector.ts +++ b/packages/service-core/src/auth/RemoteJWKSCollector.ts @@ -12,10 +12,11 @@ import { ServiceError } from '@powersync/lib-services-framework'; import { KeyCollector, KeyResult } from './KeyCollector.js'; -import { KeySpec } from './KeySpec.js'; +import { KeyOptions, KeySpec } from './KeySpec.js'; export type RemoteJWKSCollectorOptions = { lookupOptions?: LookupOptions; + keyOptions?: KeyOptions; }; /** @@ -24,6 +25,7 @@ export type RemoteJWKSCollectorOptions = { export class RemoteJWKSCollector implements KeyCollector { private url: URL; private agent: http.Agent; + private keyOptions: KeyOptions; constructor( url: string, @@ -34,6 +36,7 @@ export class RemoteJWKSCollector implements KeyCollector { } catch (e: any) { throw new ServiceError(ErrorCode.PSYNC_S3102, `Invalid jwks_uri: ${JSON.stringify(url)} Details: ${e.message}`); } + this.keyOptions = options?.keyOptions ?? {}; // We do support http here for self-hosting use cases. // Management service restricts this to https for hosted versions. @@ -123,7 +126,7 @@ export class RemoteJWKSCollector implements KeyCollector { } } - const key = await KeySpec.importKey(keyData); + const key = await KeySpec.importKey(keyData, this.keyOptions); keys.push(key); } diff --git a/packages/service-core/src/auth/StaticSupabaseKeyCollector.ts b/packages/service-core/src/auth/StaticSupabaseKeyCollector.ts index 5efcfa741..581cbfcfe 100644 --- a/packages/service-core/src/auth/StaticSupabaseKeyCollector.ts +++ b/packages/service-core/src/auth/StaticSupabaseKeyCollector.ts @@ -2,7 +2,7 @@ import * as jose from 'jose'; import { KeySpec, KeyOptions } from './KeySpec.js'; import { KeyCollector, KeyResult } from './KeyCollector.js'; -const SUPABASE_KEY_OPTIONS: KeyOptions = { +export const SUPABASE_KEY_OPTIONS: KeyOptions = { requiresAudience: ['authenticated'], maxLifetimeSeconds: 86400 * 7 + 1200 // 1 week + 20 minutes margin }; diff --git a/packages/service-core/src/auth/utils.ts b/packages/service-core/src/auth/utils.ts index 848c6ecf7..cc2745d43 100644 --- a/packages/service-core/src/auth/utils.ts +++ b/packages/service-core/src/auth/utils.ts @@ -1,5 +1,9 @@ import { AuthorizationError, ErrorCode } from '@powersync/lib-services-framework'; import * as jose from 'jose'; +import * as urijs from 'uri-js'; +import * as uuid from 'uuid'; +import { KeySpec } from './KeySpec.js'; +import { KeyStore } from './KeyStore.js'; export function mapJoseError(error: jose.errors.JOSEError, token: string): AuthorizationError { const tokenDetails = tokenDebugDetails(token); @@ -61,15 +65,28 @@ export function mapAuthConfigError(error: any): AuthorizationError { * We use this to add details to our logs. We don't log the entire token, since it may for example * a password incorrectly used as a token. */ -function tokenDebugDetails(token: string): string { +export function tokenDebugDetails(token: string): string { + return parseTokenDebug(token).description; +} + +function parseTokenDebug(token: string) { try { // For valid tokens, we return the header and payload const header = jose.decodeProtectedHeader(token); const payload = jose.decodeJwt(token); - return ``; + const isSupabase = typeof payload.iss == 'string' && payload.iss.includes('supabase.co'); + const isSharedSecret = isSupabase && header.alg === 'HS256'; + + return { + header, + payload, + isSupabase, + isSharedSecret: isSharedSecret, + description: `` + }; } catch (e) { // Token fails to parse. Return some details. - return invalidTokenDetails(token); + return { description: invalidTokenDetails(token) }; } } @@ -100,3 +117,106 @@ function invalidTokenDetails(token: string): string { return ``; } + +export interface SupabaseAuthDetails { + projectId: string; + url: string; + hostname: string; +} + +export function getSupabaseJwksUrl(connection: any): SupabaseAuthDetails | null { + if (connection == null) { + return null; + } else if (connection.type != 'postgresql') { + return null; + } + + let hostname: string | undefined = connection.hostname; + if (hostname == null && typeof connection.uri == 'string') { + hostname = urijs.parse(connection.uri).host; + } + if (hostname == null) { + return null; + } + + const match = /db.(\w+).supabase.co/.exec(hostname); + if (match == null) { + return null; + } + const projectId = match[1]; + + return { projectId, hostname, url: `https://${projectId}.supabase.co/auth/v1/.well-known/jwks.json` }; +} + +export function debugKeyNotFound( + keyStore: KeyStore, + keys: KeySpec[], + token: string +): { configurationDetails: string; tokenDetails: string } { + const knownKeys = keys.map((key) => key.description).join(', '); + const td = parseTokenDebug(token); + const tokenDetails = td.description; + const configuredSupabase = keyStore.supabaseAuthDebug; + + // Cases to check: + // 1. Is Supabase token, but supabase auth not enabled. + // 2. Is Supabase HS256 token, but no secret configured. + // 3. Is Supabase singing key token, but no Supabase signing keys configured. + // 4. Supabase project id mismatch. + + if (td.isSharedSecret) { + // Supabase HS256 token + // UUID: HS256 (Shared Secret) + // Other: Legacy HS256 (Shared Secret) + // Not a big difference between the two other than terminology used on Supabase. + const isLegacy = uuid.validate(td.header.kid) ? false : true; + const addMessage = + configuredSupabase.jwksEnabled && !isLegacy + ? ' Use asymmetric keys on Supabase (RSA or ECC) to allow automatic key retrieval.' + : ''; + if (!configuredSupabase.sharedSecretEnabled) { + return { + configurationDetails: `Token is a Supabase ${isLegacy ? 'Legacy ' : ''}HS256 (Shared Secret) token, but Supabase JWT secret is not configured.${addMessage}`, + tokenDetails + }; + } else { + return { + // This is an educated guess + configurationDetails: `Token is a Supabase ${isLegacy ? 'Legacy ' : ''}HS256 (Shared Secret) token, but configured Supabase JWT secret does not match.${addMessage}`, + tokenDetails + }; + } + } else if (td.isSupabase) { + // Supabase JWT Signing Keys + if (!configuredSupabase.jwksEnabled) { + if (configuredSupabase.jwksDetails != null) { + return { + configurationDetails: `Token uses Supabase JWT Signing Keys, but Supabase Auth is not enabled`, + tokenDetails + }; + } else { + return { + configurationDetails: `Token uses Supabase JWT Signing Keys, but no Supabase connection is configured`, + tokenDetails + }; + } + } else if (configuredSupabase.jwksDetails != null) { + const configuredProjectId = configuredSupabase.jwksDetails.projectId; + const issuer = td.payload.iss as string; // Is a string since since isSupabase is true + if (!issuer.includes(configuredProjectId)) { + return { + configurationDetails: `Supabase project id mismatch. Expected project: ${configuredProjectId}, got issuer: ${issuer}`, + tokenDetails + }; + } else { + // Project id matches, but no matching keys found + return { + configurationDetails: `Supabase signing keys configured, but no matching keys found. Known keys: ${knownKeys}`, + tokenDetails + }; + } + } + } + + return { configurationDetails: `Known keys: ${knownKeys}`, tokenDetails: tokenDebugDetails(token) }; +} diff --git a/packages/service-core/src/util/config/compound-config-collector.ts b/packages/service-core/src/util/config/compound-config-collector.ts index de485c4b7..781ee4f64 100644 --- a/packages/service-core/src/util/config/compound-config-collector.ts +++ b/packages/service-core/src/util/config/compound-config-collector.ts @@ -89,6 +89,7 @@ export class CompoundConfigCollector { } ]) ); + keyStore.supabaseAuthDebug.sharedSecretEnabled = true; } let jwks_uris = baseConfig.client_auth?.jwks_uri ?? []; @@ -114,6 +115,29 @@ export class CompoundConfigCollector { for (let uri of jwks_uris) { collectors.add(new auth.CachedKeyCollector(new auth.RemoteJWKSCollector(uri, { lookupOptions: jwksLookup }))); } + const supabaseAuthDetails = auth.getSupabaseJwksUrl(baseConfig.replication?.connections?.[0]); + keyStore.supabaseAuthDebug.jwksDetails = supabaseAuthDetails; + + if (baseConfig.client_auth?.supabase) { + // Automatic support for Supabase signing keys: + // https://supabase.com/docs/guides/auth/signing-keys + if (supabaseAuthDetails != null) { + const collector = new auth.RemoteJWKSCollector(supabaseAuthDetails.url, { + lookupOptions: jwksLookup, + // Special case aud and max lifetime for Supabase keys + keyOptions: auth.SUPABASE_KEY_OPTIONS + }); + collectors.add(new auth.CachedKeyCollector(collector)); + keyStore.supabaseAuthDebug.jwksEnabled = true; + logger.info(`Configured Supabase Auth with ${supabaseAuthDetails.url}`); + } else { + logger.warn( + 'Supabase Auth is enabled, but no Supabase connection string found. Skipping Supabase JWKS URL configuration.' + ); + } + } else if (supabaseAuthDetails != null) { + logger.warn(`Supabase connection string found, but Supabase Auth is not enabled in the config.`); + } const sync_rules = await this.collectSyncRules(baseConfig, runnerConfig); diff --git a/packages/service-core/test/src/auth.test.ts b/packages/service-core/test/src/auth.test.ts index 73cdedcca..6617fd8d1 100644 --- a/packages/service-core/test/src/auth.test.ts +++ b/packages/service-core/test/src/auth.test.ts @@ -6,7 +6,8 @@ import { KeySpec } from '../../src/auth/KeySpec.js'; import { RemoteJWKSCollector } from '../../src/auth/RemoteJWKSCollector.js'; import { KeyResult } from '../../src/auth/KeyCollector.js'; import { CachedKeyCollector } from '../../src/auth/CachedKeyCollector.js'; -import { JwtPayload } from '@/index.js'; +import { JwtPayload, StaticSupabaseKeyCollector } from '@/index.js'; +import { debugKeyNotFound } from '../../src/auth/utils.js'; const publicKeyRSA: jose.JWK = { use: 'sig', @@ -438,4 +439,325 @@ describe('JWT Auth', () => { expect(verified.claim).toEqual('test-claim-2'); }); + + describe('debugKeyNotFound', () => { + test('Supabase token with legacy auth not configured', async () => { + const keys = await StaticSupabaseKeyCollector.importKeys([]); + const store = new KeyStore(keys); + + // Mock Supabase debug info - legacy not enabled + store.supabaseAuthDebug = { + jwksDetails: null, + jwksEnabled: false, + sharedSecretEnabled: false + }; + + // Create a legacy Supabase token (HS256) + const token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'HS256', kid: 'test' }) + .setSubject('test') + .setIssuer('https://abc123.supabase.co/auth/v1') + .setAudience('authenticated') + .setExpirationTime('1h') + .setIssuedAt() + .sign(Buffer.from('secret')); + + const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e); + expect(err.configurationDetails).toMatch( + 'Token is a Supabase Legacy HS256 (Shared Secret) token, but Supabase JWT secret is not configured' + ); + }); + + test('Legacy Supabase token with wrong secret', async () => { + const keys = await StaticSupabaseKeyCollector.importKeys([sharedKey]); + const store = new KeyStore(keys); + + // Mock Supabase debug info - legacy enabled + store.supabaseAuthDebug = { + jwksDetails: null, + jwksEnabled: false, + sharedSecretEnabled: true + }; + + // Create a legacy Supabase token (HS256) + const token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'HS256', kid: sharedKey2.kid }) + .setSubject('test') + .setIssuer('https://abc123.supabase.co/auth/v1') + .setAudience('authenticated') + .setExpirationTime('1h') + .setIssuedAt() + .sign(await jose.importJWK(sharedKey2)); + + const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e); + expect(err.configurationDetails).toMatch( + 'Token is a Supabase Legacy HS256 (Shared Secret) token, but configured Supabase JWT secret does not match' + ); + }); + + test('New HS256 Supabase token with wrong secret', async () => { + const keys = await StaticSupabaseKeyCollector.importKeys([sharedKey]); + const store = new KeyStore(keys); + + // Mock Supabase debug info - legacy enabled + store.supabaseAuthDebug = { + jwksDetails: null, + jwksEnabled: false, + sharedSecretEnabled: true + }; + + // Create a new HS256 Supabase token. + // The only real difference here is that the kid is a UUID + const token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'HS256', kid: '2fc01f1d-90fb-4c8b-b646-1c06ed86be46' }) + .setSubject('test') + .setIssuer('https://abc123.supabase.co/auth/v1') + .setAudience('authenticated') + .setExpirationTime('1h') + .setIssuedAt() + .sign(await jose.importJWK(sharedKey2)); + + const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e); + expect(err.configurationDetails).toMatch( + 'Token is a Supabase HS256 (Shared Secret) token, but configured Supabase JWT secret does not match' + ); + }); + + test('Supabase signing key token with no Supabase connection', async () => { + const keys = await StaticSupabaseKeyCollector.importKeys([]); + const store = new KeyStore(keys); + + // Mock Supabase debug info - no Supabase connection + store.supabaseAuthDebug = { + jwksDetails: null, + jwksEnabled: false, + sharedSecretEnabled: false + }; + + const signKey = await jose.importJWK(privateKeyECDSA); + const token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'ES256', kid: 'test-kid' }) + .setSubject('test') + .setIssuer('https://abc123.supabase.co/auth/v1') + .setAudience('authenticated') + .setExpirationTime('1h') + .setIssuedAt() + .sign(signKey); + + const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e); + expect(err.configurationDetails).toMatch( + 'Token uses Supabase JWT Signing Keys, but no Supabase connection is configured' + ); + }); + + test('Supabase signing key token with Supabase auth disabled', async () => { + const keys = await StaticSupabaseKeyCollector.importKeys([]); + const store = new KeyStore(keys); + + // Mock Supabase debug info - Supabase project, but Supabase auth not enabled + store.supabaseAuthDebug = { + jwksDetails: { + projectId: 'abc123', + hostname: 'db.abc123.supabase.co', + url: 'https://abc123.supabase.co/auth/v1/.well-known/jwks.json' + }, + jwksEnabled: false, + sharedSecretEnabled: false + }; + + const signKey = await jose.importJWK(privateKeyECDSA); + const token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'ES256', kid: 'test-kid' }) + .setSubject('test') + .setIssuer('https://abc123.supabase.co/auth/v1') + .setAudience('authenticated') + .setExpirationTime('1h') + .setIssuedAt() + .sign(signKey); + + const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e); + expect(err.configurationDetails).toMatch( + 'Token uses Supabase JWT Signing Keys, but Supabase Auth is not enabled' + ); + }); + + test('Supabase project ID mismatch', async () => { + const keys = await StaticSupabaseKeyCollector.importKeys([publicKeyRSA]); + const store = new KeyStore(keys); + + // Mock Supabase debug info - JWKS enabled with different project ID + store.supabaseAuthDebug = { + jwksDetails: { + projectId: 'expected123', + hostname: 'db.expected123.supabase.co', + url: 'https://expected123.supabase.co/auth/v1/.well-known/jwks.json' + }, + jwksEnabled: true, + sharedSecretEnabled: false + }; + + // Create a modern Supabase token with different project ID + const token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'ES256', kid: privateKeyECDSA.kid }) + .setSubject('test') + .setIssuer('https://different456.supabase.co/auth/v1') + .setAudience('authenticated') + .setExpirationTime('1h') + .setIssuedAt() + .sign(await jose.importJWK(privateKeyECDSA)); + + const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e); + expect(err.configurationDetails).toMatch( + 'Supabase project id mismatch. Expected project: expected123, got issuer: https://different456.supabase.co/auth/v1' + ); + }); + + test('Supabase signing keys configured but no matching keys', async () => { + const keys = await StaticSupabaseKeyCollector.importKeys([publicKeyRSA]); + const store = new KeyStore(keys); + + // Mock Supabase debug info - JWKS enabled with matching project ID + store.supabaseAuthDebug = { + jwksDetails: { + projectId: 'abc123', + hostname: 'db.abc123.supabase.co', + url: 'https://abc123.supabase.co/auth/v1/.well-known/jwks.json' + }, + jwksEnabled: true, + sharedSecretEnabled: false + }; + + // Create a modern Supabase token with matching project ID + const token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'ES256', kid: privateKeyECDSA.kid }) + .setSubject('test') + .setIssuer('https://abc123.supabase.co/auth/v1') + .setAudience('authenticated') + .setExpirationTime('1h') + .setIssuedAt() + .sign(await jose.importJWK(privateKeyECDSA)); + + const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e); + expect(err.configurationDetails).toMatch( + 'Supabase signing keys configured, but no matching keys found. Known keys: ' + ); + }); + + test('non-Supabase token', async () => { + const keys = await StaticSupabaseKeyCollector.importKeys([sharedKey]); + const store = new KeyStore(keys); + + // Create a regular JWT token (not Supabase) + const token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'ES256', kid: privateKeyECDSA.kid }) + .setSubject('test') + .setIssuer('https://regular-issuer.com') + .setAudience('my-audience') + .setExpirationTime('1h') + .setIssuedAt() + .sign(await jose.importJWK(privateKeyECDSA)); + + // Treated as just a generic unknown key + const err = await store.verifyJwt(token, { defaultAudiences: ['my-audience'], maxAge: '1d' }).catch((e) => e); + expect(err.configurationDetails).toMatch('Known keys:'); + }); + + test('Valid legacy Supabase token', async () => { + const keys = await StaticSupabaseKeyCollector.importKeys([sharedKey]); + const store = new KeyStore(keys); + + // Mock Supabase debug info - legacy enabled + store.supabaseAuthDebug = { + jwksDetails: null, + jwksEnabled: false, + sharedSecretEnabled: true + }; + + // Create a legacy Supabase token (HS256) + const token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'HS256', kid: sharedKey.kid }) + .setSubject('test') + .setIssuer('https://abc123.supabase.co/auth/v1') + .setAudience('authenticated') + .setExpirationTime('1h') + .setIssuedAt() + .sign(await jose.importJWK(sharedKey)); + + await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }); + }); + + test('Valid Supabase signing key', async () => { + const keys = await StaticSupabaseKeyCollector.importKeys([privateKeyECDSA]); + const store = new KeyStore(keys); + + // Mock Supabase debug info - JWKS enabled, legacy disabled + store.supabaseAuthDebug = { + jwksDetails: null, + jwksEnabled: true, + sharedSecretEnabled: false + }; + + // Create a modern Supabase signing key token (ES256) + const token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'ES256', kid: privateKeyECDSA.kid }) + .setSubject('test') + .setIssuer('https://abc123.supabase.co/auth/v1') + .setAudience('authenticated') + .setExpirationTime('1h') + .setIssuedAt() + .sign(await jose.importJWK(privateKeyECDSA)); + + await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }); + }); + + test('Legacy Supabase anon token', async () => { + const keys = await StaticSupabaseKeyCollector.importKeys([sharedKey]); + const store = new KeyStore(keys); + + // Mock Supabase debug info - legacy enabled + store.supabaseAuthDebug = { + jwksDetails: null, + jwksEnabled: false, + sharedSecretEnabled: true + }; + + // Create a legacy Supabase token (HS256) + const token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'HS256', kid: sharedKey.kid }) + .setSubject('test') + .setIssuer('https://abc123.supabase.co/auth/v1') + .setAudience('anon') + .setExpirationTime('1h') + .setIssuedAt() + .sign(await jose.importJWK(sharedKey)); + + const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e); + expect(err.message).toMatch('[PSYNC_S2105] Unexpected "aud" claim value: "anon"'); + }); + + test('Supabase signing key anon token', async () => { + const keys = await StaticSupabaseKeyCollector.importKeys([privateKeyECDSA]); + const store = new KeyStore(keys); + + // Mock Supabase debug info - JWKS enabled + store.supabaseAuthDebug = { + jwksDetails: null, + jwksEnabled: true, + sharedSecretEnabled: false + }; + + // Create a modern Supabase signing key token (ES256) + const token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'ES256', kid: privateKeyECDSA.kid }) + .setSubject('test') + .setIssuer('https://abc123.supabase.co/auth/v1') + .setAudience('anon') + .setExpirationTime('1h') + .setIssuedAt() + .sign(await jose.importJWK(privateKeyECDSA)); + + const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e); + expect(err.message).toMatch('[PSYNC_S2105] Unexpected "aud" claim value: "anon"'); + }); + }); });