Skip to content

Commit

Permalink
feat(lib): Updated error types (#362)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
dmihalcik-virtru authored Oct 15, 2024
1 parent 386ec6f commit 7fb29c5
Show file tree
Hide file tree
Showing 50 changed files with 677 additions and 549 deletions.
21 changes: 14 additions & 7 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

107 changes: 80 additions & 27 deletions lib/src/access.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { type AuthProvider } from './auth/auth.js';
import {
InvalidFileError,
NetworkError,
PermissionDeniedError,
ServiceError,
UnauthenticatedError,
} from './errors.js';
import { pemToCryptoPublicKey, validateSecureUrl } from './utils.js';

export class RewrapRequest {
Expand Down Expand Up @@ -32,22 +39,40 @@ export async function fetchWrappedKey(
},
body: JSON.stringify(requestBody),
});
const response = await fetch(req.url, {
method: req.method,
mode: 'cors', // no-cors, *cors, same-origin
cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
credentials: 'same-origin', // include, *same-origin, omit
headers: req.headers,
redirect: 'follow', // manual, *follow, error
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
body: req.body as BodyInit,
});

if (!response.ok) {
throw new Error(`${req.method} ${req.url} => ${response.status} ${response.statusText}`);
}
try {
const response = await fetch(req.url, {
method: req.method,
mode: 'cors', // no-cors, *cors, same-origin
cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
credentials: 'same-origin', // include, *same-origin, omit
headers: req.headers,
redirect: 'follow', // manual, *follow, error
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
body: req.body as BodyInit,
});

return response.json();
if (!response.ok) {
switch (response.status) {
case 400:
throw new InvalidFileError(
`400 for [${req.url}]: rewrap failure [${await response.text()}]`
);
case 401:
throw new UnauthenticatedError(`401 for [${req.url}]`);
case 403:
throw new PermissionDeniedError(`403 for [${req.url}]`);
default:
throw new NetworkError(
`${req.method} ${req.url} => ${response.status} ${response.statusText}`
);
}
}

return response.json();
} catch (e) {
throw new NetworkError(`unable to fetch wrapped key from [${url}]: ${e}`);
}
}

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

async function noteInvalidPublicKey(url: string, r: Promise<CryptoKey>): Promise<CryptoKey> {
try {
return await r;
} catch (e) {
if (e instanceof TypeError) {
throw new ServiceError(`invalid public key from [${url}]`, e);
}
throw e;
}
}

/**
* If we have KAS url but not public key we can fetch it from KAS, fetching
* the value from `${kas}/kas_public_key`.
*/
export async function fetchECKasPubKey(kasEndpoint: string): Promise<KasPublicKeyInfo> {
validateSecureUrl(kasEndpoint);
const pkUrlV2 = `${kasEndpoint}/v2/kas_public_key?algorithm=ec:secp256r1&v=2`;
const kasPubKeyResponse = await fetch(pkUrlV2);
if (!kasPubKeyResponse.ok) {
if (kasPubKeyResponse.status != 404) {
throw new Error(
`unable to load KAS public key from [${pkUrlV2}]. Received [${kasPubKeyResponse.status}:${kasPubKeyResponse.statusText}]`
);
const kasPubKeyResponseV2 = await fetch(pkUrlV2);
if (!kasPubKeyResponseV2.ok) {
switch (kasPubKeyResponseV2.status) {
case 404:
// v2 not implemented, perhaps a legacy server
break;
case 401:
throw new UnauthenticatedError(`401 for [${pkUrlV2}]`);
case 403:
throw new PermissionDeniedError(`403 for [${pkUrlV2}]`);
default:
throw new NetworkError(
`${pkUrlV2} => ${kasPubKeyResponseV2.status} ${kasPubKeyResponseV2.statusText}`
);
}
// most likely a server that does not implement v2 endpoint, so no key identifier
const pkUrlV1 = `${kasEndpoint}/kas_public_key?algorithm=ec:secp256r1`;
const r2 = await fetch(pkUrlV1);
if (!r2.ok) {
throw new Error(
`unable to load KAS public key from [${pkUrlV1}]. Received [${r2.status}:${r2.statusText}]`
);
switch (r2.status) {
case 401:
throw new UnauthenticatedError(`401 for [${pkUrlV2}]`);
case 403:
throw new PermissionDeniedError(`403 for [${pkUrlV2}]`);
default:
throw new NetworkError(
`unable to load KAS public key from [${pkUrlV1}]. Received [${r2.status}:${r2.statusText}]`
);
}
}
const pem = await r2.json();
return {
key: pemToCryptoPublicKey(pem),
key: noteInvalidPublicKey(pkUrlV1, pemToCryptoPublicKey(pem)),
publicKey: pem,
url: kasEndpoint,
algorithm: 'ec:secp256r1',
};
}
const jsonContent = await kasPubKeyResponse.json();
const jsonContent = await kasPubKeyResponseV2.json();
const { publicKey, kid }: KasPublicKeyInfo = jsonContent;
if (!publicKey) {
throw new Error(`Invalid response from public key endpoint [${JSON.stringify(jsonContent)}]`);
throw new NetworkError(
`invalid response from public key endpoint [${JSON.stringify(jsonContent)}]`
);
}
return {
key: pemToCryptoPublicKey(publicKey),
key: noteInvalidPublicKey(pkUrlV2, pemToCryptoPublicKey(publicKey)),
publicKey,
url: kasEndpoint,
algorithm: 'ec:secp256r1',
Expand Down
5 changes: 2 additions & 3 deletions lib/src/auth/oidc-clientcredentials-provider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ConfigurationError } from '../errors.js';
import { AuthProvider, type HttpRequest } from './auth.js';
import { AccessToken, type ClientSecretCredentials } from './oidc.js';

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

this.oidcAuth = new AccessToken({
Expand Down
5 changes: 2 additions & 3 deletions lib/src/auth/oidc-externaljwt-provider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ConfigurationError } from '../errors.js';
import { type AuthProvider, type HttpRequest } from './auth.js';
import { AccessToken, type ExternalJwtCredentials } from './oidc.js';

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

this.oidcAuth = new AccessToken({
Expand Down
5 changes: 2 additions & 3 deletions lib/src/auth/oidc-refreshtoken-provider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ConfigurationError } from '../errors.js';
import { type AuthProvider, type HttpRequest } from './auth.js';
import { AccessToken, type RefreshTokenCredentials } from './oidc.js';

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

this.oidcAuth = new AccessToken({
Expand Down
30 changes: 19 additions & 11 deletions lib/src/auth/oidc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { default as dpopFn } from 'dpop';
import { HttpRequest, withHeaders } from './auth.js';
import { base64 } from '../encodings/index.js';
import { IllegalArgumentError } from '../errors.js';
import { ConfigurationError, TdfError } from '../errors.js';
import { cryptoPublicToPem, rstrip } from '../utils.js';

/**
Expand Down Expand Up @@ -98,19 +98,23 @@ export class AccessToken {

constructor(cfg: OIDCCredentials, request?: typeof fetch) {
if (!cfg.clientId) {
throw new Error('A Keycloak client identifier is currently required for all auth mechanisms');
throw new ConfigurationError(
'A Keycloak client identifier is currently required for all auth mechanisms'
);
}
if (cfg.exchange === 'client' && !cfg.clientSecret) {
throw new Error('When using client credentials, both clientId and clientSecret are required');
throw new ConfigurationError(
'When using client credentials, both clientId and clientSecret are required'
);
}
if (cfg.exchange === 'refresh' && !cfg.refreshToken) {
throw new Error('When using refresh token, a refresh token must be provided');
throw new ConfigurationError('When using refresh token, a refresh token must be provided');
}
if (cfg.exchange === 'external' && !cfg.externalJwt) {
throw new Error('When using external JWT, the jwt must be provided');
throw new ConfigurationError('When using external JWT, the jwt must be provided');
}
if (!cfg.exchange) {
throw new Error('Invalid oidc configuration');
throw new ConfigurationError('Invalid oidc configuration');
}
this.config = cfg;
this.request = request;
Expand All @@ -137,7 +141,9 @@ export class AccessToken {
});
if (!response.ok) {
console.error(await response.text());
throw new Error(`${response.status} ${response.statusText}`);
throw new TdfError(
`auth info fail: GET [${url}] => ${response.status} ${response.statusText}`
);
}

return (await response.json()) as unknown;
Expand All @@ -151,7 +157,7 @@ export class AccessToken {
// add DPoP headers if configured
if (this.config.dpopEnabled) {
if (!this.signingKey) {
throw new IllegalArgumentError('No signature configured');
throw new ConfigurationError('No signature configured');
}
const clientPubKey = await cryptoPublicToPem(this.signingKey.publicKey);
headers['X-VirtruPubKey'] = base64.encode(clientPubKey);
Expand Down Expand Up @@ -195,7 +201,9 @@ export class AccessToken {
const response = await this.doPost(url, body);
if (!response.ok) {
console.error(await response.text());
throw new Error(`${response.status} ${response.statusText}`);
throw new TdfError(
`token/code exchange fail: POST [${url}] => ${response.status} ${response.statusText}`
);
}
return response.json();
}
Expand Down Expand Up @@ -255,7 +263,7 @@ export class AccessToken {
async exchangeForRefreshToken(): Promise<string> {
const cfg = this.config;
if (cfg.exchange != 'external' && cfg.exchange != 'refresh') {
throw new Error('No refresh token provided!');
throw new ConfigurationError('no refresh token provided!');
}
const tokenResponse = (this.data = await this.accessTokenLookup(this.config));
if (!tokenResponse.refresh_token) {
Expand All @@ -278,7 +286,7 @@ export class AccessToken {

async withCreds(httpReq: HttpRequest): Promise<HttpRequest> {
if (!this.signingKey) {
throw new Error(
throw new ConfigurationError(
'Client public key was not set via `updateClientPublicKey` or passed in via constructor, cannot fetch OIDC token with valid Virtru claims'
);
}
Expand Down
11 changes: 6 additions & 5 deletions lib/src/auth/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { OIDCExternalJwtProvider } from './oidc-externaljwt-provider.js';
import { type AuthProvider } from './auth.js';
import { OIDCRefreshTokenProvider } from './oidc-refreshtoken-provider.js';
import { isBrowser } from '../utils.js';
import { ConfigurationError } from '../errors.js';

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

if (isBrowser()) {
//If you're in a browser and passing client secrets, you're Doing It Wrong.
// if (clientConfig.clientSecret) {
// throw new Error('Client credentials not supported in a browser context');
// throw new ConfigurationError('Client credentials not supported in a browser context');
// }
//Are we exchanging a refreshToken for a bearer token (normal AuthCode browser auth flow)?
//If this is a browser context, we expect the caller to handle the initial
Expand All @@ -118,15 +119,15 @@ export const clientAuthProvider = async (clientConfig: OIDCCredentials): Promise
return clientSecretAuthProvider(clientConfig);
}
default:
throw new Error(`Unsupported client type`);
throw new ConfigurationError(`Unsupported client type`);
}
}
//If you're NOT in a browser and are NOT passing client secrets, you're Doing It Wrong.
//If this is not a browser context, we expect the caller to supply their client ID and client secret, so that
// we can authenticate them directly with the OIDC endpoint.
if (clientConfig.exchange !== 'client') {
throw new Error(
'If using client credentials, must supply both client ID and client secret to constructor'
throw new ConfigurationError(
'When using client credentials, must supply both client ID and client secret to constructor'
);
}
return clientSecretAuthProvider(clientConfig);
Expand Down
Loading

0 comments on commit 7fb29c5

Please sign in to comment.