// MyDay's WebAuthn client (SPEC-009 §5). Vanilla JavaScript: no framework, no // build step, no inline script — this file is served same-origin from the // reserved `static` workflow, so the CSP `script-src 'self'` holds (AC-3). // // It drives three ceremonies against the SPEC-006 endpoints: // // enroll POST ?do=auth&action=enroll.begin -> navigator.credentials.create // POST ?do=auth&action=enroll.finish (audited; TOTP still follows) // login POST ?do=auth&action=login.begin -> navigator.credentials.get // POST ?do=auth&action=login.finish // passkey the nudge screen's GET has ALREADY begun the ceremony and embedded // the options; this completes it with // POST ?do=auth&action=passkey.add // // The routing fields (do/action) and the CSRF token ride the QUERY STRING: the // finish step's body must be the raw credential JSON, because that is what the // WebAuthn server parses (go-webauthn reads the body). The door reads `csrf` // via FormValue, which covers the query string. // // Failures are GENERIC on purpose (A3): the client never reports which factor // failed, whether the identity exists, or why. "use strict"; (function () { // ── the data-* contract ──────────────────────────────────────────────────── var ROOT = "[data-webauthn]"; // data-webauthn="enroll" | "login" | "passkey" var START = "[data-webauthn-start]"; var STATUS = "[data-webauthn-status]"; var MESSAGES = { waiting: "Waiting for your device\u2026", cancelled: "Cancelled. You can try again.", failed: "Could not complete the passkey step. Try again, or use your authenticator code.", unavailable: "This browser cannot use passkeys. Use your authenticator code instead." }; // Shown only when the server does NOT redirect away (a successful enroll // stays on the enrollment screen so the TOTP step can follow). var SUCCESS = { enroll: "Passkey registered. Now set up your authenticator app.", login: "Signed in.", passkey: "Passkey added." }; // ── base64url <-> ArrayBuffer ───────────────────────────────────────────── // // The server serializes every binary WebAuthn field as base64url WITHOUT // padding (go-webauthn's protocol.URLEncodedBase64), so both padded and // unpadded input are accepted and the output is always unpadded. function base64urlToBuffer(value) { var b64 = String(value).replace(/-/g, "+").replace(/_/g, "/"); while (b64.length % 4 !== 0) { b64 += "="; } var raw = window.atob(b64); var bytes = new Uint8Array(raw.length); for (var i = 0; i < raw.length; i++) { bytes[i] = raw.charCodeAt(i); } return bytes.buffer; } function bufferToBase64url(buffer) { var bytes = new Uint8Array(buffer); var raw = ""; for (var i = 0; i < bytes.length; i++) { raw += String.fromCharCode(bytes[i]); } return window.btoa(raw).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } // ── options ─────────────────────────────────────────────────────────────── function decodeCreationOptions(options) { var publicKey = options.publicKey || options; var out = { publicKey: publicKey }; out.publicKey.challenge = base64urlToBuffer(publicKey.challenge); if (publicKey.user && publicKey.user.id) { out.publicKey.user.id = base64urlToBuffer(publicKey.user.id); } if (publicKey.excludeCredentials) { out.publicKey.excludeCredentials = publicKey.excludeCredentials.map(function (credential) { return { id: base64urlToBuffer(credential.id), type: credential.type, transports: credential.transports }; }); } return out; } function decodeRequestOptions(options) { var publicKey = options.publicKey || options; var out = { publicKey: publicKey }; out.publicKey.challenge = base64urlToBuffer(publicKey.challenge); if (publicKey.allowCredentials) { out.publicKey.allowCredentials = publicKey.allowCredentials.map(function (credential) { return { id: base64urlToBuffer(credential.id), type: credential.type, transports: credential.transports }; }); } return out; } // ── the credential the server expects ───────────────────────────────────── function credentialToJSON(credential) { var response = credential.response; var payload = { id: credential.id, rawId: bufferToBase64url(credential.rawId), type: credential.type, clientExtensionResults: typeof credential.getClientExtensionResults === "function" ? credential.getClientExtensionResults() : {} }; if (response.attestationObject) { // Registration (create). payload.response = { attestationObject: bufferToBase64url(response.attestationObject), clientDataJSON: bufferToBase64url(response.clientDataJSON) }; } else { // Assertion (get). payload.response = { authenticatorData: bufferToBase64url(response.authenticatorData), clientDataJSON: bufferToBase64url(response.clientDataJSON), signature: bufferToBase64url(response.signature) }; if (response.userHandle) { payload.response.userHandle = bufferToBase64url(response.userHandle); } } return payload; } // ── transport ───────────────────────────────────────────────────────────── function csrfToken() { var meta = document.querySelector('meta[name="csrf-token"]'); return meta ? meta.getAttribute("content") || "" : ""; } function withCSRF(url) { var token = csrfToken(); if (!token) { return url; } return url + (url.indexOf("?") === -1 ? "?" : "&") + "csrf=" + encodeURIComponent(token); } // begin posts an empty, cookied request and expects the options as JSON. function begin(url) { return fetch(withCSRF(url), { method: "POST", credentials: "same-origin", headers: { Accept: "application/json" } }).then(function (response) { if (!response.ok) { throw new Error("begin"); } return response.json(); }); } // finish posts the credential as the RAW body (the WebAuthn server parses it) // and follows the resulting redirect when the server sends one. function finish(url, credential) { return fetch(withCSRF(url), { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify(credential) }).then(function (response) { if (response.redirected && response.url) { window.location.assign(response.url); return; } if (!response.ok) { throw new Error("finish"); } }); } // ── the ceremonies ──────────────────────────────────────────────────────── function runEnroll(root) { return begin(root.getAttribute("data-begin")) .then(function (options) { return navigator.credentials.create(decodeCreationOptions(options)); }) .then(function (credential) { return finish(root.getAttribute("data-finish"), credentialToJSON(credential)); }); } function runLogin(root) { return begin(root.getAttribute("data-begin")) .then(function (options) { return navigator.credentials.get(decodeRequestOptions(options)); }) .then(function (credential) { return finish(root.getAttribute("data-finish"), credentialToJSON(credential)); }); } // The nudge screen's GET already began the registration, so the options are // embedded in the markup (data-options). A data-begin URL is accepted as a // fallback, which keeps the same client usable if that changes. function runPasskey(root) { var embedded = root.getAttribute("data-options"); var started; if (embedded) { started = Promise.resolve(JSON.parse(embedded)); } else if (root.getAttribute("data-begin")) { started = begin(root.getAttribute("data-begin")); } else { return Promise.reject(new Error("no options")); } return started .then(function (options) { return navigator.credentials.create(decodeCreationOptions(options)); }) .then(function (credential) { return finish(root.getAttribute("data-finish"), credentialToJSON(credential)); }); } var RUNNERS = { enroll: runEnroll, login: runLogin, passkey: runPasskey }; // ── wiring ──────────────────────────────────────────────────────────────── function statusOf(root) { return root.querySelector(STATUS) || document.getElementById("passkey-status"); } function setStatus(root, message, isError) { var node = statusOf(root); if (!node) { return; } node.textContent = message; node.className = isError ? "error" : ""; node.setAttribute("role", "status"); } function passkeysUnavailable() { return !window.PublicKeyCredential || !navigator.credentials; } function reportFailure(root, error) { if (error && error.name === "NotAllowedError") { setStatus(root, MESSAGES.cancelled, false); return; } setStatus(root, MESSAGES.failed, true); } function wire(root) { var kind = String(root.getAttribute("data-webauthn") || "").toLowerCase(); var runner = RUNNERS[kind]; var button = root.querySelector(START); if (!runner || !button) { return; } if (passkeysUnavailable()) { button.disabled = true; setStatus(root, MESSAGES.unavailable, false); return; } button.addEventListener("click", function () { button.disabled = true; setStatus(root, MESSAGES.waiting, false); runner(root) .then(function () { setStatus(root, SUCCESS[kind] || "Done.", false); }) ["catch"](function (error) { reportFailure(root, error); }) .then(function () { button.disabled = false; }); }); } function ready() { var roots = document.querySelectorAll(ROOT); for (var i = 0; i < roots.length; i++) { wire(roots[i]); } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", ready); } else { ready(); } })();