agilentics / boiler
// The browser half of the two WebAuthn ceremonies.
//
// Both are the same shape: ask the server for options, hand them to the
// platform, send back what it produced. All the checking happens on the server -
// nothing here decides whether a credential is acceptable, and nothing here
// should, because a page can be replaced by whoever is looking at it.
//
// The wire format is base64url and the platform wants ArrayBuffers, so the two
// conversions below are the whole of the impedance mismatch.
const webauthn = (() => {

  const supported = () =>
    typeof window.PublicKeyCredential === "function" &&
    !!(navigator.credentials && navigator.credentials.create);

  // base64url, not base64: no padding, and -_ where +/ would be. Getting this
  // wrong produces a credential the server cannot match, with no error to say so.
  const toBuffer = (value) => {
    const b64 = value.replace(/-/g, "+").replace(/_/g, "/");
    const pad = b64.length % 4 ? "=".repeat(4 - (b64.length % 4)) : "";
    const raw = atob(b64 + pad);
    const out = new Uint8Array(raw.length);
    for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
    return out.buffer;
  };

  const fromBuffer = (buffer) => {
    const bytes = new Uint8Array(buffer);
    let s = "";
    for (const b of bytes) s += String.fromCharCode(b);
    return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  };

  // The server sends the fields the spec defines as ArrayBuffers encoded as
  // base64url strings. Decode exactly those and leave everything else alone.
  const decodeOptions = (options) => {
    const out = { ...options };
    if (out.challenge) out.challenge = toBuffer(out.challenge);
    if (out.user && out.user.id) out.user = { ...out.user, id: toBuffer(out.user.id) };
    for (const key of ["excludeCredentials", "allowCredentials"]) {
      if (Array.isArray(out[key])) {
        out[key] = out[key].map((c) => ({ ...c, id: toBuffer(c.id) }));
      }
    }
    return out;
  };

  const encodeCredential = (cred) => {
    const r = cred.response;
    const out = {
      id: cred.id,
      rawId: fromBuffer(cred.rawId),
      type: cred.type,
      clientExtensionResults: cred.getClientExtensionResults(),
      response: { clientDataJSON: fromBuffer(r.clientDataJSON) },
    };
    if (r.attestationObject) {
      out.response.attestationObject = fromBuffer(r.attestationObject);
      // A hint for the server's stored transports. Not every authenticator
      // reports it, which is why it is guarded rather than assumed.
      if (r.getTransports) out.response.transports = r.getTransports();
    } else {
      out.response.authenticatorData = fromBuffer(r.authenticatorData);
      out.response.signature = fromBuffer(r.signature);
      out.response.userHandle = r.userHandle ? fromBuffer(r.userHandle) : null;
    }
    return out;
  };

  // The platform throws for "the person cancelled" and for "this device cannot"
  // alike, and its messages are not written for a person. One readable sentence
  // instead, with the original left in the console for whoever is debugging.
  const friendly = (err) => {
    console.debug("WebAuthn:", err);
    if (err && err.name === "NotAllowedError") {
      return new Error("That was cancelled, or it timed out. Try again.");
    }
    if (err && err.name === "InvalidStateError") {
      return new Error("That authenticator is already enrolled on this account.");
    }
    return err instanceof Error ? err : new Error("Your device could not complete that.");
  };

  async function enrol(name) {
    if (!supported()) throw new Error("This browser does not support passkeys.");
    const options = await api.post("/api/webauthn/register/options");
    let credential;
    try {
      credential = await navigator.credentials.create({
        publicKey: decodeOptions(options),
      });
    } catch (err) { throw friendly(err); }
    return api.post("/api/webauthn/register/verify", {
      credential: encodeCredential(credential),
      name: name || null,
    });
  }

  // The second leg of sign-in. Called only after /api/login answered
  // mfa_required, so the server already holds the pending state this needs.
  async function assertLogin() {
    if (!supported()) {
      throw new Error("This account needs a passkey, and this browser cannot use one.");
    }
    const options = await api.post("/api/login/webauthn/options");
    let credential;
    try {
      credential = await navigator.credentials.get({
        publicKey: decodeOptions(options),
      });
    } catch (err) { throw friendly(err); }
    return api.post("/api/login/webauthn/verify", {
      credential: encodeCredential(credential),
    });
  }

  return { supported, enrol, assertLogin };
})();