added passkey support (closes #6)
All checks were successful
test-and-lint / test-and-lint (pull_request) Successful in 2m50s

This commit is contained in:
2026-06-10 03:24:31 +02:00
parent 01ec41288a
commit fb3be56959
18 changed files with 1680 additions and 61 deletions

View File

@@ -7,16 +7,70 @@ document.addEventListener("DOMContentLoaded", () => {
const twoFactorInput = document.getElementById("two-factor-code");
const twoFactorGroup = document.getElementById("two-factor-group");
const submitButton = document.getElementById("login-submit");
const passkeyLoginButton = document.getElementById("passkey-login-button");
const passkeyLoginHint = document.getElementById("passkey-login-hint");
let pendingTwoFactorToken = null;
if (!form) return;
setupPasswordVisibilityToggles();
function showError(message) {
errorBox.textContent = message || "Login failed.";
errorBox.style.display = "block";
}
function clearError() {
errorBox.textContent = "";
errorBox.style.display = "none";
}
function setupPasswordVisibilityToggles(root = document) {
const eyeIcon = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M2.25 12s3.5-6.75 9.75-6.75S21.75 12 21.75 12 18.25 18.75 12 18.75 2.25 12 2.25 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="2.75" fill="none" stroke="currentColor" stroke-width="1.8"/>
</svg>`;
const eyeOffIcon = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 3l18 18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M10.58 10.58A2.75 2.75 0 0 0 13.42 13.42" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M7.1 7.7C3.95 9.55 2.25 12 2.25 12s3.5 6.75 9.75 6.75c1.65 0 3.08-.47 4.29-1.15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.8 5.55A9.2 9.2 0 0 1 12 5.25c6.25 0 9.75 6.75 9.75 6.75a15.3 15.3 0 0 1-2.3 2.95" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
root.querySelectorAll('input[type="password"]').forEach((input) => {
if (input.dataset.visibilityToggleAttached === 'true') return;
input.dataset.visibilityToggleAttached = 'true';
let wrapper = input.closest('.password-input-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.className = 'password-input-wrapper';
input.parentNode.insertBefore(wrapper, input);
wrapper.appendChild(input);
}
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'password-toggle';
toggle.innerHTML = eyeIcon;
toggle.setAttribute('aria-label', `Show ${input.placeholder || 'password'}`);
toggle.setAttribute('title', 'Show password');
toggle.addEventListener('click', () => {
const show = input.type === 'password';
input.type = show ? 'text' : 'password';
toggle.innerHTML = show ? eyeOffIcon : eyeIcon;
toggle.setAttribute('aria-label', `${show ? 'Hide' : 'Show'} ${input.placeholder || 'password'}`);
toggle.setAttribute('title', show ? 'Hide password' : 'Show password');
});
wrapper.appendChild(toggle);
});
}
function storeTokens(data) {
localStorage.setItem("access_token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
@@ -26,15 +80,131 @@ document.addEventListener("DOMContentLoaded", () => {
pendingTwoFactorToken = token;
usernameInput.disabled = true;
passwordInput.disabled = true;
if (passkeyLoginButton) passkeyLoginButton.disabled = true;
twoFactorGroup.style.display = "block";
twoFactorInput.required = true;
twoFactorInput.focus();
submitButton.textContent = "Verify code";
}
function webauthnSupported() {
return window.PublicKeyCredential && navigator.credentials;
}
function base64URLToBuffer(value) {
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function bufferToBase64URL(buffer) {
const bytes = new Uint8Array(buffer);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function prepareCredentialRequestOptions(options) {
const publicKey = options.publicKey || options;
publicKey.challenge = base64URLToBuffer(publicKey.challenge);
if (Array.isArray(publicKey.allowCredentials)) {
publicKey.allowCredentials = publicKey.allowCredentials.map((credential) => ({
...credential,
id: base64URLToBuffer(credential.id)
}));
}
return publicKey;
}
function credentialToJSON(credential) {
const response = credential.response;
return {
id: credential.id,
rawId: bufferToBase64URL(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
authenticatorData: bufferToBase64URL(response.authenticatorData),
signature: bufferToBase64URL(response.signature),
userHandle: response.userHandle ? bufferToBase64URL(response.userHandle) : null
},
clientExtensionResults: credential.getClientExtensionResults()
};
}
async function handlePasskeyLogin() {
if (!webauthnSupported()) {
showError("Passkeys are not supported by this browser.");
return;
}
clearError();
if (passkeyLoginButton) passkeyLoginButton.disabled = true;
submitButton.disabled = true;
try {
const optionsResponse = await fetch("/api/passkeys/login/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
if (!optionsResponse.ok) {
throw new Error(await optionsResponse.text());
}
const optionsData = await optionsResponse.json();
const publicKey = prepareCredentialRequestOptions(optionsData.options);
const assertion = await navigator.credentials.get({ publicKey });
if (!assertion) {
throw new Error("Passkey authentication was cancelled.");
}
const finishResponse = await fetch("/api/passkeys/login/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_token: optionsData.session_token,
credential: credentialToJSON(assertion)
})
});
if (!finishResponse.ok) {
throw new Error(await finishResponse.text());
}
const data = await finishResponse.json();
if (data.requires_2fa) {
switchToTwoFactorMode(data.two_factor_token);
return;
}
storeTokens(data);
window.location.href = "/dashboard";
} catch (err) {
showError(err.message);
} finally {
if (!pendingTwoFactorToken && passkeyLoginButton) passkeyLoginButton.disabled = false;
submitButton.disabled = false;
}
}
if (passkeyLoginButton) {
if (!webauthnSupported()) {
passkeyLoginButton.disabled = true;
if (passkeyLoginHint) passkeyLoginHint.textContent = "Passkeys are not supported by this browser.";
} else {
passkeyLoginButton.addEventListener("click", handlePasskeyLogin);
}
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorBox.style.display = "none";
clearError();
submitButton.disabled = true;
try {