Skip to content

Commit 9dc4e01

Browse files
authored
Improve auth errors (#258)
* Restructure auth errors. * First pass of error message improvements. * Remove old AuthorizationError. * Error tweaks and tests. * Populate token details in error logs. * Further tweaks. * Changeset.
1 parent 1c3f03b commit 9dc4e01

File tree

18 files changed

+340
-87
lines changed

18 files changed

+340
-87
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@powersync/service-module-postgres': minor
3+
'@powersync/service-rsocket-router': minor
4+
'@powersync/service-errors': minor
5+
'@powersync/service-core': minor
6+
'@powersync/lib-services-framework': minor
7+
'@powersync/service-image': minor
8+
---
9+
10+
Improve authentication error messages and logs

libs/lib-services/src/router/endpoint.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ export const executeEndpoint = async <I, O, C, P extends EndpointHandlerPayload<
1616
}
1717
const authorizer_response = await endpoint.authorize?.(payload);
1818
if (authorizer_response && !authorizer_response.authorized) {
19-
throw new errors.AuthorizationError(authorizer_response.errors);
19+
if (authorizer_response.error == null) {
20+
throw new errors.AuthorizationError(errors.ErrorCode.PSYNC_S2101, 'Authorization failed');
21+
}
22+
throw authorizer_response.error;
2023
}
2124

2225
return endpoint.handler(payload);

libs/lib-services/src/router/router-definitions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ServiceError } from '@powersync/service-errors';
12
import { MicroValidator } from '../schema/definitions.js';
23

34
/**
@@ -22,7 +23,7 @@ export type AuthorizationResponse =
2223
}
2324
| {
2425
authorized: false;
25-
errors?: any[];
26+
error?: ServiceError | undefined;
2627
};
2728

2829
/**

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 { AuthorizationError, 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 AuthorizationError(
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 AuthorizationError(
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/rsocket-router/src/router/ReactiveSocketRouter.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* to expose reactive websocket stream in an interface similar to
44
* other Journey micro routers.
55
*/
6-
import { errors, logger } from '@powersync/lib-services-framework';
6+
import { ErrorCode, errors, logger } from '@powersync/lib-services-framework';
77
import * as http from 'http';
88
import { Payload, RSocketServer } from 'rsocket-core';
99
import * as ws from 'ws';
@@ -72,7 +72,7 @@ export class ReactiveSocketRouter<C> {
7272
// Throwing an exception in this context will be returned to the client side request
7373
if (!payload.metadata) {
7474
// Meta data is required for endpoint handler path matching
75-
throw new errors.AuthorizationError('No context meta data provided');
75+
throw new errors.AuthorizationError(ErrorCode.PSYNC_S2101, 'No context meta data provided');
7676
}
7777

7878
const context = await params.contextProvider(payload.metadata!);
@@ -166,7 +166,9 @@ export async function handleReactiveStream<Context>(
166166
responder
167167
});
168168
if (!isAuthorized.authorized) {
169-
return exitWithError(new errors.AuthorizationError(isAuthorized.errors));
169+
return exitWithError(
170+
isAuthorized.error ?? new errors.AuthorizationError(ErrorCode.PSYNC_S2101, 'Authorization failed')
171+
);
170172
}
171173
}
172174

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 { AuthorizationError } 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: AuthorizationError[] = [];
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 { AuthorizationError } 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: AuthorizationError[] = [];
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 { AuthorizationError } 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: AuthorizationError[];
2626
keys: KeySpec[];
2727
}

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

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { logger } from '@powersync/lib-services-framework';
1+
import { logger, errors, AuthorizationError, 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';
55
import { KeyCollector } from './KeyCollector.js';
66
import { KeyOptions, KeySpec, SUPPORTED_ALGORITHMS } from './KeySpec.js';
7+
import { mapAuthError } from './utils.js';
78

89
/**
910
* KeyStore to get keys and verify tokens.
@@ -49,7 +50,8 @@ export class KeyStore<Collector extends KeyCollector = KeyCollector> {
4950
clockTolerance: 60,
5051
// More specific algorithm checking is done when selecting the key to use.
5152
algorithms: SUPPORTED_ALGORITHMS,
52-
requiredClaims: ['aud', 'sub', 'iat', 'exp']
53+
// 'aud' presence is checked below, so we can add more details to the error message.
54+
requiredClaims: ['sub', 'iat', 'exp']
5355
});
5456

5557
let audiences = options.defaultAudiences;
@@ -60,16 +62,24 @@ export class KeyStore<Collector extends KeyCollector = KeyCollector> {
6062

6163
const tokenPayload = result.payload;
6264

63-
let aud = tokenPayload.aud!;
64-
if (!Array.isArray(aud)) {
65+
let aud = tokenPayload.aud;
66+
if (aud == null) {
67+
throw new AuthorizationError(ErrorCode.PSYNC_S2105, `JWT payload is missing a required claim "aud"`, {
68+
configurationDetails: `Current configuration allows these audience values: ${JSON.stringify(audiences)}`
69+
});
70+
} else if (!Array.isArray(aud)) {
6571
aud = [aud];
6672
}
6773
if (
6874
!aud.some((a) => {
6975
return audiences.includes(a);
7076
})
7177
) {
72-
throw new jose.errors.JWTClaimValidationFailed('unexpected "aud" claim value', 'aud', 'check_failed');
78+
throw new AuthorizationError(
79+
ErrorCode.PSYNC_S2105,
80+
`Unexpected "aud" claim value: ${JSON.stringify(tokenPayload.aud)}`,
81+
{ configurationDetails: `Current configuration allows these audience values: ${JSON.stringify(audiences)}` }
82+
);
7383
}
7484

7585
const tokenDuration = tokenPayload.exp! - tokenPayload.iat!;
@@ -78,29 +88,36 @@ export class KeyStore<Collector extends KeyCollector = KeyCollector> {
7888
// is too far into the future.
7989
const maxAge = keyOptions.maxLifetimeSeconds ?? secs(options.maxAge);
8090
if (tokenDuration > maxAge) {
81-
throw new jose.errors.JWTInvalid(`Token must expire in a maximum of ${maxAge} seconds, got ${tokenDuration}`);
91+
throw new AuthorizationError(
92+
ErrorCode.PSYNC_S2104,
93+
`Token must expire in a maximum of ${maxAge} seconds, got ${tokenDuration}s`
94+
);
8295
}
8396

8497
const parameters = tokenPayload.parameters;
8598
if (parameters != null && (Array.isArray(parameters) || typeof parameters != 'object')) {
86-
throw new jose.errors.JWTInvalid('parameters must be an object');
99+
throw new AuthorizationError(ErrorCode.PSYNC_S2101, `Payload parameters must be an object`);
87100
}
88101

89102
return tokenPayload as JwtPayload;
90103
}
91104

92105
private async verifyInternal(token: string, options: jose.JWTVerifyOptions) {
93106
let keyOptions: KeyOptions | undefined = undefined;
94-
const result = await jose.jwtVerify(
95-
token,
96-
async (header) => {
97-
let key = await this.getCachedKey(token, header);
98-
keyOptions = key.options;
99-
return key.key;
100-
},
101-
options
102-
);
103-
return { result, keyOptions: keyOptions! };
107+
try {
108+
const result = await jose.jwtVerify(
109+
token,
110+
async (header) => {
111+
let key = await this.getCachedKey(token, header);
112+
keyOptions = key.options;
113+
return key.key;
114+
},
115+
options
116+
);
117+
return { result, keyOptions: keyOptions! };
118+
} catch (e) {
119+
throw mapAuthError(e, token);
120+
}
104121
}
105122

106123
private async getCachedKey(token: string, header: jose.JWTHeaderParameters): Promise<KeySpec> {
@@ -112,7 +129,10 @@ export class KeyStore<Collector extends KeyCollector = KeyCollector> {
112129
for (let key of keys) {
113130
if (key.kid == kid) {
114131
if (!key.matchesAlgorithm(header.alg)) {
115-
throw new jose.errors.JOSEAlgNotAllowed(`Unexpected token algorithm ${header.alg}`);
132+
throw new AuthorizationError(ErrorCode.PSYNC_S2101, `Unexpected token algorithm ${header.alg}`, {
133+
configurationDetails: `Key kid: ${key.source.kid}, alg: ${key.source.alg}, kty: ${key.source.kty}`
134+
// Token details automatically populated elsewhere
135+
});
116136
}
117137
return key;
118138
}
@@ -145,8 +165,13 @@ export class KeyStore<Collector extends KeyCollector = KeyCollector> {
145165
logger.error(`Failed to refresh keys`, e);
146166
});
147167

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'
168+
throw new AuthorizationError(
169+
ErrorCode.PSYNC_S2101,
170+
'Could not find an appropriate key in the keystore. The key is missing or no key matched the token KID',
171+
{
172+
configurationDetails: `Known kid values: ${keys.map((key) => key.kid ?? '*').join(', ')}`
173+
// tokenDetails automatically populated later
174+
}
150175
);
151176
}
152177
}

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

Lines changed: 39 additions & 16 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+
AuthorizationError,
78
ErrorCode,
89
LookupOptions,
910
makeHostnameLookupFunction,
@@ -46,36 +47,58 @@ export class RemoteJWKSCollector implements KeyCollector {
4647
this.agent = this.resolveAgent();
4748
}
4849

49-
async getKeys(): Promise<KeyResult> {
50+
private async getJwksData(): Promise<any> {
5051
const abortController = new AbortController();
5152
const timeout = setTimeout(() => {
5253
abortController.abort();
5354
}, 30_000);
5455

55-
const res = await fetch(this.url, {
56-
method: 'GET',
57-
headers: {
58-
Accept: 'application/json'
59-
},
60-
signal: abortController.signal,
61-
agent: this.agent
62-
});
63-
64-
if (!res.ok) {
65-
throw new jose.errors.JWKSInvalid(`JWKS request failed with ${res.statusText}`);
66-
}
56+
try {
57+
const res = await fetch(this.url, {
58+
method: 'GET',
59+
headers: {
60+
Accept: 'application/json'
61+
},
62+
signal: abortController.signal,
63+
agent: this.agent
64+
});
65+
66+
if (!res.ok) {
67+
throw new AuthorizationError(ErrorCode.PSYNC_S2204, `JWKS request failed with ${res.statusText}`, {
68+
configurationDetails: `JWKS URL: ${this.url}`
69+
});
70+
}
6771

68-
const data = (await res.json()) as any;
72+
return (await res.json()) as any;
73+
} catch (e) {
74+
throw new AuthorizationError(ErrorCode.PSYNC_S2204, `JWKS request failed`, {
75+
configurationDetails: `JWKS URL: ${this.url}`,
76+
// This covers most cases of FetchError
77+
// `cause: e` could lose the error message
78+
cause: { message: e.message, code: e.code }
79+
});
80+
} finally {
81+
clearTimeout(timeout);
82+
}
83+
}
6984

70-
clearTimeout(timeout);
85+
async getKeys(): Promise<KeyResult> {
86+
const data = await this.getJwksData();
7187

7288
// https://github.com/panva/jose/blob/358e864a0cccf1e0f9928a959f91f18f3f06a7de/src/jwks/local.ts#L36
7389
if (
7490
data.keys == null ||
7591
!Array.isArray(data.keys) ||
7692
!(data.keys as any[]).every((key) => typeof key == 'object' && !Array.isArray(key))
7793
) {
78-
return { keys: [], errors: [new jose.errors.JWKSInvalid(`No keys in found in JWKS response`)] };
94+
return {
95+
keys: [],
96+
errors: [
97+
new AuthorizationError(ErrorCode.PSYNC_S2204, `Invalid JWKS response`, {
98+
configurationDetails: `JWKS URL: ${this.url}. Response:\n${JSON.stringify(data, null, 2)}`
99+
})
100+
]
101+
};
79102
}
80103

81104
let keys: KeySpec[] = [];

0 commit comments

Comments
 (0)