Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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.
*/
sharedSecretEnabled: boolean;
} = {
jwksDetails: null,
jwksEnabled: false,
sharedSecretEnabled: 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