diff --git a/package.json b/package.json index 49aa00f..a372300 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@al/core", - "version": "1.2.47", + "version": "1.2.48", "description": "Node Enterprise Packages for Alert Logic (NEPAL) Core Library", "main": "./dist/index.cjs.js", "types": "./dist/index.d.ts", diff --git a/src/error-handler/al-error-handler.ts b/src/error-handler/al-error-handler.ts index 2afc524..1f7ff5b 100644 --- a/src/error-handler/al-error-handler.ts +++ b/src/error-handler/al-error-handler.ts @@ -1,38 +1,56 @@ import { AxiosResponse } from 'axios'; -import { AlBaseError, AlAPIServerError, AlWrappedError } from '../common/errors'; +import { AlBaseError, AlAPIServerError, AlWrappedError, AlCabinet } from '../common'; import { AlDefaultClient, APIRequestParams } from '../client'; +export interface AlErrorDescriptor { + title:string; + description:string; + details?:any; +} + /** - * AlErrorHandler is a simple utility class for normalizing errors and exceptions into a known format. + * AlErrorHandler is a utility class meant to simplify error logging, upstream error reporting, and general error + * formatting. */ export class AlErrorHandler { + public static initialized = false; + public static categories:{[categoryId:string]:boolean} = {}; public static upstream?:{(error:AlBaseError):void}; public static verbose = false; /** * Logs a normalized error message to the console. * - * @param error Can be an AxiosResponse, Error, string, or anything else (although "anything else" will be handled with a generic error message). + * @param error Can be an AxiosResponse, Error, string, or anything else (although "anything else" will be handled with a generic error message); + * @param commentary If provided, is used to describe the error; + * @param categoryId If provided, describes the category of the logging output. + * @param overrideVerbosity If provided, the error will always be emitted to the console */ - public static log( error:AxiosResponse|AlBaseError|Error|string|any, commentary?:string, overrideVerbosity?:boolean ) { - let normalized = AlErrorHandler.normalize( error ); - if ( AlErrorHandler.verbose || overrideVerbosity ) { + public static log( error:AxiosResponse|AlBaseError|Error|string|any, commentary?:string, categoryId?:string, overrideVerbosity?:boolean ) { + AlErrorHandler.prepare(); + const normalized = AlErrorHandler.normalize( error ); + const effectiveCategoryId = categoryId ?? 'general'; + if ( overrideVerbosity || AlErrorHandler.verbose || ( effectiveCategoryId in AlErrorHandler.categories ) ) { console.log( commentary ? `${commentary}: ${normalized.message}` : normalized.message ); } } /** - * Reports an error to an external error reporting service. + * Reports an error to an external error reporting service (which must be attached separately) + * + * @param error A network error response, `Error` instance of any type, or string. + * @param commentary If provided, it is used to describe the error in its console output and internally (not user facing). */ - public static report( error:AxiosResponse|AlBaseError|Error|string|any, commentary?:string, ...details:any[] ) { + public static report( error:AxiosResponse|AlBaseError|Error|string|any, commentary?:string ) { if ( AlErrorHandler.upstream ) { let normalized = AlErrorHandler.normalize( error ); AlErrorHandler.upstream( normalized ); } else { - AlErrorHandler.log( error, commentary, true ); + AlErrorHandler.log( new Error( `No error reporter is configured for AlErrorHandler` ) ); } + AlErrorHandler.log( error, commentary, undefined, true ); } /** @@ -70,7 +88,270 @@ export class AlErrorHandler } } + /** + * Enables logging of one or more error categories. The default category is "general". + */ + public static enable( ...categories:string[] ) { + const storage = AlErrorHandler.prepare(); + categories.forEach( ( categoryId ) => AlErrorHandler.categories[categoryId] = true ); + storage.set( "visible", AlErrorHandler.categories ); + } + + /** + * Enables logging of one or more error categories. The default category is "general". + */ + public static disable( ...categories:string[] ) { + const storage = AlErrorHandler.prepare(); + categories.forEach( ( categoryId ) => delete AlErrorHandler.categories[categoryId] ); + storage.set( "visible", AlErrorHandler.categories ); + } + public static wrap( error:AxiosResponse|AlBaseError|Error|string|any, message:string ):AlWrappedError { return new AlWrappedError( message, error ); } + + public static describe( error:any, verbose = true ):AlErrorDescriptor { + + let title = "Something is wrong"; + let description = AlErrorHandler.getErrorDescription( error, verbose ); + let details:any; + + if ( AlDefaultClient.isResponse( error ) ) { + // This error is an HTTP response descriptor, indicating an API error has occurred -- format appropriately + title = "Unexpected API Response"; + details = AlErrorHandler.redact( AlErrorHandler.compactErrorResponse( error ) ); + } else if ( error instanceof AlWrappedError ) { + // This error is an outer error with a reference to an inner exception. + details = AlErrorHandler.redact( AlErrorHandler.compactWrappedError( error ) ); + } else if ( error instanceof Error ) { + // Generic Error object + details = AlErrorHandler.redact( AlErrorHandler.compactError( error ) ); + } + + return { title, description, details }; + } + + /** + * Utility function to descend into arbitrarily nested and potentially circular data, replacing any AIMS tokens + * or Authorization headers with a redaction marker. + * + * If `trimCircularity` is true (default), circular references will be flattened with a special string, making the object + * suitable for serialization. + */ + public static redact( info:any, trimCircularity:boolean = true, circular:any[] = [] ):any { + if ( typeof( info ) === 'object' && info !== null ) { + if ( circular.includes( info ) ) { + if ( trimCircularity ) { + return "(circular)"; + } + } else { + circular.push( info ); + Object.keys( info ).forEach( key => { + if ( /x-aims-auth-token/i.test( key ) || /authorization/i.test( key ) ) { + info[key] = 'XXXXX'; // REDACTED + } else { + info[key] = AlErrorHandler.redact( info[key], trimCircularity, circular ); + } + } ); + } + } else if ( typeof( info ) === 'string' ) { + return info.replace( /X-AIMS-Auth-Token['"\s:]*([a-zA-Z0-9+\=\/]+)['"]/gi, + ( completeMatch:string, token:string ) => completeMatch.replace( token, 'XXXXX' ) ) + .replace( /Authorization['"\s:]*([a-zA-Z0-9+\=\/\s]+)['"]/gi, + ( completeMatch:string, token:string ) => completeMatch.replace( token, 'XXXXX' ) ); + } + return info; + } + + protected static prepare():AlCabinet { + if ( ! AlErrorHandler.initialized ) { + const storage = AlCabinet.persistent("errors"); + AlErrorHandler.categories = storage.get( "visible", {} ); + AlErrorHandler.initialized = true; + return storage; + } + + } + + protected static getErrorDescription( error:any, verbose = true ):string { + if ( typeof( error ) === 'string' ) { + return error; + } else if ( AlDefaultClient.isResponse( error ) ) { + return AlErrorHandler.getResponseDescription( error, verbose ); + } else if ( error instanceof AlWrappedError ) { + return AlErrorHandler.consolidateWrappedErrorDescription( error, verbose ); + } else if ( error instanceof Error ) { + return error.message; + } else { + if ( verbose ) { + return "An unknown error prevented this view from rendering. If this persists, please contact Alert Logic support for assistance."; + } else { + return "An internal error occurred."; + } + } + } + + protected static compactErrorResponse( response:AxiosResponse ):any { + return { + data: response.data, + status: response.status, + statusText: response.statusText, + headers: response.headers, + config: response.config + }; + } + + protected static compactWrappedError( error:AlWrappedError ):any { + let cursor = error; + const stack = []; + while( cursor ) { + if ( AlDefaultClient.isResponse( cursor ) ) { + stack.push( AlErrorHandler.compactErrorResponse( cursor ) ); + } else if ( cursor instanceof Error ) { + stack.push( AlErrorHandler.compactError( cursor ) ); + } else if ( typeof( cursor ) === 'string' ) { + stack.push( cursor ); + } else { + stack.push( "Eggplant Parmesiano with Spider Eggs" ); + } + if ( cursor instanceof AlWrappedError ) { + cursor = cursor.getInnerError(); + } else { + cursor = null; + } + cursor = cursor instanceof AlWrappedError ? cursor.getInnerError() : null; + } + return stack; + } + + protected static compactError( error:Error, type:string = "Error", otherProperties?:any ):any { + const compact:any = { + type, + message: error.message, + stack: error.stack ? error.stack.split( "\n" ).map( line => line.trim() ) : null + }; + if ( otherProperties ) { + Object.assign( compact, otherProperties ); + } + return compact; + } + + protected static consolidateWrappedErrorDescription( error:AlWrappedError|Error|AxiosResponse|string, verbose = true ) { + let description = ''; + let cursor = error; + let adjustCapitalization = ( text:string ) => { + if ( ! text || text.length === 0 ) { + return ''; + } + if ( description.length > 0 ) { + let firstChar = text[0]; + if ( firstChar === firstChar.toUpperCase() ) { + return firstChar.toLowerCase() + text.substring( 1 ); + } + } + return text; + }; + while( cursor ) { + if ( description.length > 0 ) { + description += `: `; + } + if ( cursor instanceof Error ) { + description += adjustCapitalization( cursor.message ); + } else if ( AlDefaultClient.isResponse( cursor ) ) { + description += adjustCapitalization( AlErrorHandler.getResponseDescription( cursor, verbose ) ); + } else if ( typeof( cursor ) === 'string' ) { + description += adjustCapitalization( cursor ); + } + cursor = cursor instanceof AlWrappedError ? cursor.getInnerError() : null; + } + return description; + } + + /** + * Matches a response TODO(kjn): hook this up to the content service, when it's available, and use content from there instead of here :) + */ + protected static getResponseDescription( response:AxiosResponse, verbose = true ) { + const request = response.config as APIRequestParams; + const serviceName = 'service_name' in request ? request.service_name : "a required service"; + const status = response.status; + const statusText = response.statusText; + switch( status ) { + case 400 : + if ( verbose ) { + return `${serviceName} doesn't appear to understand one of our requests. If this condition persists, please contact Alert Logic support.`; + } else { + return `${serviceName} did not understand our request.`; + } + + case 401 : + if ( verbose ) { + return `${serviceName} doesn't appear to be accepting our identity or authentication state. If this condition persists after reauthenticating, please contact Alert Logic support.`; + } else { + return `${serviceName} did not recognize our credentials.`; + } + + case 403 : + if ( verbose ) { + return `${serviceName} is denying our authorization to access its data. If this condition persists after reauthenticating, please contact Alert Logic support.`; + } else { + return `${serviceName} denied access to us.`; + } + + case 404 : + if ( verbose ) { + return "The data you are trying to access doesn't appear to exist. If you are certain this is an error and the condition persists, please contact Alert Logic support."; + } else { + return `${serviceName} could not find a resource.`; + } + + case 410 : + if ( verbose ) { + return "The data you're trying to access doesn't appear to exist anymore. If you are certain this is an error and the condition persists, please contact Alert Logic support."; + } else { + return `${serviceName} could not return a deleted resource.`; + } + + case 418 : + if ( verbose ) { + return "Sadly, the data you're looking for has turned into a teapot. Tragic but delicious!"; + } else { + return `${serviceName} is short and stout.`; + } + + case 500 : + if ( verbose ) { + return `${serviceName} has experienced an unexpected internal error. If this condition persists, please contact Alert Logic support.`; + } else { + return `${serviceName} experienced an internal error.`; + } + + case 502 : + if ( verbose ) { + return `${serviceName} has failed because of an unexpected response from an upstream service. If this condition persists, please contact Alert Logic support.`; + } else { + return `${serviceName} reported an upstream error.`; + } + + case 503 : + if ( verbose ) { + return `${serviceName} is currently unavailable. If this condition persists, please contact Alert Logic support.`; + } else { + return `${serviceName} is unavailable.`; + } + + case 504 : + if ( verbose ) { + return `${serviceName} is not responding quickly enough. If this condition persists, please contact Alert Logic support.`; + } else { + return `${serviceName} timed out.`; + } + + default : + if ( verbose ) { + return `${serviceName} responded in an unexpected way (${status}/${statusText}). If this condition persists, please contact Alert Logic support.`; + } else { + return `${serviceName} responded with status ${statusText} (${status})`; + } + } + } } diff --git a/src/session/utilities/al-identity-providers.ts b/src/session/utilities/al-identity-providers.ts index 5897a2f..6c2a342 100644 --- a/src/session/utilities/al-identity-providers.ts +++ b/src/session/utilities/al-identity-providers.ts @@ -52,8 +52,8 @@ export class AlIdentityProviders * itself is bootstrapped and the angular router assumes control of the URL. */ - public async warmup():Promise { - let currentURL = window?.location?.href ?? ''; + public async warmup( url?:string ):Promise { + let targetURL = url ?? window?.location?.href ?? ''; if ( AlIdentityProviders.inAuth0Workflow(window?.location?.href) ) { try { AlErrorHandler.log( "IdP Warmup: initializing auth0" ); @@ -68,7 +68,7 @@ export class AlIdentityProviders } catch( e ) { AlErrorHandler.log(e, "IdP Warmup: auth0 initialization failed" ); } - return this.maybeRewriteBrokenURL( currentURL ); + return this.maybeRewriteBrokenURL( targetURL ); } else { await this.getKeycloak(); } @@ -167,7 +167,6 @@ export class AlIdentityProviders * promise to hang indefinitely. */ protected async innerGetKeyCloak( cloak:Keycloak, timeout:number = 5000 ):Promise { - console.error( new Error("Getting keycloak!" ), window.location.href ); return Promise.race( [ AlStopwatch.promise( timeout ), new Promise( async ( resolve, reject ) => { let cloakPhase = this.storage.get("cloakInitPhase", 0 ); @@ -256,45 +255,50 @@ export class AlIdentityProviders try { AlErrorHandler.log( `IdP warmup: evaluating input URL [${inputURL}]` ); let verifyMfaRouteMatcher = /\?state=(.*)\#\/mfa\/verify(.*)/; + let altVerifyMfaRouteMatcher = /\?token=(.*)&state=(.*)\#\/mfa\/verify(.*)/; let acceptTosRouteMatcher = /\?state=(.*)\#\/terms-of-service(.*)/; let node = AlLocatorService.getNodeByURI( inputURL ); let nodeId = node?.locTypeId || 'unknown'; - if ( nodeId === AlLocation.AccountsUI ) { + if ( nodeId === AlLocation.AccountsUI || nodeId === AlLocation.MagmaUI ) { if ( inputURL.match( verifyMfaRouteMatcher ) ) { + AlErrorHandler.log(`IdP warmup: MFA validation URL detected` ); let matches = verifyMfaRouteMatcher.exec( inputURL ); let stateValue = matches[1]; let qsValue = matches[2]; - AlErrorHandler.log(`IdP warmup: MFA validation URL detected` ); - return inputURL.replace( verifyMfaRouteMatcher, `#/mfa/verify${qsValue}&state=${stateValue}` ); + const delimiter = qsValue.includes("?") ? "&" : "?"; + return inputURL.replace( verifyMfaRouteMatcher, `#/mfa/verify${qsValue}${delimiter}state=${stateValue}` ); + } else if ( inputURL.match( altVerifyMfaRouteMatcher ) ) { + AlErrorHandler.log(`IdP warmup: MFA validation URL detected (alternate)` ); + let matches = altVerifyMfaRouteMatcher.exec( inputURL ); + let tokenValue = matches[1]; + let stateValue = matches[2]; + let qsValue = matches[3]; + const delimiter = qsValue.includes("?") ? "&" : "?"; + return inputURL.replace( altVerifyMfaRouteMatcher, `#/mfa/verify${qsValue}${delimiter}token=${tokenValue}&state=${stateValue}` ); } else if ( inputURL.match( acceptTosRouteMatcher ) ) { + AlErrorHandler.log(`IdP warmup: TOS acceptance URL detected` ); let matches = acceptTosRouteMatcher.exec( inputURL ); let stateValue = matches[1]; let qsValue = matches[2]; - AlErrorHandler.log(`IdP warmup: TOS acceptance URL detected` ); + const delimiter = qsValue.includes("?") ? "&" : "?"; return inputURL.replace( acceptTosRouteMatcher, `#/terms-of-service${qsValue}&state=${stateValue}` ); } - let matches = /^(.*)\/\?state=(.*)(\#\/.*)$/.exec( inputURL ); - if ( matches ) { - // This is an Auth0 redirect URL. It needs to be massaged to be gracefully handled by angular. - let paramToken = matches[3].includes("?") ? "&" : "?"; - const rewrittenURL = `${matches[1]}/${matches[3]}${paramToken}state=${matches[2]}`; - return rewrittenURL; - } } else { let matches = /^(.*)\/\?error=login_required&state=(.*)(\#\/.*)$/.exec( inputURL ); if ( matches ) { // This is an Auth0 redirect URL. It needs to be massaged to be gracefully handled by angular. - let paramToken = matches[3].includes("?") ? "&" : "?"; - const rewrittenURL = `${matches[1]}/${matches[3]}${paramToken}error=login_required&state=${matches[2]}`; + let delimiter = matches[3].includes("?") ? "&" : "?"; + const rewrittenURL = `${matches[1]}/${matches[3]}${delimiter}error=login_required&state=${matches[2]}`; AlErrorHandler.log(`IdP warmup: login required URL detected` ); return rewrittenURL; } } } catch( e ) { AlErrorHandler.report( "Unexpected error: could not preprocess application URI: " + e.toString() ); + throw e; } return undefined; - }; + } } diff --git a/test/error-handler/al-error-handler.spec.ts b/test/error-handler/al-error-handler.spec.ts index 3fa61db..d9599a7 100644 --- a/test/error-handler/al-error-handler.spec.ts +++ b/test/error-handler/al-error-handler.spec.ts @@ -8,6 +8,15 @@ import { AxiosResponse, AxiosRequestConfig } from 'axios'; describe('AlErrorHandler', () => { describe(".log()", () => { let logStub; + let http404Response:AxiosResponse = { + status: 404, + statusText: "Not found", + data: { message: "Get lost, hoser!" }, + headers: { 'X-Response-Reason': 'Pure silliness' }, + config: { + service_name: 'aims' + } as AxiosRequestConfig + }; before( () => { logStub = sinon.stub( console, 'log' ); } ); @@ -16,16 +25,7 @@ describe('AlErrorHandler', () => { } ); it("Should handle any input without blowing up", () => { AlErrorHandler.verbose = true; - let httpResponse:AxiosResponse = { - status: 404, - statusText: "Not found", - data: { message: "Get lost, hoser!" }, - headers: { 'X-Response-Reason': 'Pure silliness' }, - config: { - service_name: 'aims' - } as AxiosRequestConfig - }; - AlErrorHandler.log( httpResponse, "Got a weird response" ); + AlErrorHandler.log( http404Response, "Got a weird response" ); AlErrorHandler.log( new AlBaseError( "Something is rotten in the state of Denmark." ) ); AlErrorHandler.log( new Error("Something stinks under the kitchen sink." ) ); AlErrorHandler.log( "Throwing strings as Errors is silly and should never be done, but what can you do?", "Some comment" ); @@ -35,5 +35,13 @@ describe('AlErrorHandler', () => { AlErrorHandler.log( "This should not get emitted" ); expect( logStub.callCount ).to.equal( 5 ); // 1 for each .log call } ); + + it("should describe API errors in both verbose and short form", () => { + let verboseDescription = AlErrorHandler.describe( http404Response ); + let terseDescription = AlErrorHandler.describe( http404Response, false ); + expect( terseDescription.title ).to.equal("Unexpected API Response" ); + expect( verboseDescription.title ).to.equal("Unexpected API Response" ); + expect( terseDescription.description.length ).to.be.lessThan( verboseDescription.description.length ); + } ); } ); }); diff --git a/test/session/al-identity-providers.spec.ts b/test/session/al-identity-providers.spec.ts index 7df5c89..1c55d15 100644 --- a/test/session/al-identity-providers.spec.ts +++ b/test/session/al-identity-providers.spec.ts @@ -4,11 +4,9 @@ import { expect } from 'chai'; import { describe } from 'mocha'; import * as sinon from 'sinon'; import { exampleSession } from '../mocks'; -import { - AlIdentityProviders, -} from '@al/core'; +import { AlIdentityProviders, AlErrorHandler } from '@al/core'; -describe.only('AlIdentityProviders', () => { +describe('AlIdentityProviders', () => { let identityProviders:AlIdentityProviders; beforeEach( () => { @@ -39,4 +37,31 @@ describe.only('AlIdentityProviders', () => { expect( AlIdentityProviders.inAuth0Workflow( url4 ) ).to.equal( true ); } ); } ); + + describe(".warmup()", () => { + it( "should return undefined for most URLs", () => { + const url1 = `https://console.magma.product.dev.alertlogic.com/` + + `?state=1656b907-98d2-4d60-8ad2-ef95227c363e` + + `&session_state=15160e1c-ed64-4d04-9957-a78956f55434` + + `&iss=https%3A%2F%2Ffoundation.foundation-stage.cloudops.fortradev.com%2Fidp%2Frealms%2Fproducts` + + `&code=7096de64-2a04-4f96-a46c-36d204b39db2.15160e1c-ed64-4d04-9957-a78956f55434.bf900b32-0776-4624-905d-6305d1227beb`; + expect( identityProviders['maybeRewriteBrokenURL']( url1 ) ).to.equal( undefined ); + + const url2 = `http://www.google.com`; + expect( identityProviders['maybeRewriteBrokenURL']( url2 ) ).to.equal( undefined ); + + } ); + + it( "should redirect known auth0 url malformations correctly", () => { + AlErrorHandler.verbose = true; + const url1 = `https://console.magma.product.dev.alertlogic.com/?state=something#/mfa/verify`; + expect( identityProviders['maybeRewriteBrokenURL']( url1 ) ) + .to.equal( `https://console.magma.product.dev.alertlogic.com/#/mfa/verify?state=something` ); + + const url2 = `https://console.magma.product.dev.alertlogic.com/?token=blippety&state=blop#/mfa/verify`; + const result2 = identityProviders['maybeRewriteBrokenURL']( url2 ); + + console.log("Full thing: %s", result2 ); + } ); + } ); } );