Skip to main content

max / makenotwork

8.4 KB · 213 lines History Blame Raw
1 // Passkey / WebAuthn browser API helpers for login and registration.
2 // Loaded on the login page and dashboard settings tab.
3
4 (function () {
5 'use strict';
6
7 // -- Encoding helpers (WebAuthn API uses ArrayBuffer, server uses base64url) --
8
9 function base64urlToBuffer(base64url) {
10 var base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
11 var pad = base64.length % 4;
12 if (pad) base64 += '='.repeat(4 - pad);
13 var binary = atob(base64);
14 var bytes = new Uint8Array(binary.length);
15 for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
16 return bytes.buffer;
17 }
18
19 function bufferToBase64url(buffer) {
20 var bytes = new Uint8Array(buffer);
21 var binary = '';
22 for (var i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
23 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
24 }
25
26 // -- Login flow (unauthenticated, on /login page) --
27
28 window.loginWithPasskey = async function () {
29 var errorEl = document.getElementById('passkey-login-error');
30 if (errorEl) errorEl.innerHTML = '';
31
32 try {
33 var resp = await fetch('/auth/passkey/start', { method: 'POST' });
34 if (!resp.ok) throw new Error('Server error');
35 var options = await resp.json();
36
37 // Decode challenge
38 options.publicKey.challenge = base64urlToBuffer(options.publicKey.challenge);
39 if (options.publicKey.allowCredentials) {
40 options.publicKey.allowCredentials.forEach(function (c) {
41 c.id = base64urlToBuffer(c.id);
42 });
43 }
44
45 var assertion = await navigator.credentials.get(options);
46
47 // Encode response for server
48 var body = JSON.stringify({
49 id: assertion.id,
50 rawId: bufferToBase64url(assertion.rawId),
51 type: assertion.type,
52 response: {
53 authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
54 clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
55 signature: bufferToBase64url(assertion.response.signature),
56 userHandle: assertion.response.userHandle
57 ? bufferToBase64url(assertion.response.userHandle)
58 : null
59 }
60 });
61
62 var finishResp = await fetch('/auth/passkey/finish', {
63 method: 'POST',
64 headers: { 'Content-Type': 'application/json' },
65 body: body
66 });
67
68 if (!finishResp.ok) {
69 var err = await finishResp.text();
70 throw new Error(err || 'Authentication failed');
71 }
72
73 var result = await finishResp.json();
74 if (result.redirect) window.location.href = result.redirect;
75 } catch (e) {
76 if (e.name === 'NotAllowedError') return; // User cancelled
77 if (errorEl) {
78 errorEl.innerHTML = '<span class="save-error">Passkey login failed. Try again or use your password.</span>';
79 }
80 }
81 };
82
83 // -- Registration flow (authenticated, on dashboard settings) --
84
85 window.registerPasskey = async function () {
86 var statusEl = document.getElementById('passkey-status');
87 if (statusEl) statusEl.innerHTML = '';
88
89 var password = prompt('Enter your password to add a passkey:');
90 if (!password) return;
91
92 var csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
93
94 try {
95 var resp = await fetch('/api/users/me/passkeys/register/start', {
96 method: 'POST',
97 headers: {
98 'Content-Type': 'application/x-www-form-urlencoded',
99 ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
100 },
101 body: new URLSearchParams({ password: password }).toString()
102 });
103 if (!resp.ok) throw new Error('Server error');
104 var options = await resp.json();
105
106 // Decode challenge and user ID
107 options.publicKey.challenge = base64urlToBuffer(options.publicKey.challenge);
108 options.publicKey.user.id = base64urlToBuffer(options.publicKey.user.id);
109 if (options.publicKey.excludeCredentials) {
110 options.publicKey.excludeCredentials.forEach(function (c) {
111 c.id = base64urlToBuffer(c.id);
112 });
113 }
114
115 var credential = await navigator.credentials.create(options);
116
117 // Encode attestation for server
118 var body = JSON.stringify({
119 id: credential.id,
120 rawId: bufferToBase64url(credential.rawId),
121 type: credential.type,
122 response: {
123 attestationObject: bufferToBase64url(credential.response.attestationObject),
124 clientDataJSON: bufferToBase64url(credential.response.clientDataJSON)
125 }
126 });
127
128 var finishResp = await fetch('/api/users/me/passkeys/register/finish', {
129 method: 'POST',
130 headers: {
131 'Content-Type': 'application/json',
132 ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
133 },
134 body: body
135 });
136
137 if (!finishResp.ok) throw new Error('Registration failed');
138
139 // Replace the passkey section with the updated list
140 var html = await finishResp.text();
141 document.getElementById('passkey-section').innerHTML = html;
142 } catch (e) {
143 if (e.name === 'NotAllowedError') return; // User cancelled
144 if (statusEl) {
145 statusEl.innerHTML = '<span class="save-error">Registration failed. Please try again.</span>';
146 }
147 }
148 };
149
150 // -- Management (rename / delete via event delegation) --
151
152 // Event delegation on document — works regardless of HTMX swap timing
153 document.addEventListener('click', function (e) {
154 var btn = e.target.closest('.passkey-rename-btn');
155 if (btn) {
156 var id = btn.getAttribute('data-id');
157 var currentName = btn.getAttribute('data-name');
158 var name = prompt('Rename passkey:', currentName);
159 if (!name || name.trim() === '' || name === currentName) return;
160
161 var csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
162 var body = new URLSearchParams({ name: name.trim() });
163
164 fetch('/api/users/me/passkeys/' + encodeURIComponent(id), {
165 method: 'PUT',
166 headers: {
167 'Content-Type': 'application/x-www-form-urlencoded',
168 ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
169 },
170 body: body.toString()
171 }).then(function (resp) {
172 if (resp.ok) return resp.text();
173 throw new Error('Rename failed');
174 }).then(function (html) {
175 var section = document.getElementById('passkey-section');
176 if (section) section.innerHTML = html;
177 });
178 return;
179 }
180
181 var delBtn = e.target.closest('.passkey-delete-btn');
182 if (delBtn) {
183 var delId = delBtn.getAttribute('data-id');
184 var password = prompt('Enter your password to remove this passkey:');
185 if (!password) return;
186
187 var csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
188 var body = new URLSearchParams({ password: password });
189
190 fetch('/api/users/me/passkeys/' + encodeURIComponent(delId), {
191 method: 'DELETE',
192 headers: {
193 'Content-Type': 'application/x-www-form-urlencoded',
194 ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
195 },
196 body: body.toString()
197 }).then(function (resp) {
198 if (resp.ok) return resp.text();
199 throw new Error('Delete failed');
200 }).then(function (html) {
201 var section = document.getElementById('passkey-section');
202 if (section) section.innerHTML = html;
203 });
204 return;
205 }
206
207 var addBtn = e.target.closest('#add-passkey-btn');
208 if (addBtn) {
209 window.registerPasskey();
210 }
211 });
212 })();
213