Source

lib/client/WebauthnClient.ts

import {
  create as createWebauthnCredential,
  get as getWebauthnCredential,
} from "@github/webauthn-json";

import { WebauthnSupport } from "../WebauthnSupport";
import { Client } from "./Client";
import { PasscodeState } from "../state/PasscodeState";

import { WebauthnState } from "../state/WebauthnState";

import {
  InvalidWebauthnCredentialError,
  TechnicalError,
  UnauthorizedError,
  UserVerificationError,
  WebauthnRequestCancelledError,
} from "../Errors";

import {
  Attestation,
  User,
  WebauthnCredentials,
  WebauthnFinalized,
} from "../Dto";

/**
 * A class that handles WebAuthn authentication and registration.
 *
 * @constructor
 * @category SDK
 * @subcategory Clients
 * @extends {Client}
 */
class WebauthnClient extends Client {
  webauthnState: WebauthnState;
  passcodeState: PasscodeState;
  controller: AbortController;

  _getCredential = getWebauthnCredential;
  _createCredential = createWebauthnCredential;

  // eslint-disable-next-line require-jsdoc
  constructor(api: string, timeout = 13000) {
    super(api, timeout);
    /**
     *  @public
     *  @type {WebauthnState}
     */
    this.webauthnState = new WebauthnState();
    /**
     *  @public
     *  @type {PasscodeState}
     */
    this.passcodeState = new PasscodeState();
  }

  /**
   * Performs a WebAuthn authentication ceremony. When 'userID' is specified, the API provides a list of
   * allowed credentials and the browser is able to present a list of suitable credentials to the user.
   *
   * @param {string=} userID - The user's UUID.
   * @param {boolean=} useConditionalMediation - Enables autofill assisted login.
   * @return {Promise<void>}
   * @throws {WebauthnRequestCancelledError}
   * @throws {InvalidWebauthnCredentialError}
   * @throws {RequestTimeoutError}
   * @throws {TechnicalError}
   * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/webauthnLoginInit
   * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/webauthnLoginFinal
   * @see https://www.w3.org/TR/webauthn-2/#authentication-ceremony
   */
  async login(
    userID?: string,
    useConditionalMediation?: boolean
  ): Promise<void> {
    const challengeResponse = await this.client.post(
      "/webauthn/login/initialize",
      { user_id: userID }
    );

    if (!challengeResponse.ok) {
      throw new TechnicalError();
    }

    const challenge = challengeResponse.json();
    challenge.signal = this._createAbortSignal();

    if (useConditionalMediation) {
      // `CredentialMediationRequirement` doesn't support "conditional" in the current typescript version.
      challenge.mediation = "conditional" as CredentialMediationRequirement;
    }

    let assertion;
    try {
      assertion = await this._getCredential(challenge);
    } catch (e) {
      throw new WebauthnRequestCancelledError(e);
    }

    const assertionResponse = await this.client.post(
      "/webauthn/login/finalize",
      assertion
    );

    if (assertionResponse.status === 400 || assertionResponse.status === 401) {
      throw new InvalidWebauthnCredentialError();
    } else if (!assertionResponse.ok) {
      throw new TechnicalError();
    }

    const finalizeResponse: WebauthnFinalized = assertionResponse.json();

    this.webauthnState
      .read()
      .addCredential(finalizeResponse.user_id, finalizeResponse.credential_id)
      .write();

    this.passcodeState.read().reset(userID).write();

    return;
  }

  /**
   * Performs a WebAuthn registration ceremony.
   *
   * @return {Promise<void>}
   * @throws {WebauthnRequestCancelledError}
   * @throws {RequestTimeoutError}
   * @throws {UnauthorizedError}
   * @throws {TechnicalError}
   * @throws {UserVerificationError}
   * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/webauthnRegInit
   * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/webauthnRegFinal
   * @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
   */
  async register(): Promise<void> {
    const challengeResponse = await this.client.post(
      "/webauthn/registration/initialize"
    );

    if (challengeResponse.status >= 400 && challengeResponse.status <= 499) {
      throw new UnauthorizedError();
    } else if (!challengeResponse.ok) {
      throw new TechnicalError();
    }

    const challenge = challengeResponse.json();
    challenge.signal = this._createAbortSignal();

    let attestation;
    try {
      attestation = (await this._createCredential(challenge)) as Attestation;
    } catch (e) {
      throw new WebauthnRequestCancelledError(e);
    }

    // The generated PublicKeyCredentialWithAttestationJSON object does not align with the API. The list of
    // supported transports must be available under a different path.
    attestation.transports = attestation.response.transports;

    const attestationResponse = await this.client.post(
      "/webauthn/registration/finalize",
      attestation
    );

    if (
      attestationResponse.status >= 400 &&
      attestationResponse.status <= 499
    ) {
      if (attestationResponse.status === 422) {
        throw new UserVerificationError();
      }
      throw new UnauthorizedError();
    }
    if (!attestationResponse.ok) {
      throw new TechnicalError();
    }

    const finalizeResponse: WebauthnFinalized = attestationResponse.json();
    this.webauthnState
      .read()
      .addCredential(finalizeResponse.user_id, finalizeResponse.credential_id)
      .write();

    return;
  }

  /**
   * Returns a list of all WebAuthn credentials assigned to the current user.
   *
   * @return {Promise<WebauthnCredentials>}
   * @throws {UnauthorizedError}
   * @throws {RequestTimeoutError}
   * @throws {TechnicalError}
   * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/listCredentials
   */
  async listCredentials(): Promise<WebauthnCredentials> {
    const response = await this.client.get("/webauthn/credentials");

    if (response.status === 401) {
      throw new UnauthorizedError();
    } else if (!response.ok) {
      throw new TechnicalError();
    }

    return response.json();
  }

  /**
   * Updates the WebAuthn credential.
   *
   * @param {string=} credentialID - The credential's UUID.
   * @param {string} name - The new credential name.
   * @return {Promise<void>}
   * @throws {NotFoundError}
   * @throws {UnauthorizedError}
   * @throws {RequestTimeoutError}
   * @throws {TechnicalError}
   * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/updateCredential
   */
  async updateCredential(credentialID: string, name: string): Promise<void> {
    const response = await this.client.patch(
      `/webauthn/credentials/${credentialID}`,
      {
        name,
      }
    );

    if (response.status === 401) {
      throw new UnauthorizedError();
    } else if (!response.ok) {
      throw new TechnicalError();
    }

    return;
  }

  /**
   * Deletes the WebAuthn credential.
   *
   * @param {string=} credentialID - The credential's UUID.
   * @return {Promise<void>}
   * @throws {NotFoundError}
   * @throws {UnauthorizedError}
   * @throws {RequestTimeoutError}
   * @throws {TechnicalError}
   * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/deleteCredential
   */
  async deleteCredential(credentialID: string): Promise<void> {
    const response = await this.client.delete(
      `/webauthn/credentials/${credentialID}`
    );

    if (response.status === 401) {
      throw new UnauthorizedError();
    } else if (!response.ok) {
      throw new TechnicalError();
    }

    return;
  }

  /**
   * Determines whether a credential registration ceremony should be performed. Returns 'true' when WebAuthn
   * is supported and the user's credentials do not intersect with the credentials already known on the
   * current browser/device.
   *
   * @param {User} user - The user object.
   * @return {Promise<boolean>}
   */
  async shouldRegister(user: User): Promise<boolean> {
    const supported = WebauthnSupport.supported();

    if (!user.webauthn_credentials || !user.webauthn_credentials.length) {
      return supported;
    }

    const matches = this.webauthnState
      .read()
      .matchCredentials(user.id, user.webauthn_credentials);

    return supported && !matches.length;
  }

  // eslint-disable-next-line require-jsdoc
  _createAbortSignal() {
    if (this.controller) {
      this.controller.abort();
    }

    this.controller = new AbortController();
    return this.controller.signal;
  }
}

export { WebauthnClient };