Files
WRNexusJS/packages/auth/assets/client/auth.js
2026-07-29 12:51:10 +05:30

595 lines
23 KiB
JavaScript

/* global window, document, navigator, CustomEvent, HTMLElement, HTMLFormElement, MutationObserver, fetch, atob, btoa, Blob, URL, sessionStorage, localStorage, location, crypto */
(() => {
if (window.WRNexusAuth) return;
const mounted = new WeakSet();
const requestControllers = new WeakMap();
const cleanups = new WeakMap();
const recoveryMounted = new WeakSet();
const recoveryCleanups = new WeakMap();
const flowMounted = new WeakSet();
const flowCleanups = new WeakMap();
const MFA_STORAGE_KEY = "wrnexus.auth.mfa";
const DEVICE_STORAGE_KEY = "wrnexus.auth.device";
function fromBase64Url(value) {
const input = String(value);
if (!input || !/^[A-Za-z0-9_-]+$/.test(input) || input.length % 4 === 1) {
throw new TypeError("Passkey data must be canonical Base64URL without padding");
}
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(normalized + "=".repeat((4 - (normalized.length % 4)) % 4));
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
if (toBase64Url(bytes) !== input) {
throw new TypeError("Passkey data must be canonical Base64URL without padding");
}
return bytes;
}
function toBase64Url(value) {
const bytes =
value instanceof ArrayBuffer
? new Uint8Array(value)
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function publicKeyCreationOptions(options) {
return {
...options,
challenge: fromBase64Url(options.challenge),
user: { ...options.user, id: fromBase64Url(options.user.id) },
excludeCredentials: (options.excludeCredentials || []).map((item) => ({
...item,
id: fromBase64Url(item.id),
})),
};
}
function publicKeyRequestOptions(options) {
return {
...options,
challenge: fromBase64Url(options.challenge),
allowCredentials: (options.allowCredentials || []).map((item) => ({
...item,
id: fromBase64Url(item.id),
})),
};
}
function credentialToJson(credential) {
if (!credential) return null;
const response = credential.response;
const base = {
id: credential.id,
type: credential.type,
rawId: toBase64Url(credential.rawId),
authenticatorAttachment: credential.authenticatorAttachment,
clientExtensionResults: credential.getClientExtensionResults
? credential.getClientExtensionResults()
: {},
};
if (response && "attestationObject" in response) {
return {
...base,
response: {
clientDataJSON: toBase64Url(response.clientDataJSON),
attestationObject: toBase64Url(response.attestationObject),
transports: response.getTransports ? response.getTransports() : [],
},
};
}
return {
...base,
response: {
clientDataJSON: toBase64Url(response.clientDataJSON),
authenticatorData: toBase64Url(response.authenticatorData),
signature: toBase64Url(response.signature),
userHandle: response.userHandle ? toBase64Url(response.userHandle) : null,
},
};
}
function csrfHeaders() {
const match = document.cookie.match(/(?:^|;\s*)wire-csrf=([^;]+)/);
return match ? { "x-csrf-token": decodeURIComponent(match[1]) } : {};
}
async function json(url, body, signal) {
const response = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: {
"content-type": "application/json",
accept: "application/json",
...csrfHeaders(),
},
body: JSON.stringify(body),
signal,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.ok === false) {
const error = new Error(
payload.message || payload.error || `Request failed (${response.status})`,
);
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
function setState(element, state, message) {
element.dataset.authState = state;
element.setAttribute("aria-busy", state === "loading" ? "true" : "false");
const status = element.querySelector("[data-auth-status]");
if (status) {
status.textContent = message || "";
status.hidden = !message;
}
const button = element.matches("button") ? element : element.querySelector("button");
if (button) button.disabled = state === "loading";
}
function dispatch(element, name, detail) {
element.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
}
function navigate(target) {
if (!target) return false;
const url = new URL(target, location.href);
if (url.origin !== location.origin) return false;
if (window.__wrnexusNavigate) {
window.__wrnexusNavigate(url.pathname + url.search + url.hash);
} else {
location.assign(url.href);
}
return true;
}
function randomDeviceId() {
if (crypto.randomUUID) return crypto.randomUUID();
const bytes = crypto.getRandomValues(new Uint8Array(24));
return toBase64Url(bytes);
}
function deviceFingerprint() {
try {
let value = localStorage.getItem(DEVICE_STORAGE_KEY);
if (!value) {
value = randomDeviceId();
localStorage.setItem(DEVICE_STORAGE_KEY, value);
}
return value;
} catch {
return randomDeviceId();
}
}
function toggle(element, visible) {
if (!element) return;
element.hidden = !visible;
element.classList.toggle("hidden", !visible);
}
function setMessage(element, message, isError = false) {
if (!element) return;
element.textContent = message || "";
element.hidden = !message;
element.classList.toggle("hidden", !message);
element.dataset.status = isError ? "error" : "success";
}
function setFormBusy(form, busy) {
form?.querySelectorAll("[type=submit]").forEach((button) => {
button.disabled = busy;
});
}
async function registerPasskey(element) {
setState(element, "loading", element.dataset.loadingMessage || "Preparing passkey…");
try {
if (!window.PublicKeyCredential || !navigator.credentials || !window.AbortController) {
throw new Error("Passkeys are not supported in this browser");
}
const controller = new window.AbortController();
requestControllers.get(element)?.abort();
requestControllers.set(element, controller);
const optionsPayload = await json(
element.dataset.optionsEndpoint || "/api/auth/passkeys/register/options",
{},
controller.signal,
);
const credential = await navigator.credentials.create({
publicKey: publicKeyCreationOptions(optionsPayload.options),
signal: controller.signal,
});
const result = await json(
element.dataset.verifyEndpoint || "/api/auth/passkeys/register/verify",
{
key: optionsPayload.key,
response: credentialToJson(credential),
name: element.dataset.passkeyName || "Passkey",
},
controller.signal,
);
setState(element, "success", element.dataset.successMessage || "Passkey added.");
dispatch(element, "auth:passkey-registered", result);
} catch (error) {
if (error && error.name === "AbortError") return;
setState(
element,
"error",
error instanceof Error ? error.message : "Passkey registration failed",
);
dispatch(element, "auth:error", { error });
}
}
async function authenticatePasskey(element) {
setState(element, "loading", element.dataset.loadingMessage || "Waiting for your passkey…");
try {
if (!window.PublicKeyCredential || !navigator.credentials || !window.AbortController) {
throw new Error("Passkeys are not supported in this browser");
}
const controller = new window.AbortController();
requestControllers.get(element)?.abort();
requestControllers.set(element, controller);
const identifier =
element.dataset.identifier ||
element.closest("form")?.querySelector("[name=identifier]")?.value ||
element.closest("[data-auth-sign-in]")?.querySelector("form [name=identifier]")?.value ||
undefined;
const optionsPayload = await json(
element.dataset.optionsEndpoint || "/api/auth/passkeys/login/options",
{ identifier },
controller.signal,
);
const credential = await navigator.credentials.get({
publicKey: publicKeyRequestOptions(optionsPayload.options),
signal: controller.signal,
mediation: element.dataset.conditional === "true" ? "conditional" : "optional",
});
const result = await json(
element.dataset.verifyEndpoint || "/api/auth/passkeys/login/verify",
{
key: optionsPayload.key,
response: credentialToJson(credential),
},
controller.signal,
);
setState(element, "success", element.dataset.successMessage || "Signed in.");
dispatch(element, "auth:passkey-authenticated", result);
if (element.dataset.redirect) navigate(element.dataset.redirect);
} catch (error) {
if (error && error.name === "AbortError") return;
const detail = error?.payload || {};
if (detail.code === "mfa-required" && detail.mfaToken) {
sessionStorage.setItem(MFA_STORAGE_KEY, JSON.stringify(detail));
dispatch(element, "auth:mfa-required", detail);
navigate(element.dataset.mfaHref || "/two-factor");
return;
}
setState(element, "error", error instanceof Error ? error.message : "Passkey sign-in failed");
dispatch(element, "auth:error", { error, ...detail });
}
}
function recoveryCodes(element) {
return Array.from(element.querySelectorAll("[data-recovery-code]"))
.map((item) => String(item.textContent || "").trim())
.filter(Boolean);
}
function renderRecoveryCodes(element, codes) {
const list = element.querySelector("[data-recovery-code-list]");
if (!list || !Array.isArray(codes)) return;
list.textContent = "";
for (const code of codes) {
const item = document.createElement("code");
item.dataset.recoveryCode = "";
item.className = "rounded bg-[var(--wire-color-surface)] px-2 py-1.5 text-center";
item.textContent = String(code);
list.appendChild(item);
}
}
function downloadRecoveryCodes(element) {
const codes = recoveryCodes(element);
if (!codes.length) return;
const blob = new Blob([codes.join("\n") + "\n"], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = element.dataset.recoveryFilename || "wrnexus-recovery-codes.txt";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function mountRecoveryCodes(element) {
if (!(element instanceof HTMLElement) || recoveryMounted.has(element)) return;
recoveryMounted.add(element);
const download = element.querySelector("[data-recovery-download]");
const onDownload = () => downloadRecoveryCodes(element);
const onSuccess = (event) => {
if (event.target instanceof HTMLFormElement && event.detail?.codes) {
renderRecoveryCodes(element, event.detail.codes);
}
};
download?.addEventListener("click", onDownload);
element.addEventListener("wire:success", onSuccess);
recoveryCleanups.set(element, () => {
download?.removeEventListener("click", onDownload);
element.removeEventListener("wire:success", onSuccess);
});
}
function mountOtpSignIn(element) {
if (!(element instanceof HTMLElement) || flowMounted.has(element)) return;
flowMounted.add(element);
const requestForm = element.querySelector("[data-auth-otp-request]");
const completeForm = element.querySelector("[data-auth-otp-complete]");
const back = element.querySelector("[data-auth-otp-back]");
const onSuccess = (event) => {
if (event.target !== requestForm || !event.detail?.id) return;
const challenge = completeForm?.querySelector("[name=challengeId]");
if (challenge) challenge.value = String(event.detail.id);
toggle(requestForm, false);
toggle(completeForm, true);
completeForm?.querySelector("[name=code]")?.focus();
dispatch(element, "auth:otp-requested", event.detail);
};
const onError = (event) => {
if (event.target !== completeForm) return;
const detail = event.detail || {};
if (detail.code !== "mfa-required" || !detail.mfaToken) return;
sessionStorage.setItem(MFA_STORAGE_KEY, JSON.stringify(detail));
dispatch(element, "auth:mfa-required", detail);
navigate(element.dataset.mfaHref || "/two-factor");
};
const onBack = () => {
toggle(completeForm, false);
toggle(requestForm, true);
requestForm?.querySelector("[name=identifier]")?.focus();
};
element.addEventListener("wire:success", onSuccess);
element.addEventListener("wire:error", onError);
back?.addEventListener("click", onBack);
flowCleanups.set(element, () => {
element.removeEventListener("wire:success", onSuccess);
element.removeEventListener("wire:error", onError);
back?.removeEventListener("click", onBack);
});
}
function mountSignIn(element) {
if (!(element instanceof HTMLElement) || flowMounted.has(element)) return;
flowMounted.add(element);
const form = element.querySelector("form[data-schema]");
const fingerprintInput = form?.querySelector("[name=deviceFingerprint]");
const deviceNameInput = form?.querySelector("[name=deviceName]");
if (fingerprintInput && !fingerprintInput.value) fingerprintInput.value = deviceFingerprint();
if (deviceNameInput && !deviceNameInput.value) {
deviceNameInput.value = String(navigator.userAgent || navigator.platform || "Browser").slice(
0,
120,
);
}
const onError = (event) => {
if (!(event.target instanceof HTMLFormElement)) return;
const detail = event.detail || {};
if (detail.code !== "mfa-required" || !detail.mfaToken) return;
sessionStorage.setItem(MFA_STORAGE_KEY, JSON.stringify(detail));
dispatch(element, "auth:mfa-required", detail);
navigate(element.dataset.mfaHref || "/two-factor");
};
element.addEventListener("wire:error", onError);
flowCleanups.set(element, () => element.removeEventListener("wire:error", onError));
}
function readMfaState() {
try {
return JSON.parse(sessionStorage.getItem(MFA_STORAGE_KEY) || "null");
} catch {
return null;
}
}
function mountMfaChallenge(element) {
if (!(element instanceof HTMLElement) || flowMounted.has(element)) return;
flowMounted.add(element);
const form = element.querySelector("[data-auth-mfa-form]");
const tokenInput = form?.querySelector("[name=mfaToken]");
const challengeInput = form?.querySelector("[name=challengeId]");
const methodInput = form?.querySelector("[data-auth-mfa-method]");
const codeInput = form?.querySelector("[name=code]");
const status = form?.querySelector("[data-auth-mfa-status]");
const saved = readMfaState();
if (tokenInput && !tokenInput.value && saved?.mfaToken) {
tokenInput.value = String(saved.mfaToken);
}
const allowed = Array.isArray(saved?.requires?.mfa) ? saved.requires.mfa : [];
const initialMethod = element.dataset.mfaInitialMethod;
if (methodInput && allowed.length) {
Array.from(methodInput.options).forEach((option) => {
option.disabled = !allowed.includes(option.value);
option.hidden = option.disabled;
});
const selected = allowed.includes(initialMethod)
? initialMethod
: allowed.includes(methodInput.value)
? methodInput.value
: allowed[0];
if (selected) methodInput.value = selected;
} else if (methodInput && initialMethod) {
methodInput.value = initialMethod;
}
const onMethodChange = () => {
if (challengeInput) challengeInput.value = "";
setMessage(status, "");
if (codeInput) {
codeInput.value = "";
codeInput.setAttribute(
"inputmode",
methodInput?.value === "recovery-code" ? "text" : "numeric",
);
}
};
const onSubmit = async (event) => {
const method = methodInput?.value;
if (!form || !["email-otp", "sms-otp"].includes(method) || challengeInput?.value) return;
event.preventDefault();
event.stopImmediatePropagation();
if (!tokenInput?.value) {
setMessage(status, "Sign-in verification has expired. Start again.", true);
return;
}
setFormBusy(form, true);
setMessage(status, "Sending a one-time code…");
try {
const result = await json(element.dataset.mfaOtpAction || "/api/auth/mfa/otp", {
mfaToken: tokenInput.value,
method,
});
if (challengeInput) challengeInput.value = String(result.id || "");
setMessage(status, "A one-time code has been sent.");
codeInput?.focus();
} catch (error) {
setMessage(
status,
error instanceof Error ? error.message : "Unable to send a one-time code",
true,
);
} finally {
setFormBusy(form, false);
}
};
const onSuccess = (event) => {
if (event.target !== form) return;
sessionStorage.removeItem(MFA_STORAGE_KEY);
};
onMethodChange();
methodInput?.addEventListener("change", onMethodChange);
form?.addEventListener("submit", onSubmit, true);
element.addEventListener("wire:success", onSuccess);
flowCleanups.set(element, () => {
methodInput?.removeEventListener("change", onMethodChange);
form?.removeEventListener("submit", onSubmit, true);
element.removeEventListener("wire:success", onSuccess);
});
}
function mountAuthenticatorSetup(element) {
if (!(element instanceof HTMLElement) || flowMounted.has(element)) return;
flowMounted.add(element);
const startForm = element.querySelector("[data-auth-authenticator-start]");
const details = element.querySelector("[data-auth-authenticator-details]");
const confirmForm = element.querySelector("[data-auth-authenticator-confirm]");
const secret = element.querySelector("[data-auth-authenticator-secret]");
const uri = element.querySelector("[data-auth-authenticator-uri]");
const onSuccess = (event) => {
if (event.target === startForm && event.detail?.credentialId) {
const credential = confirmForm?.querySelector("[name=credentialId]");
if (credential) credential.value = String(event.detail.credentialId);
if (secret) secret.textContent = String(event.detail.secret || "");
if (uri) uri.textContent = String(event.detail.uri || "");
toggle(startForm, false);
toggle(details, true);
confirmForm?.querySelector("[name=code]")?.focus();
dispatch(element, "auth:authenticator-created", event.detail);
}
if (event.target === confirmForm) {
dispatch(element, "auth:authenticator-enabled", event.detail);
}
};
element.addEventListener("wire:success", onSuccess);
flowCleanups.set(element, () => element.removeEventListener("wire:success", onSuccess));
}
function mountElement(element) {
if (!(element instanceof HTMLElement) || mounted.has(element)) return;
mounted.add(element);
const action = element.dataset.authPasskey;
const button = element.matches("button") ? element : element.querySelector("button");
if (!button || !action) return;
const handler = () =>
action === "register" ? registerPasskey(element) : authenticatePasskey(element);
button.addEventListener("click", handler);
cleanups.set(element, () => button.removeEventListener("click", handler));
}
function mount(root = document) {
if (root instanceof HTMLElement && root.matches("[data-auth-passkey]")) mountElement(root);
root.querySelectorAll?.("[data-auth-passkey]").forEach(mountElement);
if (root instanceof HTMLElement && root.matches("[data-auth-recovery-codes]"))
mountRecoveryCodes(root);
root.querySelectorAll?.("[data-auth-recovery-codes]").forEach(mountRecoveryCodes);
if (root instanceof HTMLElement && root.matches("[data-auth-otp-sign-in]"))
mountOtpSignIn(root);
root.querySelectorAll?.("[data-auth-otp-sign-in]").forEach(mountOtpSignIn);
if (root instanceof HTMLElement && root.matches("[data-auth-sign-in]")) mountSignIn(root);
root.querySelectorAll?.("[data-auth-sign-in]").forEach(mountSignIn);
if (root instanceof HTMLElement && root.matches("[data-auth-mfa-challenge]"))
mountMfaChallenge(root);
root.querySelectorAll?.("[data-auth-mfa-challenge]").forEach(mountMfaChallenge);
if (root instanceof HTMLElement && root.matches("[data-auth-authenticator-setup]"))
mountAuthenticatorSetup(root);
root.querySelectorAll?.("[data-auth-authenticator-setup]").forEach(mountAuthenticatorSetup);
}
function unmount(root = document) {
const elements = [];
if (root instanceof HTMLElement && root.matches("[data-auth-passkey]")) elements.push(root);
root.querySelectorAll?.("[data-auth-passkey]").forEach((element) => elements.push(element));
for (const element of elements) {
requestControllers.get(element)?.abort();
requestControllers.delete(element);
cleanups.get(element)?.();
cleanups.delete(element);
mounted.delete(element);
}
const recoveryElements = [];
if (root instanceof HTMLElement && root.matches("[data-auth-recovery-codes]"))
recoveryElements.push(root);
root
.querySelectorAll?.("[data-auth-recovery-codes]")
.forEach((element) => recoveryElements.push(element));
for (const element of recoveryElements) {
recoveryCleanups.get(element)?.();
recoveryCleanups.delete(element);
recoveryMounted.delete(element);
}
const flowElements = [];
const selector =
"[data-auth-otp-sign-in],[data-auth-sign-in],[data-auth-mfa-challenge],[data-auth-authenticator-setup]";
if (root instanceof HTMLElement && root.matches(selector)) flowElements.push(root);
root.querySelectorAll?.(selector).forEach((element) => flowElements.push(element));
for (const element of flowElements) {
flowCleanups.get(element)?.();
flowCleanups.delete(element);
flowMounted.delete(element);
}
}
window.WRNexusAuth = { mount, unmount, registerPasskey, authenticatePasskey };
window.__wrnexusRuntimes = window.__wrnexusRuntimes || {};
window.__wrnexusRuntimes.auth = { mount, unmount };
if (document.readyState === "loading")
document.addEventListener("DOMContentLoaded", () => mount(document), { once: true });
else mount(document);
new MutationObserver((records) => {
for (const record of records)
for (const node of record.addedNodes) if (node instanceof HTMLElement) mount(node);
}).observe(document.documentElement, { childList: true, subtree: true });
})();