Skip to content

Commit 7fb29c5

Browse files
feat(lib): Updated error types (#362)
* feat(lib): Updated error types - Untyped `Error` objects indicate a likely bug in the library itself - The messages should be prefixed `internal: ` - Samples include bad resource management or missing values in fields that should be const. - `TdfError` should be the root for all errors an application might theoretically screen for to find out if something is wrong in their application that might be caused by TDF or this library. Includes a novel `code` field to allow tracking based on a unique(?) error code - `ConfigurationError` should be able to be fixed by updating the application code. - `InvalidFileError` indicates that a file is likely tampered with or corrupt, although for some errors this may also indicate something is wrong with the user KAS. There are several subtypes when there may be changes to the configuration or user settings that could potentially fix the issue. - `DecryptError` may indicate that the key is incorrect; this could be caused by using a remote or CKS key is out of date, indicating a failure on the server side, but at the moment we have not implemented this. - `IntegrityError` indicates that the segment or global hash is incorrect. This could indicate a file was generated with a deprecated library that uses a different hash calculation. - `UnsafeUrlError` indicates that one or more required Key Access Objects refers to a remote KAS that is not in the allowlist. You can manually check the URL and add it to the allowlist in the client constructor if is a supported KAS. - `NetworkError` indicates a network connectivity error (e.g. during rewrap or key lookup), or a 5xx error on a service - `UnauthenticatedError` indicates that the Bearer token or a required DPoP was not attached to a request. This is often fixable with a mix of IdP/OAuth configuration changes and changes to the application or by adding custom middleware or some combination of all these. - `PermissionDeniedError` indicates that a service (rewrap or public key) has denied access, either due to traditional login (bearer token insufficient scope is one possibility) or due to ABAC (client entity does not have sufficient attributes for policy) - `UnsupportedFeatureError` indicates that an enum in the file or a KAS requirement is not met, e.g. KAS uses an unsupported EC curve, or the TDF file embeds such a curve; could indicate the file was generated with a newer, experimental, or deprecated/removed feature.
1 parent 386ec6f commit 7fb29c5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+677
-549
lines changed

cli/package-lock.json

Lines changed: 14 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/src/access.ts

Lines changed: 80 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
import { type AuthProvider } from './auth/auth.js';
2+
import {
3+
InvalidFileError,
4+
NetworkError,
5+
PermissionDeniedError,
6+
ServiceError,
7+
UnauthenticatedError,
8+
} from './errors.js';
29
import { pemToCryptoPublicKey, validateSecureUrl } from './utils.js';
310

411
export class RewrapRequest {
@@ -32,22 +39,40 @@ export async function fetchWrappedKey(
3239
},
3340
body: JSON.stringify(requestBody),
3441
});
35-
const response = await fetch(req.url, {
36-
method: req.method,
37-
mode: 'cors', // no-cors, *cors, same-origin
38-
cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
39-
credentials: 'same-origin', // include, *same-origin, omit
40-
headers: req.headers,
41-
redirect: 'follow', // manual, *follow, error
42-
referrerPolicy: 'no-referrer', // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url
43-
body: req.body as BodyInit,
44-
});
4542

46-
if (!response.ok) {
47-
throw new Error(`${req.method} ${req.url} => ${response.status} ${response.statusText}`);
48-
}
43+
try {
44+
const response = await fetch(req.url, {
45+
method: req.method,
46+
mode: 'cors', // no-cors, *cors, same-origin
47+
cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
48+
credentials: 'same-origin', // include, *same-origin, omit
49+
headers: req.headers,
50+
redirect: 'follow', // manual, *follow, error
51+
referrerPolicy: 'no-referrer', // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url
52+
body: req.body as BodyInit,
53+
});
4954

50-
return response.json();
55+
if (!response.ok) {
56+
switch (response.status) {
57+
case 400:
58+
throw new InvalidFileError(
59+
`400 for [${req.url}]: rewrap failure [${await response.text()}]`
60+
);
61+
case 401:
62+
throw new UnauthenticatedError(`401 for [${req.url}]`);
63+
case 403:
64+
throw new PermissionDeniedError(`403 for [${req.url}]`);
65+
default:
66+
throw new NetworkError(
67+
`${req.method} ${req.url} => ${response.status} ${response.statusText}`
68+
);
69+
}
70+
}
71+
72+
return response.json();
73+
} catch (e) {
74+
throw new NetworkError(`unable to fetch wrapped key from [${url}]: ${e}`);
75+
}
5176
}
5277

5378
export type KasPublicKeyAlgorithm = 'ec:secp256r1' | 'rsa:2048';
@@ -75,43 +100,71 @@ export type KasPublicKeyInfo = {
75100
key: Promise<CryptoKey>;
76101
};
77102

103+
async function noteInvalidPublicKey(url: string, r: Promise<CryptoKey>): Promise<CryptoKey> {
104+
try {
105+
return await r;
106+
} catch (e) {
107+
if (e instanceof TypeError) {
108+
throw new ServiceError(`invalid public key from [${url}]`, e);
109+
}
110+
throw e;
111+
}
112+
}
113+
78114
/**
79115
* If we have KAS url but not public key we can fetch it from KAS, fetching
80116
* the value from `${kas}/kas_public_key`.
81117
*/
82118
export async function fetchECKasPubKey(kasEndpoint: string): Promise<KasPublicKeyInfo> {
83119
validateSecureUrl(kasEndpoint);
84120
const pkUrlV2 = `${kasEndpoint}/v2/kas_public_key?algorithm=ec:secp256r1&v=2`;
85-
const kasPubKeyResponse = await fetch(pkUrlV2);
86-
if (!kasPubKeyResponse.ok) {
87-
if (kasPubKeyResponse.status != 404) {
88-
throw new Error(
89-
`unable to load KAS public key from [${pkUrlV2}]. Received [${kasPubKeyResponse.status}:${kasPubKeyResponse.statusText}]`
90-
);
121+
const kasPubKeyResponseV2 = await fetch(pkUrlV2);
122+
if (!kasPubKeyResponseV2.ok) {
123+
switch (kasPubKeyResponseV2.status) {
124+
case 404:
125+
// v2 not implemented, perhaps a legacy server
126+
break;
127+
case 401:
128+
throw new UnauthenticatedError(`401 for [${pkUrlV2}]`);
129+
case 403:
130+
throw new PermissionDeniedError(`403 for [${pkUrlV2}]`);
131+
default:
132+
throw new NetworkError(
133+
`${pkUrlV2} => ${kasPubKeyResponseV2.status} ${kasPubKeyResponseV2.statusText}`
134+
);
91135
}
92136
// most likely a server that does not implement v2 endpoint, so no key identifier
93137
const pkUrlV1 = `${kasEndpoint}/kas_public_key?algorithm=ec:secp256r1`;
94138
const r2 = await fetch(pkUrlV1);
95139
if (!r2.ok) {
96-
throw new Error(
97-
`unable to load KAS public key from [${pkUrlV1}]. Received [${r2.status}:${r2.statusText}]`
98-
);
140+
switch (r2.status) {
141+
case 401:
142+
throw new UnauthenticatedError(`401 for [${pkUrlV2}]`);
143+
case 403:
144+
throw new PermissionDeniedError(`403 for [${pkUrlV2}]`);
145+
default:
146+
throw new NetworkError(
147+
`unable to load KAS public key from [${pkUrlV1}]. Received [${r2.status}:${r2.statusText}]`
148+
);
149+
}
99150
}
100151
const pem = await r2.json();
101152
return {
102-
key: pemToCryptoPublicKey(pem),
153+
key: noteInvalidPublicKey(pkUrlV1, pemToCryptoPublicKey(pem)),
103154
publicKey: pem,
104155
url: kasEndpoint,
105156
algorithm: 'ec:secp256r1',
106157
};
107158
}
108-
const jsonContent = await kasPubKeyResponse.json();
159+
const jsonContent = await kasPubKeyResponseV2.json();
109160
const { publicKey, kid }: KasPublicKeyInfo = jsonContent;
110161
if (!publicKey) {
111-
throw new Error(`Invalid response from public key endpoint [${JSON.stringify(jsonContent)}]`);
162+
throw new NetworkError(
163+
`invalid response from public key endpoint [${JSON.stringify(jsonContent)}]`
164+
);
112165
}
113166
return {
114-
key: pemToCryptoPublicKey(publicKey),
167+
key: noteInvalidPublicKey(pkUrlV2, pemToCryptoPublicKey(publicKey)),
115168
publicKey,
116169
url: kasEndpoint,
117170
algorithm: 'ec:secp256r1',

lib/src/auth/oidc-clientcredentials-provider.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ConfigurationError } from '../errors.js';
12
import { AuthProvider, type HttpRequest } from './auth.js';
23
import { AccessToken, type ClientSecretCredentials } from './oidc.js';
34

@@ -10,9 +11,7 @@ export class OIDCClientCredentialsProvider implements AuthProvider {
1011
oidcOrigin,
1112
}: Partial<ClientSecretCredentials> & Omit<ClientSecretCredentials, 'exchange'>) {
1213
if (!clientId || !clientSecret) {
13-
throw new Error(
14-
'To use this nonbrowser-only provider you must supply clientId & clientSecret'
15-
);
14+
throw new ConfigurationError('clientId & clientSecret required for client credentials flow');
1615
}
1716

1817
this.oidcAuth = new AccessToken({

lib/src/auth/oidc-externaljwt-provider.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ConfigurationError } from '../errors.js';
12
import { type AuthProvider, type HttpRequest } from './auth.js';
23
import { AccessToken, type ExternalJwtCredentials } from './oidc.js';
34

@@ -11,9 +12,7 @@ export class OIDCExternalJwtProvider implements AuthProvider {
1112
oidcOrigin,
1213
}: Partial<ExternalJwtCredentials> & Omit<ExternalJwtCredentials, 'exchange'>) {
1314
if (!clientId || !externalJwt) {
14-
throw new Error(
15-
'To use this browser-only provider you must supply clientId/JWT from trusted external IdP'
16-
);
15+
throw new ConfigurationError('external JWT exchange reequires client id and jwt');
1716
}
1817

1918
this.oidcAuth = new AccessToken({

lib/src/auth/oidc-refreshtoken-provider.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ConfigurationError } from '../errors.js';
12
import { type AuthProvider, type HttpRequest } from './auth.js';
23
import { AccessToken, type RefreshTokenCredentials } from './oidc.js';
34

@@ -11,9 +12,7 @@ export class OIDCRefreshTokenProvider implements AuthProvider {
1112
oidcOrigin,
1213
}: Partial<RefreshTokenCredentials> & Omit<RefreshTokenCredentials, 'exchange'>) {
1314
if (!clientId || !refreshToken) {
14-
throw new Error(
15-
'To use this browser-only provider you must supply clientId/valid OIDC refresh token'
16-
);
15+
throw new ConfigurationError('refresh token or client id missing');
1716
}
1817

1918
this.oidcAuth = new AccessToken({

lib/src/auth/oidc.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { default as dpopFn } from 'dpop';
22
import { HttpRequest, withHeaders } from './auth.js';
33
import { base64 } from '../encodings/index.js';
4-
import { IllegalArgumentError } from '../errors.js';
4+
import { ConfigurationError, TdfError } from '../errors.js';
55
import { cryptoPublicToPem, rstrip } from '../utils.js';
66

77
/**
@@ -98,19 +98,23 @@ export class AccessToken {
9898

9999
constructor(cfg: OIDCCredentials, request?: typeof fetch) {
100100
if (!cfg.clientId) {
101-
throw new Error('A Keycloak client identifier is currently required for all auth mechanisms');
101+
throw new ConfigurationError(
102+
'A Keycloak client identifier is currently required for all auth mechanisms'
103+
);
102104
}
103105
if (cfg.exchange === 'client' && !cfg.clientSecret) {
104-
throw new Error('When using client credentials, both clientId and clientSecret are required');
106+
throw new ConfigurationError(
107+
'When using client credentials, both clientId and clientSecret are required'
108+
);
105109
}
106110
if (cfg.exchange === 'refresh' && !cfg.refreshToken) {
107-
throw new Error('When using refresh token, a refresh token must be provided');
111+
throw new ConfigurationError('When using refresh token, a refresh token must be provided');
108112
}
109113
if (cfg.exchange === 'external' && !cfg.externalJwt) {
110-
throw new Error('When using external JWT, the jwt must be provided');
114+
throw new ConfigurationError('When using external JWT, the jwt must be provided');
111115
}
112116
if (!cfg.exchange) {
113-
throw new Error('Invalid oidc configuration');
117+
throw new ConfigurationError('Invalid oidc configuration');
114118
}
115119
this.config = cfg;
116120
this.request = request;
@@ -137,7 +141,9 @@ export class AccessToken {
137141
});
138142
if (!response.ok) {
139143
console.error(await response.text());
140-
throw new Error(`${response.status} ${response.statusText}`);
144+
throw new TdfError(
145+
`auth info fail: GET [${url}] => ${response.status} ${response.statusText}`
146+
);
141147
}
142148

143149
return (await response.json()) as unknown;
@@ -151,7 +157,7 @@ export class AccessToken {
151157
// add DPoP headers if configured
152158
if (this.config.dpopEnabled) {
153159
if (!this.signingKey) {
154-
throw new IllegalArgumentError('No signature configured');
160+
throw new ConfigurationError('No signature configured');
155161
}
156162
const clientPubKey = await cryptoPublicToPem(this.signingKey.publicKey);
157163
headers['X-VirtruPubKey'] = base64.encode(clientPubKey);
@@ -195,7 +201,9 @@ export class AccessToken {
195201
const response = await this.doPost(url, body);
196202
if (!response.ok) {
197203
console.error(await response.text());
198-
throw new Error(`${response.status} ${response.statusText}`);
204+
throw new TdfError(
205+
`token/code exchange fail: POST [${url}] => ${response.status} ${response.statusText}`
206+
);
199207
}
200208
return response.json();
201209
}
@@ -255,7 +263,7 @@ export class AccessToken {
255263
async exchangeForRefreshToken(): Promise<string> {
256264
const cfg = this.config;
257265
if (cfg.exchange != 'external' && cfg.exchange != 'refresh') {
258-
throw new Error('No refresh token provided!');
266+
throw new ConfigurationError('no refresh token provided!');
259267
}
260268
const tokenResponse = (this.data = await this.accessTokenLookup(this.config));
261269
if (!tokenResponse.refresh_token) {
@@ -278,7 +286,7 @@ export class AccessToken {
278286

279287
async withCreds(httpReq: HttpRequest): Promise<HttpRequest> {
280288
if (!this.signingKey) {
281-
throw new Error(
289+
throw new ConfigurationError(
282290
'Client public key was not set via `updateClientPublicKey` or passed in via constructor, cannot fetch OIDC token with valid Virtru claims'
283291
);
284292
}

lib/src/auth/providers.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { OIDCExternalJwtProvider } from './oidc-externaljwt-provider.js';
99
import { type AuthProvider } from './auth.js';
1010
import { OIDCRefreshTokenProvider } from './oidc-refreshtoken-provider.js';
1111
import { isBrowser } from '../utils.js';
12+
import { ConfigurationError } from '../errors.js';
1213

1314
/**
1415
* Creates an OIDC Client Credentials Provider for non-browser contexts.
@@ -95,13 +96,13 @@ export const refreshAuthProvider = async (
9596
*/
9697
export const clientAuthProvider = async (clientConfig: OIDCCredentials): Promise<AuthProvider> => {
9798
if (!clientConfig.clientId) {
98-
throw new Error('Client ID must be provided to constructor');
99+
throw new ConfigurationError('Client ID must be provided to constructor');
99100
}
100101

101102
if (isBrowser()) {
102103
//If you're in a browser and passing client secrets, you're Doing It Wrong.
103104
// if (clientConfig.clientSecret) {
104-
// throw new Error('Client credentials not supported in a browser context');
105+
// throw new ConfigurationError('Client credentials not supported in a browser context');
105106
// }
106107
//Are we exchanging a refreshToken for a bearer token (normal AuthCode browser auth flow)?
107108
//If this is a browser context, we expect the caller to handle the initial
@@ -118,15 +119,15 @@ export const clientAuthProvider = async (clientConfig: OIDCCredentials): Promise
118119
return clientSecretAuthProvider(clientConfig);
119120
}
120121
default:
121-
throw new Error(`Unsupported client type`);
122+
throw new ConfigurationError(`Unsupported client type`);
122123
}
123124
}
124125
//If you're NOT in a browser and are NOT passing client secrets, you're Doing It Wrong.
125126
//If this is not a browser context, we expect the caller to supply their client ID and client secret, so that
126127
// we can authenticate them directly with the OIDC endpoint.
127128
if (clientConfig.exchange !== 'client') {
128-
throw new Error(
129-
'If using client credentials, must supply both client ID and client secret to constructor'
129+
throw new ConfigurationError(
130+
'When using client credentials, must supply both client ID and client secret to constructor'
130131
);
131132
}
132133
return clientSecretAuthProvider(clientConfig);

0 commit comments

Comments
 (0)