- 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)
306 lines
11 KiB
JavaScript
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();
|
|
}
|
|
})();
|