diagnostics.adp

Delivered as text/html

Related Files

[ hide source ] | [ make this the default ]

File Contents

<!--
  ADP page for passkey diagnistics

  SPDX-License-Identifier: MPL-2.0
-->

<property name="doc(title)">@page_title;literal@</property>
<master>
<h2>@page_title;noquote@</h2>

<p>
This page checks basic WebAuthn capabilities and runs two interactive tests.
Nothing happens automatically; each test requires a button click (user gesture).
</p>

<style>
  .webauthn-diag { max-width: 72rem; }

  .webauthn-diag .wd-row { display:flex; gap:1rem; align-items:flex-start; margin:.4rem 0; }
  .webauthn-diag .wd-k   { width: 16rem; font-weight: 600; }
  .webauthn-diag .wd-v   { flex: 1; white-space: pre-wrap; }

  .webauthn-diag .wd-box { border: 1px solid #ddd; border-radius: .5rem; padding: .75rem; margin: 1rem 0; }
  .webauthn-diag .wd-out { padding:.5rem; border:1px solid #eee; border-radius:.4rem; margin-top:.5rem; white-space: pre-wrap; }

  .webauthn-diag .wd-ok  { color: #0a7; }
  .webauthn-diag .wd-bad { color: #c00; }
</style>


<div class="wd-row"><div class="k">Diagnostics ID</div><div class="v">@diag_id;noquote@</div></div>


<div class="webauthn-diag">

  <div class="wd-box" id="capBox">
    <h3>Capabilities</h3>
    <div class="wd-row"><div class="wd-k">Secure context</div><div class="wd-v" id="c_secure"></div></div>
    <div class="wd-row"><div class="wd-k">PublicKeyCredential</div><div class="wd-v" id="c_pkc"></div></div>
    <div class="wd-row"><div class="wd-k">navigator.credentials</div><div class="wd-v" id="c_creds"></div></div>
    <div class="wd-row"><div class="wd-k">UV platform authenticator</div><div class="wd-v" id="c_uvpaa"></div></div>
    <div class="wd-row"><div class="wd-k">Conditional mediation</div><div class="wd-v" id="c_cma"></div></div>
    <div class="wd-row"><div class="wd-k">User agent</div><div class="wd-v" id="c_ua"></div></div>
    <div class="wd-row"><div class="wd-k">UA platform (UA-CH)</div><div class="wd-v" id="c_uach"></div></div>
    <hr>
    <h3>User Activities</h3>    
    <div class="wd-row"><div class="wd-k">Passkey registered</div><div class="wd-v" id="c_kreg"></div></div>
    <div class="wd-row"><div class="wd-k">Passkey used</div><div class="wd-v" id="c_kuse"></div></div>
    <hr>

    <button class="btn btn-outline-secondary" id="copyBtn" type="button">Copy report</button>
    <div class="wd-out" id="copyOut" style="display:none"></div>    
    <button class="btn btn-outline-secondary" id="sendBtn" type="button">Send report to server log</button>
    <div class="wd-out" id="sendOut" style="display:none"></div>
  </div>

  <div class="wd-box">
    <h3>Test 1: Passkey-first (discoverable)</h3>
    <p>
      Calls <code>/webauthn/auth/options</code> with <code>auth_mode=passkey</code> and runs <code>navigator.credentials.get()</code>.
    </p>
    <button class="btn btn-outline-secondary" id="testPasskeyBtn" type="button">Run passkey-first test</button>
    <div class="wd-out" id="outPasskey"></div>
  </div>

  <div class="wd-box">
    <h3>Test 2: Identifier-first</h3>
    <p>
      Provide email/username; calls <code>/webauthn/auth/options</code> with <code>auth_mode=identifier</code>.
    </p>
    <div class="wd-row">
      <div class="wd-k">Identifier</div>
      <div class="wd-v">
        <input id="identInput" type="text" placeholder="email/username" size="34">
        <button class="btn btn-outline-secondary" id="testIdentBtn" type="button">Run identifier-first test</button>
      </div>
    </div>
    <div class="wd-out" id="outIdent"></div>
  </div>

</div>

<script nonce="@::__csp_nonce@">
(function() {
  const DIAG_ID = "@diag_id;noquote@";
  const returnUrl = "@return_url;noquote@";
  const kreg = localStorage.getItem("webauthn:registered") === "1";
  const kuse = localStorage.getItem("webauthn:used") === "1";

  const cap = {
    secureContext: window.isSecureContext === true,
    hasPublicKeyCredential: !!window.PublicKeyCredential,
    hasNavigatorCredentials: !!(navigator && navigator.credentials),
    uap: navigator.userAgent || "",
    uachPlatform: null,
    uvpaa: null,
    cma: null,
  };

  async function buildDiagReport() {
    return {
      diag_id: "@diag_id;noquote@",
      when: new Date().toISOString(),
      url: location.href,
      secureContext: window.isSecureContext === true,
      hasPublicKeyCredential: !!window.PublicKeyCredential,
      hasNavigatorCredentials: !!(navigator && navigator.credentials),
      uvpaa: cap.uvpaa,
      conditionalMediation: cap.cma,
      userAgent: navigator.userAgent || "",
      uaCHPlatform: (navigator.userAgentData && navigator.userAgentData.platform) ? navigator.userAgentData.platform : null,
      keyRegistered: kreg,
      keyUsed: kuse,
    };
  }
  
  document.getElementById("sendBtn").addEventListener("click", async () => {
    const out = document.getElementById("sendOut");
    out.style.display = "";
    out.style.color = "inherit";
    out.textContent = "Sending diagnostics report...";
  
    try {
      const payload = await buildDiagReport();
      const res = await fetch("/webauthn/diagnostics", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
        credentials: "same-origin",
      });
      if (!res.ok) {
        // strict error contract: {error, detail}
        let data = {};
        try { data = await res.json(); } catch (_) {}
        out.style.color = "red";
        out.textContent = data.detail || ("Send failed: HTTP " + res.status);
        return;
      }
      out.style.color = "inherit";
      out.textContent = "Sent diagnostics report (logged server-side). Please mention diag_id: " + DIAG_ID;
    } catch (e) {
      out.style.color = "red";
      out.textContent = "Send failed: " + (e?.message || String(e));
    }
  });
    
  function setLine(id, ok, text) {
    const el = document.getElementById(id);
    el.textContent = text;
    el.className = ok ? "ed-v wd-ok" : "ed-v wd-bad";
  }

  async function probeAsyncCaps() {
    // isUserVerifyingPlatformAuthenticatorAvailable
    try {
      if (window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable) {
        cap.uvpaa = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
      }
    } catch (e) {
      cap.uvpaa = { error: e?.name || "error", detail: e?.message || String(e) };
    }

    // isConditionalMediationAvailable (optional)
    try {
      if (window.PublicKeyCredential?.isConditionalMediationAvailable) {
        cap.cma = await PublicKeyCredential.isConditionalMediationAvailable();
      }
    } catch (e) {
      cap.cma = { error: e?.name || "error", detail: e?.message || String(e) };
    }

    // UA-CH
    try {
      cap.uachPlatform = navigator.userAgentData?.platform || null;
    } catch (e) {
      cap.uachPlatform = null;
    }
  }

  function renderCaps() {
    setLine("c_secure", cap.secureContext, String(cap.secureContext));
    setLine("c_pkc", cap.hasPublicKeyCredential, String(cap.hasPublicKeyCredential));
    setLine("c_creds", cap.hasNavigatorCredentials, String(cap.hasNavigatorCredentials));
    setLine("c_uvpaa", cap.uvpaa === true, cap.uvpaa === null ? "(not available)" : JSON.stringify(cap.uvpaa));
    setLine("c_cma", cap.cma === true, cap.cma === null ? "(not available)" : JSON.stringify(cap.cma));

    setLine("c_kreg", kreg, kreg);
    setLine("c_kuse", kuse, kuse);

    document.getElementById("c_ua").textContent = cap.uap;
    document.getElementById("c_uach").textContent = cap.uachPlatform || "(not available)";
  }

  function report(outEl, isErr, msg) {
    outEl.style.display = "";
    outEl.style.color = isErr ? "red" : "inherit";
    outEl.textContent = (typeof msg === "string") ? msg : JSON.stringify(msg, null, 2);
  }

  function b64urlToUint8Array(s) {
    // base64url -> Uint8Array
    s = s.replace(/-/g, "+").replace(/_/g, "/");
    const pad = s.length % 4;
    if (pad) s += "=".repeat(4 - pad);
    const bin = atob(s);
    const bytes = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
    return bytes;
  }

  function bufToB64url(buf) {
    const bytes = new Uint8Array(buf);
    let bin = "";
    for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
    const b64 = btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
    return b64;
  }

  async function fetchOptions(authMode, identifier) {
    let url = "/webauthn/auth/options?return_url=" + encodeURIComponent(returnUrl || "/") +
              "&auth_mode=" + encodeURIComponent(authMode);

    if (authMode === "identifier") {
      url += "&identifier=" + encodeURIComponent(identifier || "");
    }

    const res = await fetch(url, { headers: { "Accept": "application/json" } });
    const data = await res.json().catch(() => ({}));
    data._http_status = res.status;
    return data;
  }

  async function runGet(outEl, authMode, identifier) {
    if (!cap.hasPublicKeyCredential || !cap.hasNavigatorCredentials) {
      report(outEl, 1, "WebAuthn APIs not available in this browser.");
      return;
    }
    if (!cap.secureContext) {
      report(outEl, 1, "Not a secure context (WebAuthn requires HTTPS).");
      return;
    }

    report(outEl, 0, "Fetching options...");
    const data = await fetchOptions(authMode, identifier);

    if (data && data.error) {
      // strict contract: {error, detail}
      report(outEl, 1, data.detail || data.error);
      return;
    }

    const state = data.state;
    const opts  = data.publicKey || data.options || data;
    if (!state) {
      report(outEl, 1, { error: "missing-state", detail: "options response missing 'state'", raw: data });
      return;
    }
    
    async function sendDiag(obj) {
      try {
        await fetch("/webauthn/diagnostics", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(obj),
          credentials: "same-origin",
        });
      } catch (_) {}
    }
    
    try {
      opts.challenge = b64urlToUint8Array(opts.challenge);
      if (opts.allowCredentials) {
        opts.allowCredentials = opts.allowCredentials.map(c => ({
          ...c,
          id: b64urlToUint8Array(c.id),
        }));
      }

      report(outEl, 0, "Calling navigator.credentials.get()...");
      const assertion = await navigator.credentials.get({ publicKey: opts });
      if (!assertion) {
        report(outEl, 1, "No assertion returned");
        return;
      }

      // We stop here; this is a diagnostics page.
      // (Optionally you could POST to /webauthn/auth/verify as well, but this already tells
      // whether the authenticator flow is invoked successfully.)
      report(outEl, 0, {
        ok: true,
        gotAssertion: true,
        id: assertion.id,
        type: assertion.type,
      });
      sendDiag({ diag_id: "@diag_id;noquote@", event: "test", mode: authMode, result: "ok", when: new Date().toISOString() });

    } catch (e) {
      report(outEl, 1, {
        error: e?.name || "error",
        detail: e?.message || String(e),
      });

      sendDiag({
        diag_id: "@diag_id;noquote@",
        event: "test",
        mode: authMode,
        result: "error",
        when: new Date().toISOString(),
        err: { name: e?.name || "error", detail: e?.message || String(e) }
      });
    }
  }

  // Wire buttons
  document.getElementById("testPasskeyBtn").addEventListener("click", () => {
    runGet(document.getElementById("outPasskey"), "passkey", "");
  });

  document.getElementById("testIdentBtn").addEventListener("click", () => {
    const ident = (document.getElementById("identInput").value || "").trim();
    runGet(document.getElementById("outIdent"), "identifier", ident);
  });

  // Copy report
  document.getElementById("copyBtn").addEventListener("click", async () => {
    const reportObj = {
      when: new Date().toISOString(),
      url: location.href,
      secureContext: cap.secureContext,
      hasPublicKeyCredential: cap.hasPublicKeyCredential,
      hasNavigatorCredentials: cap.hasNavigatorCredentials,
      uvpaa: cap.uvpaa,
      conditionalMediation: cap.cma,
      userAgent: cap.uap,
      uaCHPlatform: cap.uachPlatform,
      keyRegistered: kreg,
      keyUsed: kuse,
    };
    const text = JSON.stringify(reportObj, null, 2);
    try {
      await navigator.clipboard.writeText(text);
      document.getElementById("copyOut").style.display = "";
      document.getElementById("copyOut").textContent = "Copied report to clipboard.";
    } catch (e) {
      document.getElementById("copyOut").style.display = "";
      document.getElementById("copyOut").textContent = "Clipboard write failed. Here is the report:\n\n" + text;
    }
  });

  // init
  probeAsyncCaps().then(() => {
    renderCaps();
  });

})();
</script>