Files
nodewarden/webapp/public/webauthn-fallback-connector.html
T

347 lines
11 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NodeWarden WebAuthn Connector</title>
<style>
:root {
color-scheme: light;
--primary: #2563eb;
--primary-strong: #1d4ed8;
--text: #101828;
--muted: #667085;
--line: #d8e0ec;
--panel: #ffffff;
--surface: #f6f8fb;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background: var(--surface);
color: var(--text);
}
main {
display: grid;
min-height: 100vh;
place-items: center;
padding: 28px 18px;
}
.connector-card {
width: min(100%, 430px);
border: 1px solid var(--line);
border-radius: 18px;
background: var(--panel);
box-shadow: 0 18px 44px rgba(16, 24, 40, 0.10);
padding: 28px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 28px;
}
.brand img {
width: 44px;
height: 44px;
object-fit: contain;
}
.brand strong {
font-size: 18px;
line-height: 1;
}
h1 {
margin: 0 0 8px;
font-size: 26px;
line-height: 1.2;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.form {
display: grid;
gap: 16px;
margin-top: 24px;
}
.remember {
display: flex;
align-items: center;
gap: 9px;
color: #344054;
font-size: 14px;
}
.remember input {
width: 16px;
height: 16px;
accent-color: var(--primary);
}
button {
min-height: 48px;
width: 100%;
border: 1px solid var(--primary);
border-radius: 10px;
background: var(--primary);
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 800;
transition: background-color 160ms ease, border-color 160ms ease, transform 120ms ease;
}
button:hover:not(:disabled) {
background: var(--primary-strong);
border-color: var(--primary-strong);
}
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.62;
}
.msg {
display: none;
border-radius: 10px;
padding: 11px 12px;
font-size: 14px;
line-height: 1.45;
}
.msg.show {
display: block;
}
.msg.error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #991b1b;
}
.msg.success {
border: 1px solid #bbf7d0;
background: #f0fdf4;
color: #166534;
}
</style>
</head>
<body>
<main>
<section class="connector-card" aria-labelledby="title">
<div class="brand">
<img src="/nodewarden-logo.svg" alt="NodeWarden" />
<strong>NodeWarden</strong>
</div>
<h1 id="title">Verify your identity</h1>
<p id="subtitle">Use your security key to finish two-step verification.</p>
<div class="form">
<div id="msg" class="msg" role="status" aria-live="polite"></div>
<label class="remember">
<input id="remember" type="checkbox" />
<span id="remember-label">Trust this device for 30 days</span>
</label>
<button id="webauthn-button" type="button">Read security key</button>
</div>
</section>
</main>
<script>
(function () {
var params = new URLSearchParams(window.location.search);
var sentSuccess = false;
var text = pickText(params.get("locale") || navigator.language || "en");
document.documentElement.lang = params.get("locale") || navigator.language || "en";
var titleEl = document.getElementById("title");
var subtitleEl = document.getElementById("subtitle");
var rememberEl = document.getElementById("remember");
var rememberLabelEl = document.getElementById("remember-label");
var buttonEl = document.getElementById("webauthn-button");
var msgEl = document.getElementById("msg");
titleEl.textContent = text.title;
subtitleEl.textContent = text.subtitle;
rememberLabelEl.textContent = text.remember;
buttonEl.textContent = decodeRepeated(params.get("btnText")) || text.button;
buttonEl.addEventListener("click", start);
function pickText(locale) {
var normalized = String(locale || "en").toLowerCase();
if (normalized.indexOf("zh") === 0) {
return {
title: "\u9a8c\u8bc1\u8eab\u4efd",
subtitle: "\u4f7f\u7528\u5b89\u5168\u5bc6\u94a5\u5b8c\u6210\u4e24\u6b65\u9a8c\u8bc1\u3002",
remember: "30 \u5929\u5185\u4fe1\u4efb\u6b64\u8bbe\u5907",
button: "\u8bfb\u53d6\u5b89\u5168\u5bc6\u94a5",
awaiting: "\u7b49\u5f85\u5b89\u5168\u5bc6\u94a5\u4ea4\u4e92...",
success: "\u9a8c\u8bc1\u5b8c\u6210",
unsupported: "\u5f53\u524d\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u5b89\u5168\u5bc6\u94a5",
};
}
return {
title: "Verify your identity",
subtitle: "Use your security key to finish two-step verification.",
remember: "Trust this device for 30 days",
button: "Read security key",
awaiting: "Awaiting security key interaction...",
success: "Verification complete",
unsupported: "This browser does not support security keys",
};
}
function decodeRepeated(value) {
if (!value) return "";
var out = String(value);
for (var i = 0; i < 2; i += 1) {
try {
var next = decodeURIComponent(out);
if (next === out) break;
out = next;
} catch (_error) {
break;
}
}
return out;
}
function showMessage(kind, message) {
msgEl.textContent = String(message || "");
msgEl.className = "msg show " + kind;
}
function decodeBase64Unicode(value) {
var input = String(value || "").replace(/ /g, "+");
try {
return decodeURIComponent(Array.prototype.map.call(atob(input), function (char) {
return "%" + ("00" + char.charCodeAt(0).toString(16)).slice(-2);
}).join(""));
} catch (_error) {
var normalized = input.replace(/-/g, "+").replace(/_/g, "/");
normalized += "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
return decodeURIComponent(Array.prototype.map.call(atob(normalized), function (char) {
return "%" + ("00" + char.charCodeAt(0).toString(16)).slice(-2);
}).join(""));
}
}
function bytesFromBase64Url(value) {
var normalized = String(value || "").replace(/-/g, "+").replace(/_/g, "/");
normalized += "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
var binary = atob(normalized);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function base64UrlFromBuffer(value) {
if (!value) return undefined;
var bytes = value instanceof Uint8Array
? value
: new Uint8Array(value);
var binary = "";
for (var i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function readPublicKeyOptions() {
var data = params.get("data");
if (!data) throw new Error("No data.");
var decoded = decodeBase64Unicode(data);
if (params.get("v") === "1") {
return JSON.parse(decoded);
}
var payload = JSON.parse(decoded);
return typeof payload.data === "string" ? JSON.parse(payload.data) : payload.data;
}
function normalizeOptions(options) {
if (!options || typeof options !== "object") throw new Error("Cannot parse data.");
var copy = Object.assign({}, options);
copy.challenge = bytesFromBase64Url(copy.challenge);
if (Array.isArray(copy.allowCredentials)) {
copy.allowCredentials = copy.allowCredentials.map(function (credential) {
return Object.assign({}, credential, {
id: bytesFromBase64Url(credential.id),
});
});
}
return copy;
}
function credentialToDataString(credential) {
var response = credential.response;
var clientDataJSON = base64UrlFromBuffer(response.clientDataJSON);
var data = {
id: credential.id,
rawId: base64UrlFromBuffer(credential.rawId),
type: credential.type,
extensions: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {},
clientExtensionResults: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {},
response: {
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
clientDataJson: clientDataJSON,
clientDataJSON: clientDataJSON,
signature: base64UrlFromBuffer(response.signature),
userHandle: response.userHandle ? base64UrlFromBuffer(response.userHandle) : undefined,
},
};
return JSON.stringify(data);
}
async function start() {
if (sentSuccess) return;
if (!("credentials" in navigator) || !window.PublicKeyCredential) {
showMessage("error", text.unsupported);
return;
}
try {
msgEl.className = "msg";
buttonEl.disabled = true;
buttonEl.textContent = decodeRepeated(params.get("btnAwaitingInteractionText")) || text.awaiting;
var publicKey = normalizeOptions(readPublicKeyOptions());
var credential = await navigator.credentials.get({ publicKey: publicKey });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error("No security key was selected.");
}
window.postMessage({
command: "webAuthnResult",
data: credentialToDataString(credential),
remember: rememberEl.checked,
}, "*");
sentSuccess = true;
showMessage("success", text.success);
} catch (error) {
buttonEl.disabled = false;
buttonEl.textContent = decodeRepeated(params.get("btnText")) || text.button;
showMessage("error", error && error.message ? error.message : String(error || "WebAuthn failed."));
}
}
})();
</script>
</body>
</html>