Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/cuddly-radios-move.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/pretty-apricots-collect.md
Original file line number Diff line number Diff line change
@@ -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.
83 changes: 0 additions & 83 deletions modules/module-postgres/src/auth/SupabaseKeyCollector.ts

This file was deleted.

45 changes: 2 additions & 43 deletions modules/module-postgres/src/module/PostgresModule.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { baseUri, NormalizedBasePostgresConnectionConfig } from '@powersync/lib-service-postgres';
import {
api,
auth,
ConfigurationFileSyncRulesProvider,
ConnectionTestResult,
modules,
replication,
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';
Expand All @@ -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<types.PostgresConnectionConfig> {
Expand All @@ -32,19 +30,6 @@ export class PostgresModule extends replication.ReplicationModule<types.Postgres
}

async onInitialized(context: system.ServiceContextContainer): Promise<void> {
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({
Expand Down Expand Up @@ -110,32 +95,6 @@ export class PostgresModule extends replication.ReplicationModule<types.Postgres
}
}

// TODO: This should rather be done by registering the key collector in some kind of auth engine
private registerSupabaseAuth(context: system.ServiceContextContainer) {
const { configuration } = context;
// Register the Supabase key collector(s)
configuration.connections
?.map((baseConfig) => {
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<ConnectionTestResult> {
this.decodeConfig(config);
const normalizedConfig = this.resolveConfig(this.decodedConfig!);
Expand Down
32 changes: 28 additions & 4 deletions packages/service-core/src/auth/KeyStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -39,6 +39,29 @@ export class KeyStore<Collector extends KeyCollector = KeyCollector> {
*/
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.
*/
legacyEnabled: boolean;
} = {
jwksDetails: null,
jwksEnabled: false,
legacyEnabled: false
};

constructor(collector: Collector) {
this.collector = collector;
}
Expand Down Expand Up @@ -131,7 +154,7 @@ export class KeyStore<Collector extends KeyCollector = KeyCollector> {
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;
Expand Down Expand Up @@ -165,12 +188,13 @@ export class KeyStore<Collector extends KeyCollector = KeyCollector> {
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
}
);
}
Expand Down
7 changes: 5 additions & 2 deletions packages/service-core/src/auth/RemoteJWKSCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand All @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down
Loading