diff --git a/.changeset/green-forks-approve.md b/.changeset/green-forks-approve.md new file mode 100644 index 000000000..9bf8b996c --- /dev/null +++ b/.changeset/green-forks-approve.md @@ -0,0 +1,7 @@ +--- +'@powersync/service-core': minor +'@powersync/service-types': minor +'@powersync/service-image': minor +--- + +Add "supabase_jwt_secret" config option to simplify static Supabase auth. diff --git a/modules/module-postgres/src/auth/SupabaseKeyCollector.ts b/modules/module-postgres/src/auth/SupabaseKeyCollector.ts index c52a9abe2..af300f251 100644 --- a/modules/module-postgres/src/auth/SupabaseKeyCollector.ts +++ b/modules/module-postgres/src/auth/SupabaseKeyCollector.ts @@ -10,6 +10,8 @@ import * as pgwire_utils from '../utils/pgwire_utils.js'; * * 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". */ export class SupabaseKeyCollector implements auth.KeyCollector { private pool: pgwire.PgClient; diff --git a/packages/service-core/src/auth/StaticSupabaseKeyCollector.ts b/packages/service-core/src/auth/StaticSupabaseKeyCollector.ts new file mode 100644 index 000000000..5efcfa741 --- /dev/null +++ b/packages/service-core/src/auth/StaticSupabaseKeyCollector.ts @@ -0,0 +1,31 @@ +import * as jose from 'jose'; +import { KeySpec, KeyOptions } from './KeySpec.js'; +import { KeyCollector, KeyResult } from './KeyCollector.js'; + +const SUPABASE_KEY_OPTIONS: KeyOptions = { + requiresAudience: ['authenticated'], + maxLifetimeSeconds: 86400 * 7 + 1200 // 1 week + 20 minutes margin +}; + +/** + * Set of static keys for Supabase. + * + * Same as StaticKeyCollector, but with some configuration tweaks for Supabase. + * + * Similar to SupabaseKeyCollector, but using hardcoded keys instead of fetching + * from the database. + * + * A key can be added both with and without a kid, in case wildcard matching is desired. + */ +export class StaticSupabaseKeyCollector implements KeyCollector { + static async importKeys(keys: jose.JWK[]) { + const parsedKeys = await Promise.all(keys.map((key) => KeySpec.importKey(key, SUPABASE_KEY_OPTIONS))); + return new StaticSupabaseKeyCollector(parsedKeys); + } + + constructor(private keys: KeySpec[]) {} + + async getKeys(): Promise { + return { keys: this.keys, errors: [] }; + } +} diff --git a/packages/service-core/src/auth/auth-index.ts b/packages/service-core/src/auth/auth-index.ts index dae123d1a..1ffa4cb13 100644 --- a/packages/service-core/src/auth/auth-index.ts +++ b/packages/service-core/src/auth/auth-index.ts @@ -7,3 +7,4 @@ export * from './KeyStore.js'; export * from './LeakyBucket.js'; export * from './RemoteJWKSCollector.js'; export * from './StaticKeyCollector.js'; +export * from './StaticSupabaseKeyCollector.js'; 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 7c2b70c87..8f5d304b0 100644 --- a/packages/service-core/src/util/config/compound-config-collector.ts +++ b/packages/service-core/src/util/config/compound-config-collector.ts @@ -65,9 +65,27 @@ export class CompoundConfigCollector { const inputKeys = baseConfig.client_auth?.jwks?.keys ?? []; const staticCollector = await auth.StaticKeyCollector.importKeys(inputKeys); - collectors.add(staticCollector); + if (baseConfig.client_auth?.supabase && baseConfig.client_auth?.supabase_jwt_secret != null) { + // This replaces the old SupabaseKeyCollector, with a statically-configured key. + // You can get the same effect with manual HS256 key configuration, but this + // makes the config simpler. + // We also a custom audience ("authenticated"), increased max lifetime (1 week), + // and auto base64-url-encode the key. + collectors.add( + await auth.StaticSupabaseKeyCollector.importKeys([ + { + kty: 'oct', + alg: 'HS256', + // In this case, the key is not base64-encoded yet. + k: Buffer.from(baseConfig.client_auth.supabase_jwt_secret, 'utf8').toString('base64url'), + kid: undefined // Wildcard kid - any kid can match + } + ]) + ); + } + let jwks_uris = baseConfig.client_auth?.jwks_uri ?? []; if (typeof jwks_uris == 'string') { jwks_uris = [jwks_uris]; diff --git a/packages/types/src/config/PowerSyncConfig.ts b/packages/types/src/config/PowerSyncConfig.ts index 876f1b09f..af38b42c1 100644 --- a/packages/types/src/config/PowerSyncConfig.ts +++ b/packages/types/src/config/PowerSyncConfig.ts @@ -115,6 +115,7 @@ export const powerSyncConfig = t.object({ block_local_jwks: t.boolean.optional(), jwks: strictJwks.optional(), supabase: t.boolean.optional(), + supabase_jwt_secret: t.string.optional(), audience: t.array(t.string).optional() }) .optional(),