Skip to content

Commit 2378e36

Browse files
rkistnerCopilot
andauthored
Supabase signing keys (#311)
* Remove old SupabaseKeyCollector. * Add automatic support for Supabase signing keys. * Add debugging info for Supabase key issues. * Changesets. * Fix comment inaccuracies via copilot Co-authored-by: Copilot <[email protected]> * Distinguish legacy from new HS256 keys to tweak recommendations. --------- Co-authored-by: Copilot <[email protected]>
1 parent 04acfef commit 2378e36

File tree

10 files changed

+520
-137
lines changed

10 files changed

+520
-137
lines changed

.changeset/cuddly-radios-move.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@powersync/service-module-postgres': minor
3+
'@powersync/service-core': minor
4+
'@powersync/service-image': minor
5+
---
6+
7+
Drop support for legacy Supabase keys via app.settings.jwt_secret.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@powersync/service-module-postgres': minor
3+
'@powersync/service-core': minor
4+
'@powersync/service-image': minor
5+
---
6+
7+
Add automatic support for Supabase JWT Signing Keys.

modules/module-postgres/src/auth/SupabaseKeyCollector.ts

Lines changed: 0 additions & 83 deletions
This file was deleted.

modules/module-postgres/src/module/PostgresModule.ts

Lines changed: 2 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1+
import { baseUri, NormalizedBasePostgresConnectionConfig } from '@powersync/lib-service-postgres';
12
import {
23
api,
3-
auth,
44
ConfigurationFileSyncRulesProvider,
55
ConnectionTestResult,
66
modules,
77
replication,
88
system
99
} from '@powersync/service-core';
1010
import * as jpgwire from '@powersync/service-jpgwire';
11+
import { ReplicationMetric } from '@powersync/service-types';
1112
import { PostgresRouteAPIAdapter } from '../api/PostgresRouteAPIAdapter.js';
12-
import { SupabaseKeyCollector } from '../auth/SupabaseKeyCollector.js';
1313
import { ConnectionManagerFactory } from '../replication/ConnectionManagerFactory.js';
1414
import { PgManager } from '../replication/PgManager.js';
1515
import { PostgresErrorRateLimiter } from '../replication/PostgresErrorRateLimiter.js';
@@ -18,8 +18,6 @@ import { PUBLICATION_NAME } from '../replication/WalStream.js';
1818
import { WalStreamReplicator } from '../replication/WalStreamReplicator.js';
1919
import * as types from '../types/types.js';
2020
import { PostgresConnectionConfig } from '../types/types.js';
21-
import { baseUri, NormalizedBasePostgresConnectionConfig } from '@powersync/lib-service-postgres';
22-
import { ReplicationMetric } from '@powersync/service-types';
2321
import { getApplicationName } from '../utils/application-name.js';
2422

2523
export class PostgresModule extends replication.ReplicationModule<types.PostgresConnectionConfig> {
@@ -32,19 +30,6 @@ export class PostgresModule extends replication.ReplicationModule<types.Postgres
3230
}
3331

3432
async onInitialized(context: system.ServiceContextContainer): Promise<void> {
35-
const client_auth = context.configuration.base_config.client_auth;
36-
37-
if (client_auth?.supabase && client_auth?.supabase_jwt_secret == null) {
38-
// Only use the deprecated SupabaseKeyCollector when there is no
39-
// secret hardcoded. Hardcoded secrets are handled elsewhere, using
40-
// StaticSupabaseKeyCollector.
41-
42-
// Support for SupabaseKeyCollector is deprecated and support will be
43-
// completely removed by Supabase soon. We can keep support a while
44-
// longer for self-hosted setups, before also removing that on our side.
45-
this.registerSupabaseAuth(context);
46-
}
47-
4833
// Record replicated bytes using global jpgwire metrics. Only registered if this module is replicating
4934
if (context.replicationEngine) {
5035
jpgwire.setMetricsRecorder({
@@ -110,32 +95,6 @@ export class PostgresModule extends replication.ReplicationModule<types.Postgres
11095
}
11196
}
11297

113-
// TODO: This should rather be done by registering the key collector in some kind of auth engine
114-
private registerSupabaseAuth(context: system.ServiceContextContainer) {
115-
const { configuration } = context;
116-
// Register the Supabase key collector(s)
117-
configuration.connections
118-
?.map((baseConfig) => {
119-
if (baseConfig.type != types.POSTGRES_CONNECTION_TYPE) {
120-
return;
121-
}
122-
try {
123-
return this.resolveConfig(types.PostgresConnectionConfig.decode(baseConfig as any));
124-
} catch (ex) {
125-
this.logger.warn('Failed to decode configuration.', ex);
126-
}
127-
})
128-
.filter((c) => !!c)
129-
.forEach((config) => {
130-
const keyCollector = new SupabaseKeyCollector(config!);
131-
context.lifeCycleEngine.withLifecycle(keyCollector, {
132-
// Close the internal pool
133-
stop: (collector) => collector.shutdown()
134-
});
135-
configuration.client_keystore.collector.add(new auth.CachedKeyCollector(keyCollector));
136-
});
137-
}
138-
13998
async testConnection(config: PostgresConnectionConfig): Promise<ConnectionTestResult> {
14099
this.decodeConfig(config);
141100
const normalizedConfig = this.resolveConfig(this.decodedConfig!);

packages/service-core/src/auth/KeyStore.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import secs from '../util/secs.js';
44
import { JwtPayload } from './JwtPayload.js';
55
import { KeyCollector } from './KeyCollector.js';
66
import { KeyOptions, KeySpec, SUPPORTED_ALGORITHMS } from './KeySpec.js';
7-
import { mapAuthError } from './utils.js';
7+
import { debugKeyNotFound, mapAuthError, SupabaseAuthDetails, tokenDebugDetails } from './utils.js';
88

99
/**
1010
* KeyStore to get keys and verify tokens.
@@ -39,6 +39,29 @@ export class KeyStore<Collector extends KeyCollector = KeyCollector> {
3939
*/
4040
collector: Collector;
4141

42+
/**
43+
* For debug purposes only.
44+
*
45+
* This is very Supabase-specific, but we need the info on this level. For example,
46+
* we want to detect cases where a Supabase token is used, but Supabase auth is not enabled
47+
* (no Supabase collector configured).
48+
*/
49+
supabaseAuthDebug: {
50+
/**
51+
* This can be populated without jwksEnabled, but not the other way around.
52+
*/
53+
jwksDetails: SupabaseAuthDetails | null;
54+
jwksEnabled: boolean;
55+
/**
56+
* This can be enabled without jwksDetails populated.
57+
*/
58+
sharedSecretEnabled: boolean;
59+
} = {
60+
jwksDetails: null,
61+
jwksEnabled: false,
62+
sharedSecretEnabled: false
63+
};
64+
4265
constructor(collector: Collector) {
4366
this.collector = collector;
4467
}
@@ -131,7 +154,7 @@ export class KeyStore<Collector extends KeyCollector = KeyCollector> {
131154
if (!key.matchesAlgorithm(header.alg)) {
132155
throw new AuthorizationError(ErrorCode.PSYNC_S2101, `Unexpected token algorithm ${header.alg}`, {
133156
configurationDetails: `Key kid: ${key.source.kid}, alg: ${key.source.alg}, kty: ${key.source.kty}`
134-
// Token details automatically populated elsewhere
157+
// tokenDetails automatically populated higher up the stack
135158
});
136159
}
137160
return key;
@@ -165,12 +188,13 @@ export class KeyStore<Collector extends KeyCollector = KeyCollector> {
165188
logger.error(`Failed to refresh keys`, e);
166189
});
167190

191+
const details = debugKeyNotFound(this, keys, token);
192+
168193
throw new AuthorizationError(
169194
ErrorCode.PSYNC_S2101,
170195
'Could not find an appropriate key in the keystore. The key is missing or no key matched the token KID',
171196
{
172-
configurationDetails: `Known keys: ${keys.map((key) => key.description).join(', ')}`
173-
// tokenDetails automatically populated later
197+
...details
174198
}
175199
);
176200
}

packages/service-core/src/auth/RemoteJWKSCollector.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import {
1212
ServiceError
1313
} from '@powersync/lib-services-framework';
1414
import { KeyCollector, KeyResult } from './KeyCollector.js';
15-
import { KeySpec } from './KeySpec.js';
15+
import { KeyOptions, KeySpec } from './KeySpec.js';
1616

1717
export type RemoteJWKSCollectorOptions = {
1818
lookupOptions?: LookupOptions;
19+
keyOptions?: KeyOptions;
1920
};
2021

2122
/**
@@ -24,6 +25,7 @@ export type RemoteJWKSCollectorOptions = {
2425
export class RemoteJWKSCollector implements KeyCollector {
2526
private url: URL;
2627
private agent: http.Agent;
28+
private keyOptions: KeyOptions;
2729

2830
constructor(
2931
url: string,
@@ -34,6 +36,7 @@ export class RemoteJWKSCollector implements KeyCollector {
3436
} catch (e: any) {
3537
throw new ServiceError(ErrorCode.PSYNC_S3102, `Invalid jwks_uri: ${JSON.stringify(url)} Details: ${e.message}`);
3638
}
39+
this.keyOptions = options?.keyOptions ?? {};
3740

3841
// We do support http here for self-hosting use cases.
3942
// Management service restricts this to https for hosted versions.
@@ -123,7 +126,7 @@ export class RemoteJWKSCollector implements KeyCollector {
123126
}
124127
}
125128

126-
const key = await KeySpec.importKey(keyData);
129+
const key = await KeySpec.importKey(keyData, this.keyOptions);
127130
keys.push(key);
128131
}
129132

packages/service-core/src/auth/StaticSupabaseKeyCollector.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as jose from 'jose';
22
import { KeySpec, KeyOptions } from './KeySpec.js';
33
import { KeyCollector, KeyResult } from './KeyCollector.js';
44

5-
const SUPABASE_KEY_OPTIONS: KeyOptions = {
5+
export const SUPABASE_KEY_OPTIONS: KeyOptions = {
66
requiresAudience: ['authenticated'],
77
maxLifetimeSeconds: 86400 * 7 + 1200 // 1 week + 20 minutes margin
88
};

0 commit comments

Comments
 (0)