Repository: auth0/nextjs-auth0
Pull request #782 (2022-08-17 02:54:02)

Improve errors [SDK-3552]
⚠️ **This PR contains breaking changes**

<!--
❗ For general support or usage questions, use the Auth0 Community forums or raise a support ticket.

By submitting a pull request to this repository, you agree to the terms within the Auth0 Code of Conduct: https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md.
-->

- [X] All new/changed/fixed functionality is covered by tests (or N/A)
- [X] I have added documentation for all new/changed functionality (or N/A)

<!--
❗ All the above items are required. Pull requests with an incomplete or missing checklist will be closed.
-->

### 📋 Changes

This PR reworks the SDK errors to:

1. Improve error handling.
2. Set up a base that better accommodates typed errors. Individual error classes for specific common errors are not included in this PR, but are very simple to add.

#### Underlying errors
Now, SDK errors can inherit from the base `AuthError`, which contains a `cause?` property for an underlying error. This way, errors can wrap another error simply by populating this property. The message of the 'wrapped' error will be automatically appended to the message of the 'wrapper' error (and labeled accordingly), which makes it easily discoverable and actionable by the developer.

#### Error codes
SDK errors that inherit from `AuthError` will need to specify a machine-readable `code`. Developers can rely on error codes to identify specific errors. Unlike error messages, codes are part of the SDK API and will remain stable within a major version of the SDK. The error codes follow the `ERR_SOMETHING` Node.js convention.

#### Error names
SDK errors that inherit from `AuthError` will need to specify a value for the `name` property (typically the error class name). This value will show up when printing an error with `console.log(error)`, making it easier for the developer to identify the error.

#### API handler-specific errors
Before, `HandlerError` was the single catch-all error for all errors produced by an API handler. Now every handler has its own error:
- `CallbackHandlerError` for the errors of the callback handler
- `LoginHandlerError` for the errors of the login handler
- `LogoutHandlerError` for the errors of the logout handler
- `ProfileHandlerError` for the errors of the profile handler

This makes it possible for handler-specific errors to feature handler-specific codes. The end result is that every handler will only throw errors of a single type, with a machine-readable code, and a single wrapped error. Which greatly simplifies error handling.

vercel[bot]: **The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)


<details><summary>1 Ignored Deployment</summary>

| Name | Status | Preview | Updated |
| :--- | :----- | :------ | :------ |
| **nextjs-auth0** | ⬜️ Ignored ([Inspect](https://vercel.com/auth0/nextjs-auth0/373euw1mQ3bhYJ3cr2VPK86icHhU)) |  | Aug 17, 2022 at 2:54AM (UTC) |
</details>



Patch:
diff --git a/src/handlers/callback.ts b/src/handlers/callback.ts
index 146dd4f4..b3d19f68 100644
--- a/src/handlers/callback.ts
+++ b/src/handlers/callback.ts
@@ -4,7 +4,7 @@ import { AuthorizationParameters, HandleCallback as BaseHandleCallback } from '.
 import { Session } from '../session';
 import { assertReqRes } from '../utils/assert';
 import { NextConfig } from '../config';
-import { HandlerError } from '../utils/errors';
+import { CallbackHandlerError, HandlerErrorCause } from '../utils/errors';
 
 /**
  * Use this function for validating additional claims on the user's ID Token or adding removing items from
@@ -139,7 +139,7 @@ export default function handleCallbackFactory(handler: BaseHandleCallback, confi
         afterCallback: idTokenValidator(options.afterCallback, options.organization || config.organization)
       });
     } catch (e) {
-      throw new HandlerError(e);
+      throw new CallbackHandlerError(e as HandlerErrorCause);
     }
   };
 }
diff --git a/src/handlers/login.ts b/src/handlers/login.ts
index 21e02a8f..bae5bb82 100644
--- a/src/handlers/login.ts
+++ b/src/handlers/login.ts
@@ -3,7 +3,7 @@ import { AuthorizationParameters, HandleLogin as BaseHandleLogin } from '../auth
 import toSafeRedirect from '../utils/url-helpers';
 import { assertReqRes } from '../utils/assert';
 import { BaseConfig, NextConfig } from '../config';
-import { HandlerError } from '../utils/errors';
+import { HandlerErrorCause, LoginHandlerError } from '../utils/errors';
 
 /**
  * Use this to store additional state for the user before they visit the Identity Provider to login.
@@ -142,7 +142,7 @@ export default function handleLoginFactory(
 
       return await handler(req, res, options);
     } catch (e) {
-      throw new HandlerError(e);
+      throw new LoginHandlerError(e as HandlerErrorCause);
     }
   };
 }
diff --git a/src/handlers/logout.ts b/src/handlers/logout.ts
index 3128c76f..9ae09677 100644
--- a/src/handlers/logout.ts
+++ b/src/handlers/logout.ts
@@ -1,7 +1,7 @@
 import { NextApiResponse, NextApiRequest } from 'next';
 import { HandleLogout as BaseHandleLogout } from '../auth0-session';
 import { assertReqRes } from '../utils/assert';
-import { HandlerError } from '../utils/errors';
+import { HandlerErrorCause, LogoutHandlerError } from '../utils/errors';
 
 /**
  * Custom options to pass to logout.
@@ -34,7 +34,7 @@ export default function handleLogoutFactory(handler: BaseHandleLogout): HandleLo
       assertReqRes(req, res);
       return await handler(req, res, options);
     } catch (e) {
-      throw new HandlerError(e);
+      throw new LogoutHandlerError(e as HandlerErrorCause);
     }
   };
 }
diff --git a/src/handlers/profile.ts b/src/handlers/profile.ts
index 73e1c90a..45d0c89d 100644
--- a/src/handlers/profile.ts
+++ b/src/handlers/profile.ts
@@ -2,7 +2,7 @@ import { NextApiResponse, NextApiRequest } from 'next';
 import { ClientFactory } from '../auth0-session';
 import { SessionCache, Session, fromJson, GetAccessToken } from '../session';
 import { assertReqRes } from '../utils/assert';
-import { HandlerError } from '../utils/errors';
+import { ProfileHandlerError, HandlerErrorCause } from '../utils/errors';
 
 export type AfterRefetch = (req: NextApiRequest, res: NextApiResponse, session: Session) => Promise<Session> | Session;
 
@@ -85,7 +85,7 @@ export default function profileHandler(
 
       res.json(session.user);
     } catch (e) {
-      throw new HandlerError(e);
+      throw new ProfileHandlerError(e as HandlerErrorCause);
     }
   };
 }
diff --git a/src/index.ts b/src/index.ts
index 8c7e559e..982daefd 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -128,7 +128,16 @@ export {
   WithPageAuthRequiredProps
 } from './frontend';
 
-export { AccessTokenError, HandlerError } from './utils/errors';
+export {
+  AuthError,
+  AccessTokenErrorCode,
+  AccessTokenError,
+  HandlerError,
+  CallbackHandlerError,
+  LoginHandlerError,
+  LogoutHandlerError,
+  ProfileHandlerError
+} from './utils/errors';
 
 export {
   ConfigParameters,
diff --git a/src/session/get-access-token.ts b/src/session/get-access-token.ts
index 5697be95..b6e4ade2 100644
--- a/src/session/get-access-token.ts
+++ b/src/session/get-access-token.ts
@@ -1,7 +1,7 @@
 import { IncomingMessage, ServerResponse } from 'http';
 import { NextApiRequest, NextApiResponse } from 'next';
 import { ClientFactory } from '../auth0-session';
-import { AccessTokenError } from '../utils/errors';
+import { AccessTokenError, AccessTokenErrorCode } from '../utils/errors';
 import { intersect, match } from '../utils/array';
 import { Session, SessionCache, fromTokenSet } from '../session';
 import { AuthorizationParameters, NextConfig } from '../config';
@@ -95,16 +95,19 @@ export default function accessTokenFactory(
   return async (req, res, accessTokenRequest): Promise<GetAccessTokenResult> => {
     let session = sessionCache.get(req, res);
     if (!session) {
-      throw new AccessTokenError('invalid_session', 'The user does not have a valid session.');
+      throw new AccessTokenError(AccessTokenErrorCode.MISSING_SESSION, 'The user does not have a valid session.');
     }
 
     if (!session.accessToken && !session.refreshToken) {
-      throw new AccessTokenError('invalid_session', 'The user does not have a valid access token.');
+      throw new AccessTokenError(
+        AccessTokenErrorCode.MISSING_ACCESS_TOKEN,
+        'The user does not have a valid access token.'
+      );
     }
 
     if (!session.accessTokenExpiresAt) {
       throw new AccessTokenError(
-        'access_token_expired',
+        AccessTokenErrorCode.EXPIRED_ACCESS_TOKEN,
         'Expiration information for the access token is not available. The user will need to sign in again.'
       );
     }
@@ -113,7 +116,7 @@ export default function accessTokenFactory(
       const persistedScopes = session.accessTokenScope;
       if (!persistedScopes || persistedScopes.length === 0) {
         throw new AccessTokenError(
-          'insufficient_scope',
+          AccessTokenErrorCode.INSUFFICIENT_SCOPE,
           'An access token with the requested scopes could not be provided. The user will need to sign in again.'
         );
       }
@@ -121,7 +124,7 @@ export default function accessTokenFactory(
       const matchingScopes = intersect(accessTokenRequest.scopes, persistedScopes.split(' '));
       if (!match(accessTokenRequest.scopes, [...matchingScopes])) {
         throw new AccessTokenError(
-          'insufficient_scope',
+          AccessTokenErrorCode.INSUFFICIENT_SCOPE,
           `Could not retrieve an access token with scopes "${accessTokenRequest.scopes.join(
             ' '
           )}". The user will need to sign in again.`
@@ -134,14 +137,14 @@ export default function accessTokenFactory(
     // Adding a skew of 1 minute to compensate.
     if (!session.refreshToken && session.accessTokenExpiresAt * 1000 - 60000 < Date.now()) {
       throw new AccessTokenError(
-        'access_token_expired',
+        AccessTokenErrorCode.EXPIRED_ACCESS_TOKEN,
         'The access token expired and a refresh token is not available. The user will need to sign in again.'
       );
     }
 
     if (accessTokenRequest?.refresh && !session.refreshToken) {
       throw new AccessTokenError(
-        'no_refresh_token',
+        AccessTokenErrorCode.MISSING_REFRESH_TOKEN,
         'A refresh token is required to refresh the access token, but none is present.'
       );
     }
@@ -180,7 +183,10 @@ export default function accessTokenFactory(
 
     // We don't have an access token.
     if (!session.accessToken) {
-      throw new AccessTokenError('invalid_session', 'The user does not have a valid access token.');
+      throw new AccessTokenError(
+        AccessTokenErrorCode.MISSING_ACCESS_TOKEN,
+        'The user does not have a valid access token.'
+      );
     }
 
     // The access token is not expired and has sufficient scopes;
diff --git a/src/utils/errors.ts b/src/utils/errors.ts
index 870658a8..ef5c013e 100644
--- a/src/utils/errors.ts
+++ b/src/utils/errors.ts
@@ -1,68 +1,248 @@
 import { HttpError } from 'http-errors';
 
+// eslint-disable-next-line max-len
+// Basic escaping for putting untrusted data directly into the HTML body, per: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#rule-1-html-encode-before-inserting-untrusted-data-into-html-element-content
+export function htmlSafe(input: string): string {
+  return input
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;');
+}
+
 /**
- * The error thrown by {@link GetAccessToken}
+ * @ignore
+ */
+export function appendCause(errorMessage: string, cause?: Error): string {
+  if (!cause) return errorMessage;
+  const separator = errorMessage.endsWith('.') ? '' : '.';
+  return `${errorMessage}${separator} CAUSE: ${htmlSafe(cause.message)}`;
+}
+
+type AuthErrorOptions = {
+  code: string;
+  message: string;
+  name: string;
+  cause?: Error;
+  status?: number;
+};
+
+/**
+ * The base class for all SDK errors.
+ *
+ * Because part of the error message can come from the OpenID Connect `error` query parameter we
+ * do some basic escaping which makes sure the default error handler is safe from XSS.
+ *
+ * If you write your own error handler, you should **not** render the error message
+ * without using a templating engine that will properly escape it for other HTML contexts first.
+ *
+ * Note that the error message of the {@link AuthError.cause | underlying error} is **not** escaped
+ * in any way, so do **not** render it without escaping it first!
  *
  * @category Server
  */
-export class AccessTokenError extends Error {
-  public code: string;
+export abstract class AuthError extends Error {
+  /**
+   * A machine-readable error code that remains stable within a major version of the SDK. You
+   * should rely on this error code to handle errors. In contrast, the error message is not part of
+   * the API and can change anytime. Do **not** parse or otherwise rely on the error message to
+   * handle errors.
+   */
+  public readonly code: string;
+
+  /**
+   * The error class name.
+   */
+  public readonly name: string;
+
+  /**
+   * The underlying error, if any. The error message of this underlying error is **not** escaped in
+   * any way, so do **not** render it without escaping it first!
+   */
+  public readonly cause?: Error;
+
+  /**
+   * The HTTP status code, if any.
+   */
+  public readonly status?: number;
 
   /* istanbul ignore next */
-  constructor(code: string, message: string) {
-    super(message);
+  constructor(options: AuthErrorOptions) {
+    super(appendCause(options.message, options.cause));
+    this.code = options.code;
+    this.name = options.name;
+    this.cause = options.cause;
+    this.status = options.status;
+  }
+}
 
-    // Saving class name in the property of our custom error as a shortcut.
-    this.name = this.constructor.name;
+/**
+ * Error codes for {@link AccessTokenError}.
+ *
+ * @category Server
+ */
+export enum AccessTokenErrorCode {
+  MISSING_SESSION = 'ERR_MISSING_SESSION',
+  MISSING_ACCESS_TOKEN = 'ERR_MISSING_ACCESS_TOKEN',
+  MISSING_REFRESH_TOKEN = 'ERR_MISSING_REFRESH_TOKEN',
+  EXPIRED_ACCESS_TOKEN = 'ERR_EXPIRED_ACCESS_TOKEN',
+  INSUFFICIENT_SCOPE = 'ERR_INSUFFICIENT_SCOPE'
+}
+
+/**
+ * The error thrown by {@link GetAccessToken}.
+ *
+ * @see the {@link AuthError.code | code property} contains a machine-readable error code that
+ * remains stable within a major version of the SDK. You should rely on this error code to handle
+ * errors. In contrast, the error message is not part of the API and can change anytime. Do **not**
+ * parse or otherwise rely on the error message to handle errors.
+ *
+ * @see {@link AccessTokenErrorCode} for the list of all possible error codes.
+ * @category Server
+ */
+export class AccessTokenError extends AuthError {
+  /* istanbul ignore next */
+  constructor(code: AccessTokenErrorCode, message: string) {
+    super({ code: code, message: message, name: 'AccessTokenError' });
 
     // Capturing stack trace, excluding constructor call from it.
     Error.captureStackTrace(this, this.constructor);
-
-    // Machine readable code.
-    this.code = code;
     Object.setPrototypeOf(this, AccessTokenError.prototype);
   }
 }
 
-// eslint-disable-next-line max-len
-// Basic escaping for putting untrusted data directly into the HTML body, per: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#rule-1-html-encode-before-inserting-untrusted-data-into-html-element-content
-export function htmlSafe(input: string): string {
-  return input
-    .replace(/&/g, '&amp;')
-    .replace(/</g, '&lt;')
-    .replace(/>/g, '&gt;')
-    .replace(/"/g, '&quot;')
-    .replace(/'/g, '&#39;');
+/**
+ * @ignore
+ */
+export type HandlerErrorCause = Error | AuthError | HttpError;
+
+type HandlerErrorOptions = {
+  code: string;
+  message: string;
+  name: string;
+  cause: HandlerErrorCause;
+};
+
+/**
+ * The base class for errors thrown by API route handlers. It extends {@link AuthError}.
+ *
+ * Because part of the error message can come from the OpenID Connect `error` query parameter we
+ * do some basic escaping which makes sure the default error handler is safe from XSS.
+ *
+ * If you write your own error handler, you should **not** render the error message
+ * without using a templating engine that will properly escape it for other HTML contexts first.
+ *
+ * @see the {@link AuthError.cause | cause property} contains the underlying error.
+ * The error message of this underlying error is **not** escaped in any way, so do **not** render
+ * it without escaping it first!
+ *
+ * @see the {@link AuthError.status | status property} contains the HTTP status code of the error,
+ * if any.
+ *
+ * @category Server
+ */
+export class HandlerError extends AuthError {
+  /* istanbul ignore next */
+  constructor(options: HandlerErrorOptions) {
+    let status: number | undefined;
+    if ('status' in options.cause) status = options.cause.status;
+    super({ ...options, status });
+  }
 }
 
 /**
- * The error thrown by API route handlers.
+ * The error thrown by the callback API route handler. It extends {@link HandlerError}.
  *
- * Because the error message can come from the OpenID Connect `error` query parameter we
+ * Because part of the error message can come from the OpenID Connect `error` query parameter we
  * do some basic escaping which makes sure the default error handler is safe from XSS.
  *
  * If you write your own error handler, you should **not** render the error message
  * without using a templating engine that will properly escape it for other HTML contexts first.
  *
+ * @see the {@link AuthError.cause | cause property} contains the underlying error.
+ * The error message of this underlying error is **not** escaped in any way, so do **not** render
+ * it without escaping it first!
+ *
+ * @see the {@link AuthError.status | status property} contains the HTTP status code of the error,
+ * if any.
+ *
+ * @category Server
+ */
+export class CallbackHandlerError extends HandlerError {
+  public static readonly code: string = 'ERR_CALLBACK_HANDLER_FAILURE';
+
+  /* istanbul ignore next */
+  constructor(cause: HandlerErrorCause) {
+    super({
+      code: CallbackHandlerError.code,
+      message: 'Callback handler failed.',
+      name: 'CallbackHandlerError',
+      cause
+    });
+    Object.setPrototypeOf(this, CallbackHandlerError.prototype);
+  }
+}
+
+/**
+ * The error thrown by the login API route handler. It extends {@link HandlerError}.
+ *
+ * @see the {@link AuthError.cause | cause property} contains the underlying error.
  * @category Server
  */
-export class HandlerError extends Error {
-  public status: number | undefined;
-  public code: string | undefined;
+export class LoginHandlerError extends HandlerError {
+  public static readonly code: string = 'ERR_LOGIN_HANDLER_FAILURE';
 
   /* istanbul ignore next */
-  constructor(error: Error | AccessTokenError | HttpError) {
-    super(htmlSafe(error.message));
+  constructor(cause: HandlerErrorCause) {
+    super({
+      code: LoginHandlerError.code,
+      message: 'Login handler failed.',
+      name: 'LoginHandlerError',
+      cause
+    });
+    Object.setPrototypeOf(this, LoginHandlerError.prototype);
+  }
+}
 
-    this.name = error.name;
+/**
+ * The error thrown by the logout API route handler. It extends {@link HandlerError}.
+ *
+ * @see the {@link AuthError.cause | cause property} contains the underlying error.
+ * @category Server
+ */
+export class LogoutHandlerError extends HandlerError {
+  public static readonly code: string = 'ERR_LOGOUT_HANDLER_FAILURE';
 
-    if ('code' in error) {
-      this.code = error.code;
-    }
+  /* istanbul ignore next */
+  constructor(cause: HandlerErrorCause) {
+    super({
+      code: LogoutHandlerError.code,
+      message: 'Logout handler failed.',
+      name: 'LogoutHandlerError',
+      cause
+    });
+    Object.setPrototypeOf(this, LogoutHandlerError.prototype);
+  }
+}
 
-    if ('status' in error) {
-      this.status = error.status;
-    }
-    Object.setPrototypeOf(this, HandlerError.prototype);
+/**
+ * The error thrown by the profile API route handler. It extends {@link HandlerError}.
+ *
+ * @see the {@link AuthError.cause | cause property} contains the underlying error.
+ * @category Server
+ */
+export class ProfileHandlerError extends HandlerError {
+  public static readonly code: string = 'ERR_PROFILE_HANDLER_FAILURE';
+
+  /* istanbul ignore next */
+  constructor(cause: HandlerErrorCause) {
+    super({
+      code: ProfileHandlerError.code,
+      message: 'Profile handler failed.',
+      name: 'ProfileHandlerError',
+      cause
+    });
+    Object.setPrototypeOf(this, ProfileHandlerError.prototype);
   }
 }


Test patch:
diff --git a/tests/handlers/login.test.ts b/tests/handlers/login.test.ts
index dc95ee44..d799316d 100644
--- a/tests/handlers/login.test.ts
+++ b/tests/handlers/login.test.ts
@@ -282,7 +282,7 @@ describe('login handler', () => {
     });
   });
 
-  test('should escape html in errors', async () => {
+  test('should escape html in error message', async () => {
     const baseUrl = await setup(withoutApi, { discoveryOptions: { error: '<script>alert("xss")</script>' } });
 
     await expect(get(baseUrl, '/api/auth/login', { fullResponse: true })).rejects.toThrow(
diff --git a/tests/handlers/logout.test.ts b/tests/handlers/logout.test.ts
index 111dfc63..8813b3d6 100644
--- a/tests/handlers/logout.test.ts
+++ b/tests/handlers/logout.test.ts
@@ -99,11 +99,10 @@ describe('logout handler', () => {
     });
   });
 
-  test('should escape html in errors', async () => {
+  test('should escape html in error message', async () => {
     const baseUrl = await setup(withoutApi);
-
     const res = await fetch(`${baseUrl}/api/auth/logout?error=%3Cscript%3Ealert(%27xss%27)%3C%2Fscript%3E`);
 
-    expect(await res.text()).toEqual('&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;');
+    expect(await res.text()).toEqual('Logout handler failed. CAUSE: &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;');
   });
 });
diff --git a/tests/handlers/profile.test.ts b/tests/handlers/profile.test.ts
index 5e83ecb8..48bbe362 100644
--- a/tests/handlers/profile.test.ts
+++ b/tests/handlers/profile.test.ts
@@ -154,11 +154,12 @@ describe('profile handler', () => {
     await expect(get(baseUrl, '/api/auth/me', { cookieJar })).rejects.toThrowError('some validation error');
   });
 
-  test('should escape html in errors', async () => {
+  test('should escape html in error message', async () => {
     const baseUrl = await setup(withoutApi);
-
     const res = await fetch(`${baseUrl}/api/auth/me?error=%3Cscript%3Ealert(%27xss%27)%3C%2Fscript%3E`);
 
-    expect(await res.text()).toEqual('&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;');
+    expect(await res.text()).toEqual(
+      'Profile handler failed. CAUSE: &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;'
+    );
   });
 });
diff --git a/tests/utils/errors.test.ts b/tests/utils/errors.test.ts
index 8bc1392b..880320eb 100644
--- a/tests/utils/errors.test.ts
+++ b/tests/utils/errors.test.ts
@@ -1,8 +1,190 @@
-import { AccessTokenError, HandlerError } from '../../src/utils/errors';
+import {
+  AccessTokenError,
+  AccessTokenErrorCode,
+  appendCause,
+  AuthError,
+  CallbackHandlerError,
+  HandlerError,
+  LoginHandlerError,
+  LogoutHandlerError,
+  ProfileHandlerError
+} from '../../src/utils/errors';
 
-describe('errors', () => {
-  test('should be instance of themselves', () => {
-    expect(new AccessTokenError('code', 'message')).toBeInstanceOf(AccessTokenError);
-    expect(new HandlerError(new Error('message'))).toBeInstanceOf(HandlerError);
+describe('appendCause', () => {
+  test('should append the cause error message', () => {
+    const message = 'foo';
+    const cause = new Error('bar');
+
+    expect(appendCause(message, cause)).toEqual(`${message}. CAUSE: ${cause.message}`);
+  });
+
+  test('should escape the cause error message', () => {
+    const message = 'foo';
+    const cause = new Error('&<>"\'');
+
+    expect(appendCause(message, cause)).toEqual(`${message}. CAUSE: &amp;&lt;&gt;&quot;&#39;`);
+  });
+
+  test('should not add a period if there is one already', () => {
+    const message = 'foo.';
+    const cause = new Error('bar');
+
+    expect(appendCause(message, cause)).toEqual(`${message} CAUSE: ${cause.message}`);
+  });
+
+  test('should return the error message when there is no cause', () => {
+    const message = 'foo';
+
+    expect(appendCause(message, undefined)).toEqual(message);
+  });
+});
+
+describe('AccessTokenError', () => {
+  test('should be instance of itself', () => {
+    expect(new AccessTokenError(AccessTokenErrorCode.MISSING_SESSION, '')).toBeInstanceOf(AccessTokenError);
+  });
+
+  test('should be instance of AuthError', () => {
+    expect(new AccessTokenError(AccessTokenErrorCode.MISSING_SESSION, '')).toBeInstanceOf(AuthError);
+  });
+
+  test('should set all properties', () => {
+    const message = 'foo';
+    const error = new AccessTokenError(AccessTokenErrorCode.MISSING_ACCESS_TOKEN, message);
+
+    expect(error.code).toEqual(AccessTokenErrorCode.MISSING_ACCESS_TOKEN);
+    expect(error.message).toEqual(message);
+    expect(error.name).toEqual('AccessTokenError');
+    expect(error.cause).toBeUndefined();
+    expect(error.status).toBeUndefined();
+  });
+});
+
+describe('HandlerError', () => {
+  test('should not be instance of itself', () => {
+    expect(new HandlerError({ code: '', message: '', name: '', cause: new Error() })).not.toBeInstanceOf(HandlerError);
+  });
+
+  test('should set all required properties', () => {
+    const code = 'foo';
+    const message = 'bar';
+    const name = 'baz';
+    const cause = new Error('qux');
+    const error = new HandlerError({ code, message, name, cause });
+
+    expect(error.code).toEqual(code);
+    expect(error.message).toEqual(`${message}. CAUSE: ${cause.message}`);
+    expect(error.name).toEqual(name);
+    expect(error.cause).toEqual(cause);
+    expect(error.status).toBeUndefined();
+  });
+
+  test('should set status', () => {
+    const cause: Error & { status?: number } = new Error();
+    cause.status = 400;
+    const error = new HandlerError({ code: '', message: '', name: '', cause });
+
+    expect(error.cause).toEqual(cause);
+    expect(error.status).toEqual(cause.status);
+  });
+});
+
+describe('CallbackHandlerError', () => {
+  test('should be instance of itself', () => {
+    expect(new CallbackHandlerError(new Error())).toBeInstanceOf(CallbackHandlerError);
+  });
+
+  test('should be instance of HandlerError', () => {
+    expect(new CallbackHandlerError(new Error())).toBeInstanceOf(HandlerError);
+  });
+
+  test('should be instance of AuthError', () => {
+    expect(new CallbackHandlerError(new Error())).toBeInstanceOf(AuthError);
+  });
+
+  test('should set all properties', () => {
+    const cause = new Error('foo');
+    const error = new CallbackHandlerError(cause);
+
+    expect(error.code).toEqual(CallbackHandlerError.code);
+    expect(error.message).toEqual(`Callback handler failed. CAUSE: ${cause.message}`);
+    expect(error.name).toEqual('CallbackHandlerError');
+    expect(error.cause).toEqual(cause);
+    expect(error.status).toBeUndefined();
+  });
+});
+
+describe('LoginHandlerError', () => {
+  test('should be instance of itself', () => {
+    expect(new LoginHandlerError(new Error())).toBeInstanceOf(LoginHandlerError);
+  });
+
+  test('should be instance of HandlerError', () => {
+    expect(new LoginHandlerError(new Error())).toBeInstanceOf(HandlerError);
+  });
+
+  test('should be instance of AuthError', () => {
+    expect(new LoginHandlerError(new Error())).toBeInstanceOf(AuthError);
+  });
+
+  test('should set all properties', () => {
+    const cause = new Error('foo');
+    const error = new LoginHandlerError(cause);
+
+    expect(error.code).toEqual(LoginHandlerError.code);
+    expect(error.message).toEqual(`Login handler failed. CAUSE: ${cause.message}`);
+    expect(error.name).toEqual('LoginHandlerError');
+    expect(error.cause).toEqual(cause);
+    expect(error.status).toBeUndefined();
+  });
+});
+
+describe('LogoutHandlerError', () => {
+  test('should be instance of itself', () => {
+    expect(new LogoutHandlerError(new Error())).toBeInstanceOf(LogoutHandlerError);
+  });
+
+  test('should be instance of HandlerError', () => {
+    expect(new LogoutHandlerError(new Error())).toBeInstanceOf(HandlerError);
+  });
+
+  test('should be instance of AuthError', () => {
+    expect(new LogoutHandlerError(new Error())).toBeInstanceOf(AuthError);
+  });
+
+  test('should set all properties', () => {
+    const cause = new Error('foo');
+    const error = new LogoutHandlerError(cause);
+
+    expect(error.code).toEqual(LogoutHandlerError.code);
+    expect(error.message).toEqual(`Logout handler failed. CAUSE: ${cause.message}`);
+    expect(error.name).toEqual('LogoutHandlerError');
+    expect(error.cause).toEqual(cause);
+    expect(error.status).toBeUndefined();
+  });
+});
+
+describe('ProfileHandlerError', () => {
+  test('should be instance of itself', () => {
+    expect(new ProfileHandlerError(new Error())).toBeInstanceOf(ProfileHandlerError);
+  });
+
+  test('should be instance of HandlerError', () => {
+    expect(new ProfileHandlerError(new Error())).toBeInstanceOf(HandlerError);
+  });
+
+  test('should be instance of AuthError', () => {
+    expect(new ProfileHandlerError(new Error())).toBeInstanceOf(AuthError);
+  });
+
+  test('should set all properties', () => {
+    const cause = new Error('foo');
+    const error = new ProfileHandlerError(cause);
+
+    expect(error.code).toEqual(ProfileHandlerError.code);
+    expect(error.message).toEqual(`Profile handler failed. CAUSE: ${cause.message}`);
+    expect(error.name).toEqual('ProfileHandlerError');
+    expect(error.cause).toEqual(cause);
+    expect(error.status).toBeUndefined();
   });
 });