Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(lib): Updated error types #362

Merged
merged 7 commits into from
Oct 15, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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:
dmihalcik-virtru marked this conversation as resolved.
Show resolved Hide resolved
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
Loading