diff --git a/.changeset/clever-kangaroos-thank.md b/.changeset/clever-kangaroos-thank.md new file mode 100644 index 000000000..1e2ab43de --- /dev/null +++ b/.changeset/clever-kangaroos-thank.md @@ -0,0 +1,10 @@ +--- +'@powersync/service-module-postgres': minor +'@powersync/service-rsocket-router': minor +'@powersync/service-errors': minor +'@powersync/service-core': minor +'@powersync/lib-services-framework': minor +'@powersync/service-image': minor +--- + +Improve authentication error messages and logs diff --git a/libs/lib-services/src/router/endpoint.ts b/libs/lib-services/src/router/endpoint.ts index 7bdb970a3..9ab1d80f0 100644 --- a/libs/lib-services/src/router/endpoint.ts +++ b/libs/lib-services/src/router/endpoint.ts @@ -16,7 +16,10 @@ export const executeEndpoint = async { let row: { jwt_secret: string }; try { const rows = pgwire.pgwireRows( @@ -44,7 +45,10 @@ export class SupabaseKeyCollector implements auth.KeyCollector { row = rows[0] as any; } catch (e) { if (e.message?.includes('unrecognized configuration parameter')) { - throw new jose.errors.JOSEError(`Generate a new JWT secret on Supabase. Cause: ${e.message}`); + throw new AuthorizationError( + ErrorCode.PSYNC_S2201, + 'No JWT secret found in Supabase database. Manually configure the secret.' + ); } else { throw e; } @@ -53,7 +57,12 @@ export class SupabaseKeyCollector implements auth.KeyCollector { if (secret == null) { return { keys: [], - errors: [new jose.errors.JWKSNoMatchingKey()] + errors: [ + new AuthorizationError( + ErrorCode.PSYNC_S2201, + 'No JWT secret found in Supabase database. Manually configure the secret.' + ) + ] }; } else { const key: jose.JWK = { diff --git a/packages/rsocket-router/src/router/ReactiveSocketRouter.ts b/packages/rsocket-router/src/router/ReactiveSocketRouter.ts index 25592b107..dcc4cb51f 100644 --- a/packages/rsocket-router/src/router/ReactiveSocketRouter.ts +++ b/packages/rsocket-router/src/router/ReactiveSocketRouter.ts @@ -3,7 +3,7 @@ * to expose reactive websocket stream in an interface similar to * other Journey micro routers. */ -import { errors, logger } from '@powersync/lib-services-framework'; +import { ErrorCode, errors, logger } from '@powersync/lib-services-framework'; import * as http from 'http'; import { Payload, RSocketServer } from 'rsocket-core'; import * as ws from 'ws'; @@ -72,7 +72,7 @@ export class ReactiveSocketRouter { // Throwing an exception in this context will be returned to the client side request if (!payload.metadata) { // Meta data is required for endpoint handler path matching - throw new errors.AuthorizationError('No context meta data provided'); + throw new errors.AuthorizationError(ErrorCode.PSYNC_S2101, 'No context meta data provided'); } const context = await params.contextProvider(payload.metadata!); @@ -166,7 +166,9 @@ export async function handleReactiveStream( responder }); if (!isAuthorized.authorized) { - return exitWithError(new errors.AuthorizationError(isAuthorized.errors)); + return exitWithError( + isAuthorized.error ?? new errors.AuthorizationError(ErrorCode.PSYNC_S2101, 'Authorization failed') + ); } } diff --git a/packages/service-core/src/auth/CachedKeyCollector.ts b/packages/service-core/src/auth/CachedKeyCollector.ts index 2ecf365a0..6ef0a67dc 100644 --- a/packages/service-core/src/auth/CachedKeyCollector.ts +++ b/packages/service-core/src/auth/CachedKeyCollector.ts @@ -3,6 +3,8 @@ import timers from 'timers/promises'; import { KeySpec } from './KeySpec.js'; import { LeakyBucket } from './LeakyBucket.js'; import { KeyCollector, KeyResult } from './KeyCollector.js'; +import { AuthorizationError } from '@powersync/lib-services-framework'; +import { mapAuthConfigError } from './utils.js'; /** * Manages caching and refreshing for a key collector. @@ -39,7 +41,7 @@ export class CachedKeyCollector implements KeyCollector { */ private keyExpiry = 3600000; - private currentErrors: jose.errors.JOSEError[] = []; + private currentErrors: AuthorizationError[] = []; /** * Indicates a "fatal" error that should be retried. */ @@ -103,11 +105,7 @@ export class CachedKeyCollector implements KeyCollector { } catch (e) { this.error = true; // No result - keep previous keys - if (e instanceof jose.errors.JOSEError) { - this.currentErrors = [e]; - } else { - this.currentErrors = [new jose.errors.JOSEError(e.message ?? 'Failed to fetch keys')]; - } + this.currentErrors = [mapAuthConfigError(e)]; } } diff --git a/packages/service-core/src/auth/CompoundKeyCollector.ts b/packages/service-core/src/auth/CompoundKeyCollector.ts index 65c58ffb2..ff7e7614d 100644 --- a/packages/service-core/src/auth/CompoundKeyCollector.ts +++ b/packages/service-core/src/auth/CompoundKeyCollector.ts @@ -1,6 +1,7 @@ import * as jose from 'jose'; import { KeySpec } from './KeySpec.js'; import { KeyCollector, KeyResult } from './KeyCollector.js'; +import { AuthorizationError } from '@powersync/lib-services-framework'; export class CompoundKeyCollector implements KeyCollector { private collectors: KeyCollector[]; @@ -15,7 +16,7 @@ export class CompoundKeyCollector implements KeyCollector { async getKeys(): Promise { let keys: KeySpec[] = []; - let errors: jose.errors.JOSEError[] = []; + let errors: AuthorizationError[] = []; const promises = this.collectors.map((collector) => collector.getKeys().then((result) => { keys.push(...result.keys); diff --git a/packages/service-core/src/auth/KeyCollector.ts b/packages/service-core/src/auth/KeyCollector.ts index 1869d3700..a0d971db6 100644 --- a/packages/service-core/src/auth/KeyCollector.ts +++ b/packages/service-core/src/auth/KeyCollector.ts @@ -1,4 +1,4 @@ -import * as jose from 'jose'; +import { AuthorizationError } from '@powersync/lib-services-framework'; import { KeySpec } from './KeySpec.js'; export interface KeyCollector { @@ -22,6 +22,6 @@ export interface KeyCollector { } export interface KeyResult { - errors: jose.errors.JOSEError[]; + errors: AuthorizationError[]; keys: KeySpec[]; } diff --git a/packages/service-core/src/auth/KeyStore.ts b/packages/service-core/src/auth/KeyStore.ts index aed671a13..aed06e799 100644 --- a/packages/service-core/src/auth/KeyStore.ts +++ b/packages/service-core/src/auth/KeyStore.ts @@ -1,9 +1,10 @@ -import { logger } from '@powersync/lib-services-framework'; +import { logger, errors, AuthorizationError, ErrorCode } from '@powersync/lib-services-framework'; import * as jose from 'jose'; 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'; /** * KeyStore to get keys and verify tokens. @@ -49,7 +50,8 @@ export class KeyStore { clockTolerance: 60, // More specific algorithm checking is done when selecting the key to use. algorithms: SUPPORTED_ALGORITHMS, - requiredClaims: ['aud', 'sub', 'iat', 'exp'] + // 'aud' presence is checked below, so we can add more details to the error message. + requiredClaims: ['sub', 'iat', 'exp'] }); let audiences = options.defaultAudiences; @@ -60,8 +62,12 @@ export class KeyStore { const tokenPayload = result.payload; - let aud = tokenPayload.aud!; - if (!Array.isArray(aud)) { + let aud = tokenPayload.aud; + if (aud == null) { + throw new AuthorizationError(ErrorCode.PSYNC_S2105, `JWT payload is missing a required claim "aud"`, { + configurationDetails: `Current configuration allows these audience values: ${JSON.stringify(audiences)}` + }); + } else if (!Array.isArray(aud)) { aud = [aud]; } if ( @@ -69,7 +75,11 @@ export class KeyStore { return audiences.includes(a); }) ) { - throw new jose.errors.JWTClaimValidationFailed('unexpected "aud" claim value', 'aud', 'check_failed'); + throw new AuthorizationError( + ErrorCode.PSYNC_S2105, + `Unexpected "aud" claim value: ${JSON.stringify(tokenPayload.aud)}`, + { configurationDetails: `Current configuration allows these audience values: ${JSON.stringify(audiences)}` } + ); } const tokenDuration = tokenPayload.exp! - tokenPayload.iat!; @@ -78,12 +88,15 @@ export class KeyStore { // is too far into the future. const maxAge = keyOptions.maxLifetimeSeconds ?? secs(options.maxAge); if (tokenDuration > maxAge) { - throw new jose.errors.JWTInvalid(`Token must expire in a maximum of ${maxAge} seconds, got ${tokenDuration}`); + throw new AuthorizationError( + ErrorCode.PSYNC_S2104, + `Token must expire in a maximum of ${maxAge} seconds, got ${tokenDuration}s` + ); } const parameters = tokenPayload.parameters; if (parameters != null && (Array.isArray(parameters) || typeof parameters != 'object')) { - throw new jose.errors.JWTInvalid('parameters must be an object'); + throw new AuthorizationError(ErrorCode.PSYNC_S2101, `Payload parameters must be an object`); } return tokenPayload as JwtPayload; @@ -91,16 +104,20 @@ export class KeyStore { private async verifyInternal(token: string, options: jose.JWTVerifyOptions) { let keyOptions: KeyOptions | undefined = undefined; - const result = await jose.jwtVerify( - token, - async (header) => { - let key = await this.getCachedKey(token, header); - keyOptions = key.options; - return key.key; - }, - options - ); - return { result, keyOptions: keyOptions! }; + try { + const result = await jose.jwtVerify( + token, + async (header) => { + let key = await this.getCachedKey(token, header); + keyOptions = key.options; + return key.key; + }, + options + ); + return { result, keyOptions: keyOptions! }; + } catch (e) { + throw mapAuthError(e, token); + } } private async getCachedKey(token: string, header: jose.JWTHeaderParameters): Promise { @@ -112,7 +129,10 @@ export class KeyStore { for (let key of keys) { if (key.kid == kid) { if (!key.matchesAlgorithm(header.alg)) { - throw new jose.errors.JOSEAlgNotAllowed(`Unexpected token algorithm ${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 + }); } return key; } @@ -145,8 +165,13 @@ export class KeyStore { logger.error(`Failed to refresh keys`, e); }); - throw new jose.errors.JOSEError( - 'Could not find an appropriate key in the keystore. The key is missing or no key matched the token KID' + 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 kid values: ${keys.map((key) => key.kid ?? '*').join(', ')}` + // tokenDetails automatically populated later + } ); } } diff --git a/packages/service-core/src/auth/RemoteJWKSCollector.ts b/packages/service-core/src/auth/RemoteJWKSCollector.ts index 3e4186a2d..9a1d0fd8e 100644 --- a/packages/service-core/src/auth/RemoteJWKSCollector.ts +++ b/packages/service-core/src/auth/RemoteJWKSCollector.ts @@ -4,6 +4,7 @@ import * as jose from 'jose'; import fetch from 'node-fetch'; import { + AuthorizationError, ErrorCode, LookupOptions, makeHostnameLookupFunction, @@ -46,28 +47,43 @@ export class RemoteJWKSCollector implements KeyCollector { this.agent = this.resolveAgent(); } - async getKeys(): Promise { + private async getJwksData(): Promise { const abortController = new AbortController(); const timeout = setTimeout(() => { abortController.abort(); }, 30_000); - const res = await fetch(this.url, { - method: 'GET', - headers: { - Accept: 'application/json' - }, - signal: abortController.signal, - agent: this.agent - }); - - if (!res.ok) { - throw new jose.errors.JWKSInvalid(`JWKS request failed with ${res.statusText}`); - } + try { + const res = await fetch(this.url, { + method: 'GET', + headers: { + Accept: 'application/json' + }, + signal: abortController.signal, + agent: this.agent + }); + + if (!res.ok) { + throw new AuthorizationError(ErrorCode.PSYNC_S2204, `JWKS request failed with ${res.statusText}`, { + configurationDetails: `JWKS URL: ${this.url}` + }); + } - const data = (await res.json()) as any; + return (await res.json()) as any; + } catch (e) { + throw new AuthorizationError(ErrorCode.PSYNC_S2204, `JWKS request failed`, { + configurationDetails: `JWKS URL: ${this.url}`, + // This covers most cases of FetchError + // `cause: e` could lose the error message + cause: { message: e.message, code: e.code } + }); + } finally { + clearTimeout(timeout); + } + } - clearTimeout(timeout); + async getKeys(): Promise { + const data = await this.getJwksData(); // https://github.com/panva/jose/blob/358e864a0cccf1e0f9928a959f91f18f3f06a7de/src/jwks/local.ts#L36 if ( @@ -75,7 +91,14 @@ export class RemoteJWKSCollector implements KeyCollector { !Array.isArray(data.keys) || !(data.keys as any[]).every((key) => typeof key == 'object' && !Array.isArray(key)) ) { - return { keys: [], errors: [new jose.errors.JWKSInvalid(`No keys in found in JWKS response`)] }; + return { + keys: [], + errors: [ + new AuthorizationError(ErrorCode.PSYNC_S2204, `Invalid JWKS response`, { + configurationDetails: `JWKS URL: ${this.url}. Response:\n${JSON.stringify(data, null, 2)}` + }) + ] + }; } let keys: KeySpec[] = []; diff --git a/packages/service-core/src/auth/auth-index.ts b/packages/service-core/src/auth/auth-index.ts index 1ffa4cb13..faffc853f 100644 --- a/packages/service-core/src/auth/auth-index.ts +++ b/packages/service-core/src/auth/auth-index.ts @@ -8,3 +8,4 @@ export * from './LeakyBucket.js'; export * from './RemoteJWKSCollector.js'; export * from './StaticKeyCollector.js'; export * from './StaticSupabaseKeyCollector.js'; +export * from './utils.js'; diff --git a/packages/service-core/src/auth/utils.ts b/packages/service-core/src/auth/utils.ts new file mode 100644 index 000000000..848c6ecf7 --- /dev/null +++ b/packages/service-core/src/auth/utils.ts @@ -0,0 +1,102 @@ +import { AuthorizationError, ErrorCode } from '@powersync/lib-services-framework'; +import * as jose from 'jose'; + +export function mapJoseError(error: jose.errors.JOSEError, token: string): AuthorizationError { + const tokenDetails = tokenDebugDetails(token); + if (error.code === jose.errors.JWSInvalid.code || error.code === jose.errors.JWTInvalid.code) { + return new AuthorizationError(ErrorCode.PSYNC_S2101, 'Token is not a well-formed JWT. Check the token format.', { + tokenDetails, + cause: error + }); + } else if (error.code === jose.errors.JWTClaimValidationFailed.code) { + // Jose message: missing required "sub" claim + const claim = (error as jose.errors.JWTClaimValidationFailed).claim; + return new AuthorizationError( + ErrorCode.PSYNC_S2101, + `JWT payload is missing a required claim ${JSON.stringify(claim)}`, + { + cause: error, + tokenDetails + } + ); + } else if (error.code == jose.errors.JWTExpired.code) { + // Jose message: "exp" claim timestamp check failed + return new AuthorizationError(ErrorCode.PSYNC_S2103, `JWT has expired`, { + cause: error, + tokenDetails + }); + } + return new AuthorizationError(ErrorCode.PSYNC_S2101, error.message, { cause: error }); +} + +export function mapAuthError(error: any, token: string): AuthorizationError { + if (error instanceof AuthorizationError) { + error.tokenDetails ??= tokenDebugDetails(token); + return error; + } else if (error instanceof jose.errors.JOSEError) { + return mapJoseError(error, token); + } + return new AuthorizationError(ErrorCode.PSYNC_S2101, error.message, { + cause: error, + tokenDetails: tokenDebugDetails(token) + }); +} + +export function mapJoseConfigError(error: jose.errors.JOSEError): AuthorizationError { + return new AuthorizationError(ErrorCode.PSYNC_S2201, error.message ?? 'Authorization error', { cause: error }); +} + +export function mapAuthConfigError(error: any): AuthorizationError { + if (error instanceof AuthorizationError) { + return error; + } else if (error instanceof jose.errors.JOSEError) { + return mapJoseConfigError(error); + } + return new AuthorizationError(ErrorCode.PSYNC_S2201, error.message ?? 'Auth configuration error', { cause: error }); +} + +/** + * Decode token for debugging purposes. + * + * We use this to add details to our logs. We don't log the entire token, since it may for example + * a password incorrectly used as a token. + */ +function tokenDebugDetails(token: string): string { + try { + // For valid tokens, we return the header and payload + const header = jose.decodeProtectedHeader(token); + const payload = jose.decodeJwt(token); + return ``; + } catch (e) { + // Token fails to parse. Return some details. + return invalidTokenDetails(token); + } +} + +function invalidTokenDetails(token: string): string { + const parts = token.split('.'); + if (parts.length !== 3) { + return ``; + } + + const [headerB64, payloadB64, signatureB64] = parts; + + try { + JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf8')); + } catch (e) { + return ``; + } + + try { + JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8')); + } catch (e) { + return ``; + } + try { + Buffer.from(signatureB64, 'base64url'); + } catch (e) { + return ``; + } + + return ``; +} diff --git a/packages/service-core/src/routes/auth.ts b/packages/service-core/src/routes/auth.ts index f6a384b65..81716b945 100644 --- a/packages/service-core/src/routes/auth.ts +++ b/packages/service-core/src/routes/auth.ts @@ -4,6 +4,7 @@ import * as auth from '../auth/auth-index.js'; import { ServiceContext } from '../system/ServiceContext.js'; import * as util from '../util/util-index.js'; import { BasicRouterRequest, Context, RequestEndpointHandlerPayload } from './router.js'; +import { AuthorizationError, AuthorizationResponse, ErrorCode, ServiceError } from '@powersync/lib-services-framework'; export function endpoint(req: BasicRouterRequest) { const protocol = req.headers['x-forwarded-proto'] ?? req.protocol; @@ -95,25 +96,25 @@ export function getTokenFromHeader(authHeader: string = ''): string | null { return token ?? null; } -export const authUser = async (payload: RequestEndpointHandlerPayload) => { +export const authUser = async (payload: RequestEndpointHandlerPayload): Promise => { return authorizeUser(payload.context, payload.request.headers.authorization as string); }; -export async function authorizeUser(context: Context, authHeader: string = '') { +export async function authorizeUser(context: Context, authHeader: string = ''): Promise { const token = getTokenFromHeader(authHeader); if (token == null) { return { authorized: false, - errors: ['Authentication required'] + error: new AuthorizationError(ErrorCode.PSYNC_S2106, 'Authentication required') }; } - const { context: tokenContext, errors } = await generateContext(context.service_context, token); + const { context: tokenContext, tokenError } = await generateContext(context.service_context, token); if (!tokenContext) { return { authorized: false, - errors + error: tokenError }; } @@ -140,7 +141,7 @@ export async function generateContext(serviceContext: ServiceContext, token: str } catch (err) { return { context: null, - errors: [err.message] + tokenError: auth.mapAuthError(err, token) }; } } diff --git a/packages/service-core/src/routes/configure-rsocket.ts b/packages/service-core/src/routes/configure-rsocket.ts index 05572dfc5..3e68877e2 100644 --- a/packages/service-core/src/routes/configure-rsocket.ts +++ b/packages/service-core/src/routes/configure-rsocket.ts @@ -1,7 +1,7 @@ import { deserialize } from 'bson'; import * as http from 'http'; -import { errors, logger } from '@powersync/lib-services-framework'; +import { ErrorCode, errors, logger } from '@powersync/lib-services-framework'; import { ReactiveSocketRouter, RSocketRequestMeta } from '@powersync/service-rsocket-router'; import { ServiceContext } from '../system/ServiceContext.js'; @@ -22,19 +22,19 @@ export function configureRSocket(router: ReactiveSocketRouter, options: const { route_generators = DEFAULT_SOCKET_ROUTES, server, service_context } = options; router.applyWebSocketEndpoints(server, { - contextProvider: async (data: Buffer) => { + contextProvider: async (data: Buffer): Promise => { const { token, user_agent } = RSocketContextMeta.decode(deserialize(data) as any); if (!token) { - throw new errors.AuthorizationError('No token provided'); + throw new errors.AuthorizationError(ErrorCode.PSYNC_S2106, 'No token provided'); } try { const extracted_token = getTokenFromHeader(token); if (extracted_token != null) { - const { context, errors: token_errors } = await generateContext(options.service_context, extracted_token); + const { context, tokenError } = await generateContext(options.service_context, extracted_token); if (context?.token_payload == null) { - throw new errors.AuthorizationError(token_errors ?? 'Authentication required'); + throw new errors.AuthorizationError(ErrorCode.PSYNC_S2106, 'Authentication required'); } if (!service_context.routerEngine) { @@ -45,11 +45,12 @@ export function configureRSocket(router: ReactiveSocketRouter, options: token, user_agent, ...context, - token_errors: token_errors, + token_error: tokenError, service_context: service_context as RouterServiceContext }; } else { - throw new errors.AuthorizationError('No token provided'); + // Token field is present, but did not contain a token. + throw new errors.AuthorizationError(ErrorCode.PSYNC_S2106, 'No valid token provided'); } } catch (ex) { logger.error(ex); diff --git a/packages/service-core/src/routes/router.ts b/packages/service-core/src/routes/router.ts index 98bfd330f..fed544622 100644 --- a/packages/service-core/src/routes/router.ts +++ b/packages/service-core/src/routes/router.ts @@ -1,4 +1,4 @@ -import { router } from '@powersync/lib-services-framework'; +import { router, ServiceError } from '@powersync/lib-services-framework'; import type { JwtPayload } from '../auth/auth-index.js'; import { ServiceContext } from '../system/ServiceContext.js'; import { RouterEngine } from './RouterEngine.js'; @@ -16,7 +16,7 @@ export type Context = { service_context: RouterServiceContext; token_payload?: JwtPayload; - token_errors?: string[]; + token_error?: ServiceError; /** * Only on websocket endpoints. */ diff --git a/packages/service-core/test/src/auth.test.ts b/packages/service-core/test/src/auth.test.ts index cba439aa5..73cdedcca 100644 --- a/packages/service-core/test/src/auth.test.ts +++ b/packages/service-core/test/src/auth.test.ts @@ -75,21 +75,21 @@ describe('JWT Auth', () => { defaultAudiences: ['other'], maxAge: '6m' }) - ).rejects.toThrow('unexpected "aud" claim value'); + ).rejects.toThrow('[PSYNC_S2105] Unexpected "aud" claim value: "tests"'); await expect( store.verifyJwt(signedJwt, { defaultAudiences: [], maxAge: '6m' }) - ).rejects.toThrow('unexpected "aud" claim value'); + ).rejects.toThrow('[PSYNC_S2105] Unexpected "aud" claim value: "tests"'); await expect( store.verifyJwt(signedJwt, { defaultAudiences: ['tests'], maxAge: '1m' }) - ).rejects.toThrow('Token must expire in a maximum of'); + ).rejects.toThrow('[PSYNC_S2104] Token must expire in a maximum of 60 seconds, got 300s'); const signedJwt2 = await new jose.SignJWT({}) .setProtectedHeader({ alg: 'HS256', kid: 'k1' }) @@ -104,7 +104,25 @@ describe('JWT Auth', () => { defaultAudiences: ['tests'], maxAge: '5m' }) - ).rejects.toThrow('missing required "sub" claim'); + ).rejects.toThrow('[PSYNC_S2101] JWT payload is missing a required claim "sub"'); + + // expired token + const d = Math.round(Date.now() / 1000); + const signedJwt3 = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'HS256', kid: 'k1' }) + .setSubject('f1') + .setIssuedAt(d - 500) + .setIssuer('tester') + .setAudience('tests') + .setExpirationTime(d - 400) + .sign(signKey); + + await expect( + store.verifyJwt(signedJwt3, { + defaultAudiences: ['tests'], + maxAge: '5m' + }) + ).rejects.toThrow('[PSYNC_S2103] JWT has expired'); }); test('Algorithm validation', async () => { @@ -159,7 +177,7 @@ describe('JWT Auth', () => { maxAge: '6m' }) ).rejects.toThrow( - 'Could not find an appropriate key in the keystore. The key is missing or no key matched the token KID' + '[PSYNC_S2101] Could not find an appropriate key in the keystore. The key is missing or no key matched the token KID' ); // Wrong kid @@ -178,7 +196,7 @@ describe('JWT Auth', () => { maxAge: '6m' }) ).rejects.toThrow( - 'Could not find an appropriate key in the keystore. The key is missing or no key matched the token KID' + '[PSYNC_S2101] Could not find an appropriate key in the keystore. The key is missing or no key matched the token KID' ); // No kid, matches sharedKey2 @@ -255,7 +273,7 @@ describe('JWT Auth', () => { defaultAudiences: ['tests'], maxAge: '6m' }) - ).rejects.toThrow('unexpected "aud" claim value'); + ).rejects.toThrow('[PSYNC_S2105] Unexpected "aud" claim value: "tests"'); const signedJwt3 = await new jose.SignJWT({}) .setProtectedHeader({ alg: 'HS256', kid: 'k1' }) @@ -290,7 +308,7 @@ describe('JWT Auth', () => { reject_ip_ranges: ['local'] } }); - await expect(invalid.getKeys()).rejects.toThrow('IPs in this range are not supported'); + await expect(invalid.getKeys()).rejects.toThrow('[PSYNC_S2204] JWKS request failed'); // IPs throw an error immediately expect( @@ -345,7 +363,7 @@ describe('JWT Auth', () => { expect(key.kid).toEqual(publicKeyRSA.kid!); cached.addTimeForTests(301_000); - currentResponse = Promise.reject('refresh failed'); + currentResponse = Promise.reject(new Error('refresh failed')); // Uses the promise, refreshes in the background let response = await cached.getKeys(); @@ -357,14 +375,14 @@ describe('JWT Auth', () => { response = await cached.getKeys(); // Still have the cached key, but also have the error expect(response.keys[0].kid).toEqual(publicKeyRSA.kid!); - expect(response.errors[0].message).toMatch('Failed to fetch'); + expect(response.errors[0].message).toMatch('[PSYNC_S2201] refresh failed'); await cached.addTimeForTests(3601_000); response = await cached.getKeys(); // Now the keys have expired, and the request still fails expect(response.keys).toEqual([]); - expect(response.errors[0].message).toMatch('Failed to fetch'); + expect(response.errors[0].message).toMatch('[PSYNC_S2201] refresh failed'); currentResponse = Promise.resolve({ errors: [], diff --git a/packages/service-errors/src/codes.ts b/packages/service-errors/src/codes.ts index d1eef75d0..8b55c5894 100644 --- a/packages/service-errors/src/codes.ts +++ b/packages/service-errors/src/codes.ts @@ -306,8 +306,46 @@ export enum ErrorCode { */ PSYNC_S2101 = 'PSYNC_S2101', + /** + * Could not verify the auth token signature. + * + * Typical causes include: + * 1. Token kid is not found in the keystore. + * 2. Signature does not match the kid in the keystore. + */ + PSYNC_S2102 = 'PSYNC_S2102', + + /** + * Token has expired. Check the expiry date on the token. + */ + PSYNC_S2103 = 'PSYNC_S2103', + + /** + * Token expiration period is too long. Issue shorter-lived tokens. + */ + PSYNC_S2104 = 'PSYNC_S2104', + + /** + * Token audience does not match expected values. + * + * Check the aud value on the token, compared to the audience values allowed in the service config. + */ + PSYNC_S2105 = 'PSYNC_S2105', + + /** + * No token provided. An auth token is required for every request. + * + * The Auhtorization header must start with "Token" or "Bearer", followed by the JWT. + */ + PSYNC_S2106 = 'PSYNC_S2106', + // ## PSYNC_S22xx: Auth integration errors + /** + * Generic auth configuration error. See the message for details. + */ + PSYNC_S2201 = 'PSYNC_S2201', + /** * IPv6 support is not enabled for the JWKS URI. * @@ -322,6 +360,11 @@ export enum ErrorCode { */ PSYNC_S2203 = 'PSYNC_S2203', + /** + * JWKS request failed. + */ + PSYNC_S2204 = 'PSYNC_S2204', + // ## PSYNC_S23xx: Sync API errors /** diff --git a/packages/service-errors/src/errors.ts b/packages/service-errors/src/errors.ts index bfa024ae9..7e6d0bb0b 100644 --- a/packages/service-errors/src/errors.ts +++ b/packages/service-errors/src/errors.ts @@ -160,15 +160,30 @@ export class ReplicationAbortedError extends ServiceError { } export class AuthorizationError extends ServiceError { - static readonly CODE = ErrorCode.PSYNC_S2101; - - constructor(errors: any) { + /** + * String describing the token. Does not contain the full token, but may help with debugging. + * Safe for logs. + */ + tokenDetails: string | undefined; + /** + * String describing related configuration. Should never be returned to the client. + * Safe for logs. + */ + configurationDetails: string | undefined; + + constructor( + code: ErrorCode, + description: string, + options?: { tokenDetails?: string; configurationDetails?: string; cause?: any } + ) { super({ - code: AuthorizationError.CODE, + code, status: 401, - description: 'Authorization failed', - details: errors + description }); + this.cause = options?.cause; + this.tokenDetails = options?.tokenDetails; + this.configurationDetails = options?.configurationDetails; } }