NextMyDay/internal/core/web/static/webauthn.js
Claus Lohmar dd865d4f27 feat(web): add the web shell and the PWA (SPEC-009)
- One base layout (layout.html) with the header, the nav from Registry.Nav()
  (D-038), a content slot and the CSRF token; every page renders inside it and
  a module supplies content only
- Embedded same-origin assets (app.css · app.js · webauthn.js · sw.js ·
  manifest.webmanifest · icons) served by the reserved `static` workflow
- webauthn.js drives the three ceremonies from data-* attributes — no inline
  script, so the CSP `script-src 'self'` holds (AC-3)
- manifest start_url "/" (D-011); the service worker never caches an
  authenticated response or /{GUID} (A5)
2026-10-14 10:53:57 +00:00

306 lines
11 KiB
JavaScript

// 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();
}
})();