Skip to content
This repository was archived by the owner on Jul 28, 2026. It is now read-only.

Commit 369d750

Browse files
committed
feat: passkey login without email + conditional mediation (autofill)
- Require residentKey on registration so passkeys are discoverable - login/begin now works with or without email (empty body = discoverable flow) - login/complete extracts userId from response.userHandle for discoverable passkeys - Challenge map keyed by challenge string instead of userId - Frontend: add conditional mediation (passkey autofill suggestions) on login page - Frontend: passkey button no longer requires email field - Frontend: abort controller cleans up conditional mediation on navigation
1 parent 8744ca6 commit 369d750

2 files changed

Lines changed: 114 additions & 64 deletions

File tree

public/js/app.js

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,7 @@ function renderLoginPage() {
518518
<div class="auth-error"></div>
519519
<div class="form-group">
520520
<label for="login-email">Email</label>
521-
<input type="email" id="login-email" placeholder="your@email.com" required autocomplete="email" />
521+
<input type="email" id="login-email" placeholder="your@email.com" required autocomplete="email webauthn" />
522522
</div>
523523
<div class="form-group">
524524
<label for="login-password">Password</label>
@@ -548,6 +548,8 @@ function renderLoginPage() {
548548
e.preventDefault();
549549
navigateTo('signup');
550550
});
551+
552+
setupPasskeyAutofill();
551553
}
552554

553555
function renderRegisterPage() {
@@ -634,47 +636,73 @@ async function handleLogin(e) {
634636
}
635637
}
636638

639+
async function completePasskeyLogin(credential) {
640+
const data = await api('/auth/passkeys/login/complete', {
641+
method: 'POST',
642+
body: JSON.stringify({
643+
response: serializeCredential(credential),
644+
}),
645+
});
646+
state.token = data.token;
647+
state.user = data.user;
648+
localStorage.setItem('zh_token', data.token);
649+
localStorage.setItem('zh_user', JSON.stringify(data.user));
650+
history.replaceState({ page: 'overview' }, '', '/');
651+
renderDashboard();
652+
}
653+
654+
let passkeyAbortController = null;
655+
656+
async function setupPasskeyAutofill() {
657+
if (passkeyAbortController) {
658+
passkeyAbortController.abort();
659+
passkeyAbortController = null;
660+
}
661+
if (!navigator.credentials || typeof navigator.credentials.get !== 'function') return;
662+
663+
try {
664+
const beginData = await api('/auth/passkeys/login/begin', {
665+
method: 'POST',
666+
body: JSON.stringify({}),
667+
});
668+
669+
passkeyAbortController = new AbortController();
670+
const credential = await navigator.credentials.get({
671+
publicKey: prepareWebAuthnOptions(beginData.options),
672+
mediation: 'conditional',
673+
signal: passkeyAbortController.signal,
674+
});
675+
676+
if (credential) {
677+
await completePasskeyLogin(credential);
678+
}
679+
} catch (err) {
680+
if (err.name === 'AbortError' || err.name === 'NotAllowedError') return;
681+
console.error('Passkey autofill error:', err.message);
682+
}
683+
}
684+
637685
async function handlePasskeyLogin() {
638686
const btn = $('#passkey-login-btn');
639687
const errorEl = $('#login-form .auth-error');
640688
if (errorEl) errorEl.classList.remove('show');
641689

642-
const email = $('#login-email').value.trim();
643-
if (!email) {
644-
if (errorEl) {
645-
errorEl.textContent = 'Enter your email first, then click Sign in with Passkey.';
646-
errorEl.classList.add('show');
647-
}
648-
return;
649-
}
650-
651690
btn.disabled = true;
652691
btn.innerHTML = '<span class="spinner"></span>';
653692

654693
try {
694+
const email = $('#login-email').value.trim();
695+
const body = email ? { email } : {};
655696
const beginData = await api('/auth/passkeys/login/begin', {
656697
method: 'POST',
657-
body: JSON.stringify({ email }),
698+
body: JSON.stringify(body),
658699
});
659700

660701
const credential = await navigator.credentials.get({
661702
publicKey: prepareWebAuthnOptions(beginData.options),
662703
});
663704

664-
const completeData = await api('/auth/passkeys/login/complete', {
665-
method: 'POST',
666-
body: JSON.stringify({
667-
userId: beginData.userId,
668-
response: serializeCredential(credential),
669-
}),
670-
});
671-
672-
state.token = completeData.token;
673-
state.user = completeData.user;
674-
localStorage.setItem('zh_token', completeData.token);
675-
localStorage.setItem('zh_user', JSON.stringify(completeData.user));
676-
history.replaceState({ page: 'overview' }, '', '/');
677-
renderDashboard();
705+
await completePasskeyLogin(credential);
678706
} catch (err) {
679707
if (errorEl) {
680708
errorEl.textContent = err.message;
@@ -1017,6 +1045,10 @@ function initSidebarTooltip() {
10171045

10181046
function navigateTo(page) {
10191047
if (state.notifPanelOpen) closeNotifPanel();
1048+
if (passkeyAbortController) {
1049+
passkeyAbortController.abort();
1050+
passkeyAbortController = null;
1051+
}
10201052
const parts = page.split('/');
10211053
let basePage = parts[0] || 'overview';
10221054
const param = parts[1];

routes/passkeys.js

Lines changed: 57 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,16 @@ router.post('/passkeys/register/begin', authenticateToken, async (req, res) => {
6666
attestationType: 'none',
6767
excludeCredentials,
6868
userId: new TextEncoder().encode(String(userId).padStart(16, '0')),
69+
authenticatorSelection: {
70+
residentKey: 'required',
71+
userVerification: 'preferred',
72+
},
6973
});
7074

71-
challengeMap.set(userId, {
75+
challengeMap.set(options.challenge, {
7276
challenge: options.challenge,
7377
timestamp: Date.now(),
78+
userId,
7479
});
7580

7681
res.json({ options });
@@ -89,12 +94,13 @@ router.post('/passkeys/register/complete', authenticateToken, async (req, res) =
8994
return res.status(400).json({ error: 'Registration response is required' });
9095
}
9196

92-
const expectedChallenge = challengeMap.get(userId);
97+
const challengeFromResponse = JSON.parse(isoBase64URL.toUTF8String(response.response.clientDataJSON)).challenge;
98+
const expectedChallenge = challengeMap.get(challengeFromResponse);
9399
if (!expectedChallenge) {
94100
return res.status(400).json({ error: 'No registration in progress. Please try again.' });
95101
}
96102

97-
challengeMap.delete(userId);
103+
challengeMap.delete(challengeFromResponse);
98104

99105
const { rpID, origin } = getWebAuthnConfig(req);
100106
const verification = await verifyRegistrationResponse({
@@ -134,50 +140,51 @@ router.post('/passkeys/register/complete', authenticateToken, async (req, res) =
134140
router.post('/passkeys/login/begin', async (req, res) => {
135141
try {
136142
const { email } = req.body;
137-
if (!email || typeof email !== 'string') {
138-
return res.status(400).json({ error: 'Email is required' });
139-
}
140-
141-
const users = await query('SELECT id, email, username, auth_restricted FROM users WHERE email = ?', [email]);
142-
if (!users.length) {
143-
return res.status(404).json({ error: 'No account found with this email' });
144-
}
145-
146-
const user = users[0];
147-
if (user.auth_restricted) {
148-
return res.status(403).json({ error: 'Your account has been restricted. Contact support for assistance.' });
149-
}
150-
151-
const passkeys = await query(
152-
'SELECT id, credential_id, transports FROM passkeys WHERE user_id = ?',
153-
[user.id]
154-
);
155-
156-
if (!passkeys.length) {
157-
return res.status(404).json({ error: 'No passkeys registered for this account' });
143+
let userId = null;
144+
let allowCredentials = [];
145+
146+
if (email && typeof email === 'string') {
147+
const users = await query('SELECT id, email, username, auth_restricted FROM users WHERE email = ?', [email]);
148+
if (!users.length) {
149+
return res.status(404).json({ error: 'No account found with this email' });
150+
}
151+
152+
const user = users[0];
153+
if (user.auth_restricted) {
154+
return res.status(403).json({ error: 'Your account has been restricted. Contact support for assistance.' });
155+
}
156+
157+
userId = user.id;
158+
const passkeys = await query(
159+
'SELECT id, credential_id, transports FROM passkeys WHERE user_id = ?',
160+
[user.id]
161+
);
162+
163+
if (!passkeys.length) {
164+
return res.status(404).json({ error: 'No passkeys registered for this account' });
165+
}
166+
167+
allowCredentials = passkeys.map(k => ({
168+
id: k.credential_id,
169+
type: 'public-key',
170+
transports: k.transports ? k.transports.split(',') : ['internal'],
171+
}));
158172
}
159173

160-
const allowCredentials = passkeys.map(k => ({
161-
id: k.credential_id,
162-
type: 'public-key',
163-
transports: k.transports ? k.transports.split(',') : ['internal'],
164-
}));
165-
166174
const { rpID } = getWebAuthnConfig(req);
167175
const options = await generateAuthenticationOptions({
168176
rpID,
169177
allowCredentials,
170178
userVerification: 'preferred',
171179
});
172180

173-
challengeMap.set(`login:${user.id}`, {
181+
challengeMap.set(options.challenge, {
174182
challenge: options.challenge,
175183
timestamp: Date.now(),
176-
email,
177-
userId: user.id,
184+
userId,
178185
});
179186

180-
res.json({ options, userId: user.id });
187+
res.json({ options, userId });
181188
} catch (err) {
182189
console.error('Passkey login begin error:', err.message);
183190
res.status(500).json({ error: 'Failed to initiate passkey login' });
@@ -186,17 +193,28 @@ router.post('/passkeys/login/begin', async (req, res) => {
186193

187194
router.post('/passkeys/login/complete', async (req, res) => {
188195
try {
189-
const { response, userId } = req.body;
190-
if (!response || !userId) {
191-
return res.status(400).json({ error: 'Response and userId are required' });
196+
const { response, userId: bodyUserId } = req.body;
197+
if (!response) {
198+
return res.status(400).json({ error: 'Response is required' });
192199
}
193200

194-
const expectedChallenge = challengeMap.get(`login:${userId}`);
201+
const challengeFromResponse = JSON.parse(isoBase64URL.toUTF8String(response.response.clientDataJSON)).challenge;
202+
const expectedChallenge = challengeMap.get(challengeFromResponse);
195203
if (!expectedChallenge) {
196204
return res.status(400).json({ error: 'No login in progress. Please try again.' });
197205
}
198206

199-
challengeMap.delete(`login:${userId}`);
207+
challengeMap.delete(challengeFromResponse);
208+
209+
let userId = bodyUserId || expectedChallenge.userId;
210+
if (!userId && response.response.userHandle) {
211+
const userHandleBytes = isoBase64URL.toBuffer(response.response.userHandle);
212+
userId = parseInt(new TextDecoder().decode(userHandleBytes), 10);
213+
}
214+
215+
if (!userId) {
216+
return res.status(400).json({ error: 'Could not identify user. Try logging in with email.' });
217+
}
200218

201219
const passkeys = await query(
202220
'SELECT id, credential_id, public_key, counter, transports FROM passkeys WHERE user_id = ?',

0 commit comments

Comments
 (0)