mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-01 17:38:15 +00:00
357 lines
14 KiB
JavaScript
357 lines
14 KiB
JavaScript
// WebAuthn ceremony helpers. Talks to the JSON endpoints in internal/auth and
|
|
// bridges the base64url <-> ArrayBuffer encoding the browser API requires.
|
|
|
|
function b64urlToBuf(s) {
|
|
s = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
const pad = s.length % 4 ? "=".repeat(4 - (s.length % 4)) : "";
|
|
const bin = atob(s + pad);
|
|
const buf = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
|
return buf.buffer;
|
|
}
|
|
|
|
function bufToB64url(buf) {
|
|
const bytes = new Uint8Array(buf);
|
|
let bin = "";
|
|
for (const b of bytes) bin += String.fromCharCode(b);
|
|
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
}
|
|
|
|
// Decode the PublicKeyCredentialCreationOptions JSON from the server.
|
|
function decodeCreation(opts) {
|
|
opts.challenge = b64urlToBuf(opts.challenge);
|
|
opts.user.id = b64urlToBuf(opts.user.id);
|
|
if (opts.excludeCredentials) {
|
|
opts.excludeCredentials = opts.excludeCredentials.map((c) => ({ ...c, id: b64urlToBuf(c.id) }));
|
|
}
|
|
return opts;
|
|
}
|
|
|
|
function decodeRequest(opts) {
|
|
opts.challenge = b64urlToBuf(opts.challenge);
|
|
if (opts.allowCredentials) {
|
|
opts.allowCredentials = opts.allowCredentials.map((c) => ({ ...c, id: b64urlToBuf(c.id) }));
|
|
}
|
|
return opts;
|
|
}
|
|
|
|
function encodeAttestation(cred) {
|
|
return {
|
|
id: cred.id,
|
|
rawId: bufToB64url(cred.rawId),
|
|
type: cred.type,
|
|
response: {
|
|
attestationObject: bufToB64url(cred.response.attestationObject),
|
|
clientDataJSON: bufToB64url(cred.response.clientDataJSON),
|
|
},
|
|
clientExtensionResults: cred.getClientExtensionResults(),
|
|
};
|
|
}
|
|
|
|
function encodeAssertion(cred) {
|
|
return {
|
|
id: cred.id,
|
|
rawId: bufToB64url(cred.rawId),
|
|
type: cred.type,
|
|
response: {
|
|
authenticatorData: bufToB64url(cred.response.authenticatorData),
|
|
clientDataJSON: bufToB64url(cred.response.clientDataJSON),
|
|
signature: bufToB64url(cred.response.signature),
|
|
userHandle: cred.response.userHandle ? bufToB64url(cred.response.userHandle) : null,
|
|
},
|
|
clientExtensionResults: cred.getClientExtensionResults(),
|
|
};
|
|
}
|
|
|
|
function setStatus(msg) {
|
|
const el = document.getElementById("passkey-status");
|
|
if (el) el.textContent = msg;
|
|
}
|
|
|
|
async function postJSON(url, body) {
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.error || "request failed");
|
|
return data;
|
|
}
|
|
|
|
async function passkeyRegister() {
|
|
const nameEl = document.getElementById("username");
|
|
const username = nameEl ? nameEl.value.trim() : "";
|
|
const dnEl = document.getElementById("displayName");
|
|
const displayName = dnEl ? dnEl.value.trim() : "";
|
|
try {
|
|
setStatus("Starting…");
|
|
const options = await postJSON("/api/register/begin", { username, displayName });
|
|
const cred = await navigator.credentials.create({ publicKey: decodeCreation(options.publicKey) });
|
|
setStatus("Verifying…");
|
|
const result = await fetch("/api/register/finish", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(encodeAttestation(cred)),
|
|
});
|
|
const data = await result.json().catch(() => ({}));
|
|
if (!result.ok) throw new Error(data.error || "registration failed");
|
|
window.location = data.redirect || "/";
|
|
} catch (e) {
|
|
setStatus("Error: " + e.message);
|
|
}
|
|
}
|
|
|
|
// finishAssertion posts a completed assertion to the server and redirects.
|
|
async function finishAssertion(url, cred) {
|
|
setStatus("Verifying…");
|
|
const result = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(encodeAssertion(cred)),
|
|
});
|
|
const data = await result.json().catch(() => ({}));
|
|
if (!result.ok) throw new Error(data.error || "login failed");
|
|
window.location = data.redirect || "/";
|
|
}
|
|
|
|
// addPasskey registers an additional passkey for the already-signed-in user
|
|
// (used from the account page) and reloads it on success.
|
|
async function addPasskey() {
|
|
try {
|
|
const nameEl = document.getElementById("new_passkey_name");
|
|
const name = nameEl ? nameEl.value.trim() : "";
|
|
setStatus("Starting…");
|
|
const options = await postJSON("/api/register/begin", { name });
|
|
const cred = await navigator.credentials.create({ publicKey: decodeCreation(options.publicKey) });
|
|
setStatus("Verifying…");
|
|
const result = await fetch("/api/register/finish", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(encodeAttestation(cred)),
|
|
});
|
|
const data = await result.json().catch(() => ({}));
|
|
if (!result.ok) throw new Error(data.error || "could not add passkey");
|
|
window.location = "/account?pk=" + encodeURIComponent("Passkey added.");
|
|
} catch (e) {
|
|
setStatus("Error: " + e.message);
|
|
}
|
|
}
|
|
|
|
// passkeyLogin runs a username-scoped assertion (for credentials that aren't
|
|
// discoverable, where the server needs to know which account to challenge).
|
|
async function passkeyLogin() {
|
|
const nameEl = document.getElementById("username");
|
|
const username = nameEl ? nameEl.value.trim() : "";
|
|
try {
|
|
setStatus("Starting…");
|
|
const options = await postJSON("/api/login/begin", { username });
|
|
const cred = await navigator.credentials.get({ publicKey: decodeRequest(options.publicKey) });
|
|
await finishAssertion("/api/login/finish", cred);
|
|
} catch (e) {
|
|
setStatus("Error: " + e.message);
|
|
}
|
|
}
|
|
|
|
// reauthPasskey re-verifies the ALREADY signed-in user before a sensitive action
|
|
// (account deletion), then submits the form named by the button's data-form.
|
|
//
|
|
// Unlike the sign-in ceremonies this posts no username — the server asserts
|
|
// against the session's own account — and grants no access by itself: it stamps
|
|
// the session as freshly verified, and the form's handler decides what that's
|
|
// worth. requestSubmit (not submit) so the form's [data-confirm] gate still runs.
|
|
async function reauthPasskey(e) {
|
|
const btn = e.currentTarget;
|
|
const form = document.getElementById(btn.getAttribute("data-form") || "");
|
|
try {
|
|
setStatus("Starting…");
|
|
const options = await postJSON("/account/reauth/passkey/begin", {});
|
|
const cred = await navigator.credentials.get({ publicKey: decodeRequest(options.publicKey) });
|
|
setStatus("Verifying…");
|
|
const result = await fetch("/account/reauth/passkey/finish", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(encodeAssertion(cred)),
|
|
});
|
|
const data = await result.json().catch(() => ({}));
|
|
if (!result.ok) throw new Error(data.error || "verification failed");
|
|
setStatus("Verified.");
|
|
if (form) form.requestSubmit();
|
|
} catch (err) {
|
|
setStatus("Error: " + err.message);
|
|
}
|
|
}
|
|
|
|
// Tracks an in-flight conditional-mediation request so an explicit action can
|
|
// supersede the passive autofill prompt without the two colliding.
|
|
let conditionalAbort = null;
|
|
|
|
// passkeyDiscoverable runs a usernameless assertion. mediation "conditional"
|
|
// surfaces passkeys passively in the username field's autofill; "optional"
|
|
// (or omitted) pops the passkey chooser immediately.
|
|
async function passkeyDiscoverable(mediation) {
|
|
try {
|
|
if (mediation !== "conditional") setStatus("Starting…");
|
|
const options = await postJSON("/api/login/discoverable/begin", {});
|
|
const getOpts = { publicKey: decodeRequest(options.publicKey) };
|
|
if (mediation) getOpts.mediation = mediation;
|
|
if (mediation === "conditional") {
|
|
conditionalAbort = new AbortController();
|
|
getOpts.signal = conditionalAbort.signal;
|
|
}
|
|
const cred = await navigator.credentials.get(getOpts);
|
|
await finishAssertion("/api/login/discoverable/finish", cred);
|
|
} catch (e) {
|
|
if (e.name === "AbortError") return; // superseded by an explicit action
|
|
if (mediation === "conditional") return; // autofill simply went unused
|
|
setStatus("Error: " + e.message);
|
|
}
|
|
}
|
|
|
|
// passkeyButton handles the explicit "Sign in with a passkey" button: it uses a
|
|
// usernameless prompt by default, falling back to a username-scoped challenge
|
|
// when a username is typed (covers older, non-discoverable credentials).
|
|
function passkeyButton() {
|
|
if (conditionalAbort) {
|
|
conditionalAbort.abort();
|
|
conditionalAbort = null;
|
|
}
|
|
const el = document.getElementById("username");
|
|
const username = el ? el.value.trim() : "";
|
|
return username ? passkeyLogin() : passkeyDiscoverable("optional");
|
|
}
|
|
|
|
// passkeysUnsupported reports whether this browser has no WebAuthn at all, hiding
|
|
// the passkey controls on the sign-in / sign-up forms when so. Both templates wrap
|
|
// their passkey button in #passkey-section and id their divider #password-divider, so
|
|
// one helper serves both: a button that cannot possibly work is worse than no button.
|
|
function passkeysUnsupported() {
|
|
if (window.PublicKeyCredential) return false;
|
|
const section = document.getElementById("passkey-section");
|
|
const divider = document.getElementById("password-divider");
|
|
if (section) section.classList.add("d-none");
|
|
if (divider) divider.classList.add("d-none");
|
|
return true;
|
|
}
|
|
|
|
// initSigninPasskey kicks off a passive conditional-mediation prompt on page
|
|
// load, so a passkey can be offered automatically when the browser supports it.
|
|
async function initSigninPasskey() {
|
|
if (passkeysUnsupported()) return;
|
|
if (!PublicKeyCredential.isConditionalMediationAvailable) return;
|
|
try {
|
|
if (await PublicKeyCredential.isConditionalMediationAvailable()) {
|
|
passkeyDiscoverable("conditional");
|
|
}
|
|
} catch (_) {
|
|
/* not supported — fall back to the explicit button */
|
|
}
|
|
}
|
|
|
|
// initSignupEmphasis adapts the sign-up form to what this device can actually do.
|
|
// The server renders both credential options, so no-JS visitors always see both;
|
|
// this only shifts the emphasis.
|
|
//
|
|
// Three states, because "can't use a platform authenticator" is NOT the same as
|
|
// "can't use a passkey" — registration asks only for a preferred resident key with no
|
|
// attachment constraint (see RegisterBegin), so a roaming security key works fine:
|
|
//
|
|
// 1. no WebAuthn at all → hide the passkey half; it would be a dead control
|
|
// 2. WebAuthn, no platform → leave both as rendered; a security key still works,
|
|
// authenticator and nothing should imply otherwise
|
|
// 3. platform authenticator → collapse the password half behind a toggle, so the
|
|
// available easy, safe path is the default and a password is a
|
|
// deliberate choice rather than an equal option
|
|
async function initSignupEmphasis() {
|
|
const passkeySection = document.getElementById("passkey-section");
|
|
const passwordSection = document.getElementById("password-section");
|
|
const divider = document.getElementById("password-divider");
|
|
const unavailable = document.getElementById("passkey-unavailable");
|
|
const useWrap = document.getElementById("use-password-wrap");
|
|
const useBtn = document.getElementById("use-password");
|
|
const password = document.getElementById("password");
|
|
const form = document.getElementById("signup-form");
|
|
const passkeyBtn = document.getElementById("signup-passkey-btn");
|
|
// Not the sign-up page (or a markup change) — do nothing rather than guess.
|
|
if (!passkeySection || !passwordSection || !password || !form) return;
|
|
|
|
// State 1: WebAuthn is unavailable, so the passkey button cannot work.
|
|
if (passkeysUnsupported()) {
|
|
if (unavailable) unavailable.classList.remove("d-none");
|
|
return;
|
|
}
|
|
|
|
let platform = false;
|
|
try {
|
|
if (PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) {
|
|
platform = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
|
}
|
|
} catch (_) {
|
|
platform = false; // treat an unclear answer as state 2 and change nothing
|
|
}
|
|
// State 2: leave the form exactly as rendered.
|
|
if (!platform || !useWrap || !useBtn) return;
|
|
|
|
// State 3: collapse the password half.
|
|
//
|
|
// Disabling the input matters as much as hiding it. A hidden-but-required control
|
|
// makes the browser refuse to submit — Chrome reports "an invalid form control is
|
|
// not focusable" and the submission silently dies — which anyone pressing Enter in
|
|
// the username field would hit. Disabled controls are skipped by validation and
|
|
// left out of the POST entirely.
|
|
passwordSection.classList.add("d-none");
|
|
if (divider) divider.classList.add("d-none");
|
|
password.disabled = true;
|
|
useWrap.classList.remove("d-none");
|
|
|
|
useBtn.addEventListener("click", function () {
|
|
passwordSection.classList.remove("d-none");
|
|
if (divider) divider.classList.remove("d-none");
|
|
password.disabled = false;
|
|
useWrap.classList.add("d-none");
|
|
password.focus();
|
|
});
|
|
|
|
// With the password half collapsed, Enter in the username field should start the
|
|
// passkey ceremony — the visible primary action — instead of posting an empty
|
|
// password form.
|
|
form.addEventListener("submit", function (e) {
|
|
if (password.disabled && passkeyBtn) {
|
|
e.preventDefault();
|
|
passkeyBtn.click();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Bind the passkey buttons by id (only the one on the current page exists). This
|
|
// replaces inline onclick handlers so the page carries no inline JS (CSP).
|
|
(function () {
|
|
var bindings = [
|
|
["add-passkey-btn", addPasskey],
|
|
["passkey-btn", passkeyButton],
|
|
["signup-passkey-btn", passkeyRegister],
|
|
["delete-reauth-btn", reauthPasskey],
|
|
];
|
|
bindings.forEach(function (b) {
|
|
var el = document.getElementById(b[0]);
|
|
if (el) el.addEventListener("click", b[1]);
|
|
});
|
|
if (document.getElementById("signup-form")) initSignupEmphasis();
|
|
})();
|
|
|
|
// Show/hide password toggles (any element with data-pwtoggle="<input id>").
|
|
document.addEventListener("click", function (e) {
|
|
const t = e.target.closest("[data-pwtoggle]");
|
|
if (!t) return;
|
|
e.preventDefault();
|
|
const input = document.getElementById(t.getAttribute("data-pwtoggle"));
|
|
if (!input) return;
|
|
const showing = input.type !== "password";
|
|
input.type = showing ? "password" : "text";
|
|
// Keep the control's state in sync for screen readers: pressed = revealed.
|
|
const nowShowing = !showing;
|
|
t.setAttribute("aria-pressed", String(nowShowing));
|
|
t.setAttribute("aria-label", nowShowing ? "Hide password" : "Show password");
|
|
t.setAttribute("title", nowShowing ? "Hide password" : "Show password");
|
|
});
|