Skip to content

Commit 99e84eb

Browse files
committed
First pass of error message improvements.
1 parent a438f76 commit 99e84eb

File tree

11 files changed

+113
-47
lines changed

11 files changed

+113
-47
lines changed

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

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import * as lib_postgres from '@powersync/lib-service-postgres';
2-
import { auth } from '@powersync/service-core';
2+
import { auth, KeyResult } from '@powersync/service-core';
33
import * as pgwire from '@powersync/service-jpgwire';
44
import * as jose from 'jose';
55

66
import * as types from '../types/types.js';
7+
import { AuthorizationError2, ErrorCode } from '@powersync/lib-services-framework';
78

89
/**
910
* Fetches key from the Supabase database.
1011
*
1112
* Unfortunately, despite the JWTs containing a kid, we have no way to lookup that kid
1213
* before receiving a valid token.
1314
*
14-
* @deprecated Supabase is removing support for "app.settings.jwt_secret".
15+
* @deprecated Supabase is removing support for "app.settings.jwt_secret". This is likely to not function anymore, except in some self-hosted setups.
1516
*/
1617
export class SupabaseKeyCollector implements auth.KeyCollector {
1718
private pool: pgwire.PgClient;
@@ -35,7 +36,7 @@ export class SupabaseKeyCollector implements auth.KeyCollector {
3536
return this.pool.end();
3637
}
3738

38-
async getKeys() {
39+
async getKeys(): Promise<KeyResult> {
3940
let row: { jwt_secret: string };
4041
try {
4142
const rows = pgwire.pgwireRows(
@@ -44,7 +45,10 @@ export class SupabaseKeyCollector implements auth.KeyCollector {
4445
row = rows[0] as any;
4546
} catch (e) {
4647
if (e.message?.includes('unrecognized configuration parameter')) {
47-
throw new jose.errors.JOSEError(`Generate a new JWT secret on Supabase. Cause: ${e.message}`);
48+
throw new AuthorizationError2(
49+
ErrorCode.PSYNC_S2201,
50+
'No JWT secret found in Supabase database. Manually configure the secret.'
51+
);
4852
} else {
4953
throw e;
5054
}
@@ -53,7 +57,12 @@ export class SupabaseKeyCollector implements auth.KeyCollector {
5357
if (secret == null) {
5458
return {
5559
keys: [],
56-
errors: [new jose.errors.JWKSNoMatchingKey()]
60+
errors: [
61+
new AuthorizationError2(
62+
ErrorCode.PSYNC_S2201,
63+
'No JWT secret found in Supabase database. Manually configure the secret.'
64+
)
65+
]
5766
};
5867
} else {
5968
const key: jose.JWK = {

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import timers from 'timers/promises';
33
import { KeySpec } from './KeySpec.js';
44
import { LeakyBucket } from './LeakyBucket.js';
55
import { KeyCollector, KeyResult } from './KeyCollector.js';
6+
import { AuthorizationError2 } from '@powersync/lib-services-framework';
7+
import { mapAuthConfigError } from './utils.js';
68

79
/**
810
* Manages caching and refreshing for a key collector.
@@ -39,7 +41,7 @@ export class CachedKeyCollector implements KeyCollector {
3941
*/
4042
private keyExpiry = 3600000;
4143

42-
private currentErrors: jose.errors.JOSEError[] = [];
44+
private currentErrors: AuthorizationError2[] = [];
4345
/**
4446
* Indicates a "fatal" error that should be retried.
4547
*/
@@ -103,11 +105,7 @@ export class CachedKeyCollector implements KeyCollector {
103105
} catch (e) {
104106
this.error = true;
105107
// No result - keep previous keys
106-
if (e instanceof jose.errors.JOSEError) {
107-
this.currentErrors = [e];
108-
} else {
109-
this.currentErrors = [new jose.errors.JOSEError(e.message ?? 'Failed to fetch keys')];
110-
}
108+
this.currentErrors = [mapAuthConfigError(e)];
111109
}
112110
}
113111

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as jose from 'jose';
22
import { KeySpec } from './KeySpec.js';
33
import { KeyCollector, KeyResult } from './KeyCollector.js';
4+
import { AuthorizationError2 } from '@powersync/lib-services-framework';
45

56
export class CompoundKeyCollector implements KeyCollector {
67
private collectors: KeyCollector[];
@@ -15,7 +16,7 @@ export class CompoundKeyCollector implements KeyCollector {
1516

1617
async getKeys(): Promise<KeyResult> {
1718
let keys: KeySpec[] = [];
18-
let errors: jose.errors.JOSEError[] = [];
19+
let errors: AuthorizationError2[] = [];
1920
const promises = this.collectors.map((collector) =>
2021
collector.getKeys().then((result) => {
2122
keys.push(...result.keys);

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import * as jose from 'jose';
1+
import { AuthorizationError2 } from '@powersync/lib-services-framework';
22
import { KeySpec } from './KeySpec.js';
33

44
export interface KeyCollector {
@@ -22,6 +22,6 @@ export interface KeyCollector {
2222
}
2323

2424
export interface KeyResult {
25-
errors: jose.errors.JOSEError[];
25+
errors: AuthorizationError2[];
2626
keys: KeySpec[];
2727
}

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

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { logger } from '@powersync/lib-services-framework';
1+
import { logger, errors, AuthorizationError2, ErrorCode } from '@powersync/lib-services-framework';
22
import * as jose from 'jose';
33
import secs from '../util/secs.js';
44
import { JwtPayload } from './JwtPayload.js';
@@ -69,7 +69,11 @@ export class KeyStore<Collector extends KeyCollector = KeyCollector> {
6969
return audiences.includes(a);
7070
})
7171
) {
72-
throw new jose.errors.JWTClaimValidationFailed('unexpected "aud" claim value', 'aud', 'check_failed');
72+
throw new AuthorizationError2(
73+
ErrorCode.PSYNC_S2105,
74+
`Unexpected "aud" claim value: ${JSON.stringify(tokenPayload.aud)}`,
75+
{ sensitiveDetails: `Current configuration allows these audience values: ${JSON.stringify(audiences)}` }
76+
);
7377
}
7478

7579
const tokenDuration = tokenPayload.exp! - tokenPayload.iat!;
@@ -78,12 +82,15 @@ export class KeyStore<Collector extends KeyCollector = KeyCollector> {
7882
// is too far into the future.
7983
const maxAge = keyOptions.maxLifetimeSeconds ?? secs(options.maxAge);
8084
if (tokenDuration > maxAge) {
81-
throw new jose.errors.JWTInvalid(`Token must expire in a maximum of ${maxAge} seconds, got ${tokenDuration}`);
85+
throw new AuthorizationError2(
86+
ErrorCode.PSYNC_S2104,
87+
`Token must expire in a maximum of ${maxAge} seconds, got ${tokenDuration}s`
88+
);
8289
}
8390

8491
const parameters = tokenPayload.parameters;
8592
if (parameters != null && (Array.isArray(parameters) || typeof parameters != 'object')) {
86-
throw new jose.errors.JWTInvalid('parameters must be an object');
93+
throw new AuthorizationError2(ErrorCode.PSYNC_S2101, `Payload parameters must be an object`);
8794
}
8895

8996
return tokenPayload as JwtPayload;
@@ -112,7 +119,9 @@ export class KeyStore<Collector extends KeyCollector = KeyCollector> {
112119
for (let key of keys) {
113120
if (key.kid == kid) {
114121
if (!key.matchesAlgorithm(header.alg)) {
115-
throw new jose.errors.JOSEAlgNotAllowed(`Unexpected token algorithm ${header.alg}`);
122+
throw new AuthorizationError2(ErrorCode.PSYNC_S2101, `Unexpected token algorithm ${header.alg}`, {
123+
sensitiveDetails: `Key kid: ${key.source.kid}, alg: ${key.source.alg}, kty: ${key.source.kty}`
124+
});
116125
}
117126
return key;
118127
}
@@ -145,8 +154,12 @@ export class KeyStore<Collector extends KeyCollector = KeyCollector> {
145154
logger.error(`Failed to refresh keys`, e);
146155
});
147156

148-
throw new jose.errors.JOSEError(
149-
'Could not find an appropriate key in the keystore. The key is missing or no key matched the token KID'
157+
throw new AuthorizationError2(
158+
ErrorCode.PSYNC_S2101,
159+
'Could not find an appropriate key in the keystore. The key is missing or no key matched the token KID',
160+
{
161+
sensitiveDetails: `Token kid: ${kid}, token algorithm: ${header.alg}, known kid values: ${keys.map((key) => key.kid ?? '*').join(', ')}`
162+
}
150163
);
151164
}
152165
}

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as jose from 'jose';
44
import fetch from 'node-fetch';
55

66
import {
7+
AuthorizationError2,
78
ErrorCode,
89
LookupOptions,
910
makeHostnameLookupFunction,
@@ -62,7 +63,9 @@ export class RemoteJWKSCollector implements KeyCollector {
6263
});
6364

6465
if (!res.ok) {
65-
throw new jose.errors.JWKSInvalid(`JWKS request failed with ${res.statusText}`);
66+
throw new AuthorizationError2(ErrorCode.PSYNC_S2204, `JWKS request failed with ${res.statusText}`, {
67+
sensitiveDetails: `JWKS URL: ${this.url}`
68+
});
6669
}
6770

6871
const data = (await res.json()) as any;
@@ -75,7 +78,14 @@ export class RemoteJWKSCollector implements KeyCollector {
7578
!Array.isArray(data.keys) ||
7679
!(data.keys as any[]).every((key) => typeof key == 'object' && !Array.isArray(key))
7780
) {
78-
return { keys: [], errors: [new jose.errors.JWKSInvalid(`No keys in found in JWKS response`)] };
81+
return {
82+
keys: [],
83+
errors: [
84+
new AuthorizationError2(ErrorCode.PSYNC_S2204, `JWKS request failed with ${res.statusText}`, {
85+
sensitiveDetails: `JWKS URL: ${this.url}. Response:\n${JSON.stringify(data, null, 2)}`
86+
})
87+
]
88+
};
7989
}
8090

8191
let keys: KeySpec[] = [];

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export * from './LeakyBucket.js';
88
export * from './RemoteJWKSCollector.js';
99
export * from './StaticKeyCollector.js';
1010
export * from './StaticSupabaseKeyCollector.js';
11+
export * from './utils.js';
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { AuthorizationError2, ErrorCode } from '@powersync/lib-services-framework';
2+
import * as jose from 'jose';
3+
4+
export function mapJoseError(error: jose.errors.JOSEError): AuthorizationError2 {
5+
// TODO: improved message for exp issues, etc
6+
if (error.code === 'ERR_JWS_INVALID' || error.code === 'ERR_JWT_INVALID') {
7+
throw new AuthorizationError2(ErrorCode.PSYNC_S2101, 'Token is not a well-formed JWT. Check the token format.', {
8+
details: error.message
9+
});
10+
}
11+
return new AuthorizationError2(ErrorCode.PSYNC_S2101, error.message, { cause: error });
12+
}
13+
14+
export function mapAuthError(error: any): AuthorizationError2 {
15+
if (error instanceof AuthorizationError2) {
16+
return error;
17+
} else if (error instanceof jose.errors.JOSEError) {
18+
return mapJoseError(error);
19+
}
20+
return new AuthorizationError2(ErrorCode.PSYNC_S2101, error.message, { cause: error });
21+
}
22+
23+
export function mapJoseConfigError(error: jose.errors.JOSEError): AuthorizationError2 {
24+
return new AuthorizationError2(ErrorCode.PSYNC_S2201, error.message, { cause: error });
25+
}
26+
27+
export function mapAuthConfigError(error: any): AuthorizationError2 {
28+
if (error instanceof AuthorizationError2) {
29+
return error;
30+
} else if (error instanceof jose.errors.JOSEError) {
31+
return mapJoseConfigError(error);
32+
}
33+
return new AuthorizationError2(ErrorCode.PSYNC_S2201, error.message, { cause: error });
34+
}

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

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export async function authorizeUser(context: Context, authHeader: string = ''):
105105
if (token == null) {
106106
return {
107107
authorized: false,
108-
error: new AuthorizationError2(ErrorCode.PSYNC_S2115, 'Authentication required')
108+
error: new AuthorizationError2(ErrorCode.PSYNC_S2106, 'Authentication required')
109109
};
110110
}
111111

@@ -139,20 +139,10 @@ export async function generateContext(serviceContext: ServiceContext, token: str
139139
}
140140
};
141141
} catch (err) {
142-
if (err instanceof ServiceError) {
143-
return {
144-
context: null,
145-
tokenError: err
146-
};
147-
} else {
148-
return {
149-
context: null,
150-
tokenError: new AuthorizationError2(ErrorCode.PSYNC_S2101, 'Authentication error', {
151-
details: err.message,
152-
cause: err
153-
})
154-
};
155-
}
142+
return {
143+
context: null,
144+
tokenError: auth.mapAuthError(err)
145+
};
156146
}
157147
}
158148

packages/service-core/src/routes/configure-rsocket.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,15 @@ export function configureRSocket(router: ReactiveSocketRouter<Context>, options:
2626
const { token, user_agent } = RSocketContextMeta.decode(deserialize(data) as any);
2727

2828
if (!token) {
29-
throw new errors.AuthorizationError2(ErrorCode.PSYNC_S2115, 'No token provided');
29+
throw new errors.AuthorizationError2(ErrorCode.PSYNC_S2106, 'No token provided');
3030
}
3131

3232
try {
3333
const extracted_token = getTokenFromHeader(token);
3434
if (extracted_token != null) {
3535
const { context, tokenError } = await generateContext(options.service_context, extracted_token);
3636
if (context?.token_payload == null) {
37-
throw new errors.AuthorizationError2(ErrorCode.PSYNC_S2115, 'Authentication required');
37+
throw new errors.AuthorizationError2(ErrorCode.PSYNC_S2106, 'Authentication required');
3838
}
3939

4040
if (!service_context.routerEngine) {
@@ -50,7 +50,7 @@ export function configureRSocket(router: ReactiveSocketRouter<Context>, options:
5050
};
5151
} else {
5252
// Token field is present, but did not contain a token.
53-
throw new errors.AuthorizationError2(ErrorCode.PSYNC_S2115, 'No valid token provided');
53+
throw new errors.AuthorizationError2(ErrorCode.PSYNC_S2106, 'No valid token provided');
5454
}
5555
} catch (ex) {
5656
logger.error(ex);

0 commit comments

Comments
 (0)