// Passkey / WebAuthn browser API helpers for login and registration.
// Loaded on the login page and dashboard settings tab.
(function () {
'use strict';
// -- Encoding helpers (WebAuthn API uses ArrayBuffer, server uses base64url) --
function base64urlToBuffer(base64url) {
var base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
var pad = base64.length % 4;
if (pad) base64 += '='.repeat(4 - pad);
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function bufferToBase64url(buffer) {
var bytes = new Uint8Array(buffer);
var binary = '';
for (var i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// -- Login flow (unauthenticated, on /login page) --
window.loginWithPasskey = async function () {
var errorEl = document.getElementById('passkey-login-error');
if (errorEl) errorEl.innerHTML = '';
try {
var resp = await fetch('/auth/passkey/start', { method: 'POST' });
if (!resp.ok) throw new Error('Server error');
var options = await resp.json();
// Decode challenge
options.publicKey.challenge = base64urlToBuffer(options.publicKey.challenge);
if (options.publicKey.allowCredentials) {
options.publicKey.allowCredentials.forEach(function (c) {
c.id = base64urlToBuffer(c.id);
});
}
var assertion = await navigator.credentials.get(options);
// Encode response for server
var body = JSON.stringify({
id: assertion.id,
rawId: bufferToBase64url(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
signature: bufferToBase64url(assertion.response.signature),
userHandle: assertion.response.userHandle
? bufferToBase64url(assertion.response.userHandle)
: null
}
});
var finishResp = await fetch('/auth/passkey/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body
});
if (!finishResp.ok) {
var err = await finishResp.text();
throw new Error(err || 'Authentication failed');
}
var result = await finishResp.json();
if (result.redirect) window.location.href = result.redirect;
} catch (e) {
if (e.name === 'NotAllowedError') return; // User cancelled
if (errorEl) {
errorEl.innerHTML = 'Passkey login failed. Try again or use your password.';
}
}
};
// -- Registration flow (authenticated, on dashboard settings) --
window.registerPasskey = async function () {
var statusEl = document.getElementById('passkey-status');
if (statusEl) statusEl.innerHTML = '';
var password = prompt('Enter your password to add a passkey:');
if (!password) return;
var csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
try {
var resp = await fetch('/api/users/me/passkeys/register/start', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: new URLSearchParams({ password: password }).toString()
});
if (!resp.ok) throw new Error('Server error');
var options = await resp.json();
// Decode challenge and user ID
options.publicKey.challenge = base64urlToBuffer(options.publicKey.challenge);
options.publicKey.user.id = base64urlToBuffer(options.publicKey.user.id);
if (options.publicKey.excludeCredentials) {
options.publicKey.excludeCredentials.forEach(function (c) {
c.id = base64urlToBuffer(c.id);
});
}
var credential = await navigator.credentials.create(options);
// Encode attestation for server
var body = JSON.stringify({
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
attestationObject: bufferToBase64url(credential.response.attestationObject),
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON)
}
});
var finishResp = await fetch('/api/users/me/passkeys/register/finish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: body
});
if (!finishResp.ok) throw new Error('Registration failed');
// Replace the passkey section with the updated list
var html = await finishResp.text();
document.getElementById('passkey-section').innerHTML = html;
} catch (e) {
if (e.name === 'NotAllowedError') return; // User cancelled
if (statusEl) {
statusEl.innerHTML = 'Registration failed. Please try again.';
}
}
};
// -- Management (rename / delete via event delegation) --
// Event delegation on document — works regardless of HTMX swap timing
document.addEventListener('click', function (e) {
var btn = e.target.closest('.passkey-rename-btn');
if (btn) {
var id = btn.getAttribute('data-id');
var currentName = btn.getAttribute('data-name');
var name = prompt('Rename passkey:', currentName);
if (!name || name.trim() === '' || name === currentName) return;
var csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
var body = new URLSearchParams({ name: name.trim() });
fetch('/api/users/me/passkeys/' + encodeURIComponent(id), {
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: body.toString()
}).then(function (resp) {
if (resp.ok) return resp.text();
throw new Error('Rename failed');
}).then(function (html) {
var section = document.getElementById('passkey-section');
if (section) section.innerHTML = html;
});
return;
}
var delBtn = e.target.closest('.passkey-delete-btn');
if (delBtn) {
var delId = delBtn.getAttribute('data-id');
var password = prompt('Enter your password to remove this passkey:');
if (!password) return;
var csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
var body = new URLSearchParams({ password: password });
fetch('/api/users/me/passkeys/' + encodeURIComponent(delId), {
method: 'DELETE',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: body.toString()
}).then(function (resp) {
if (resp.ok) return resp.text();
throw new Error('Delete failed');
}).then(function (html) {
var section = document.getElementById('passkey-section');
if (section) section.innerHTML = html;
});
return;
}
var addBtn = e.target.closest('#add-passkey-btn');
if (addBtn) {
window.registerPasskey();
}
});
})();