detached db04eb5

refactor(web): one submit entry point for the passkey script

- the sign-in button is the submit button of a form, like the add button: one
  submit listener, no click path, and the disabled button is the only guard
  against a second prompt
- drop the busy flag and the reset and run exports
- focus the name field when register/begin refuses the name, the button otherwise
- the finish body no longer carries the name
- tests cannot hang: every test has a timeout and open prompts are released
  after each one
commit
db04eb51dbf2aadb136d710a2ff17feceb4b9154
author
brice · · 2026-10-12 16:19 UTC
parent
f42a3e9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +166 -155
M internal/adapters/web/jstest/passkey.test.mjs +119 -113
@@ -2,9 +2,13 @@
22 // navigator.credentials stubbed. Run with
33 // node --test 'internal/adapters/web/jstest/*.test.mjs'
44 import assert from 'node:assert/strict';
5-import { test, beforeEach } from 'node:test';
5+import { test as nodeTest, beforeEach, afterEach } from 'node:test';
66 import { dom, el, installDom } from './dom.mjs';
77
8+// Every test has two seconds: a ceremony that never settles fails the test
9+// instead of hanging the run.
10+const test = (title, fn) => nodeTest(title, { timeout: 2000 }, fn);
11+
812 const doc = installDom({ parseHTML: () => [] });
913 // The script registers its listeners when it loads: record them.
1014 const listeners = [];
@@ -20,32 +24,34 @@ const OPTIONS = { publicKey: { challenge: 'abc' } };
2024 const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) };
2125
2226 let fetches; // {url, init}
23-let answers; // url -> {ok, status, body} (body: object, or a function that throws, or undefined for no JSON)
27+let answers; // url -> {ok, status, body} (body undefined: not JSON)
2428 let parsed; // [kind, publicKey]
2529 let nav; // where the page was sent: 'assign:/' or 'reload'
2630 let status;
2731 let name;
2832
29-// page builds the DOM of the login page (a button) or the passkeys page (a form
30-// with the name field and a submit button).
33+// page builds the DOM of the login page (a form with the sign-in button) or of
34+// the passkeys page (a form with the name field and the add button). Both
35+// buttons are submit buttons of a [data-passkey-form].
3136 function page(kind) {
3237 dom.body = el('body');
3338 dom.focused = [];
3439 status = el('p', { attrs: { 'data-passkey-status': '' } });
3540 name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' });
36- const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish' } });
37- if (kind === 'register') {
38- const form = el('form', { attrs: { 'data-passkey-form': '' } }, name, btn);
39- dom.body.append(status, form);
40- btn.form = form;
41- } else {
42- dom.body.append(status, btn);
43- }
41+ const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish', type: 'submit' } });
42+ const form = el('form', { attrs: { 'data-passkey-form': '' } }, ...(kind === 'register' ? [name, btn] : [btn]));
43+ dom.body.append(status, form);
44+ btn.form = form;
4445 return btn;
4546 }
4647
48+// pending holds the resolvers of the authenticator prompts a test left open;
49+// afterEach answers them, so that no promise outlives its test.
50+let pending = [];
51+const prompt = (value) => new Promise((resolve) => { pending.push(() => resolve(value)); });
52+afterEach(() => { pending.forEach((release) => release()); pending = []; });
53+
4754 beforeEach(() => {
48- P.reset();
4955 fetches = [];
5056 parsed = [];
5157 nav = '';
@@ -78,32 +84,36 @@ beforeEach(() => {
7884 globalThis.location = { assign: (u) => { nav = 'assign:' + u; }, reload: () => { nav = 'reload'; } };
7985 });
8086
81-const clickOn = (target) => P.click({ target, preventDefault() {} });
82-// submitOn sends a submit event from the form of btn (a click on the submit
83-// button, or Enter in the field); it answers whether the page's own navigation
84-// was kept (no preventDefault).
87+// submitOn sends a submit event from a form (a click on its submit button, or
88+// Enter in a field). It answers the promise of the handler and whether the
89+// browser's own navigation was kept (no preventDefault).
8590 const submitOn = (target) => {
8691 let prevented = false;
8792 const p = P.submit({ target, preventDefault() { prevented = true; } });
8893 return { p, prevented: () => prevented };
8994 };
90-const formOf = (btn) => btn.form;
95+const submitForm = (btn) => submitOn(btn.form).p;
9196 const jsonBody = (i) => JSON.parse(fetches[i].init.body);
97+const beginCalls = () => fetches.filter((f) => f.url === '/begin').length;
98+const tick = () => new Promise((r) => setTimeout(r, 0));
9299
93-test('the script registers exactly one click and one submit listener', () => {
94- assert.deepEqual(listeners.map(([t]) => t).sort(), ['click', 'submit']);
95- assert.equal(listeners.find(([t]) => t === 'click')[1], P.click);
96- assert.equal(listeners.find(([t]) => t === 'submit')[1], P.submit);
100+test('the script registers exactly one submit listener and no click listener', () => {
101+ assert.deepEqual(listeners.map(([t]) => t), ['submit']);
102+ assert.equal(listeners[0][1], P.submit);
103+ assert.deepEqual(Object.keys(P).sort(), ['init', 'nameProblem', 'submit'], 'only what the tests need is exported');
97104 });
98105
99106 test('login: begin, get, finish with toJSON(), then home', async () => {
100107 const btn = page('login');
101- await clickOn(btn);
108+ const { p, prevented } = submitOn(btn.form);
109+ await p;
102110
111+ assert.equal(prevented(), true, 'the form never navigates by itself');
103112 assert.equal(fetches.length, 2);
104113 assert.equal(fetches[0].url, '/begin');
105114 assert.equal(fetches[0].init.method, 'POST');
106115 assert.equal(fetches[0].init.credentials, 'same-origin');
116+ assert.equal(fetches[0].init.body, undefined, 'a sign-in sends no name');
107117 assert.deepEqual(parsed, [['get', OPTIONS.publicKey]]);
108118 assert.deepEqual(navigator.got, { publicKey: { parsedGet: OPTIONS.publicKey } });
109119
@@ -111,17 +121,17 @@ test('login: begin, get, finish with toJSON(), then home', async () => {
111121 assert.equal(fetches[1].init.method, 'POST');
112122 assert.equal(fetches[1].init.credentials, 'same-origin');
113123 assert.equal(fetches[1].init.headers['Content-Type'], 'application/json');
114- assert.deepEqual(jsonBody(1), { ceremony: 'c1', name: '', credential: { id: 'cid', type: 'public-key' } });
124+ assert.deepEqual(jsonBody(1), { ceremony: 'c1', credential: { id: 'cid', type: 'public-key' } });
115125 assert.equal(nav, 'assign:/');
116126 assert.equal(btn.disabled, true, 'stays disabled while the page navigates');
117127 });
118128
119-test('register: the trimmed name goes to begin and to finish, then reload', async () => {
129+test('register: the trimmed name goes to begin only, then reload', async () => {
120130 const btn = page('register');
121- const { p, prevented } = submitOn(formOf(btn));
131+ const { p, prevented } = submitOn(btn.form);
122132 await p;
123133
124- assert.equal(prevented(), true, 'the form never navigates by itself');
134+ assert.equal(prevented(), true);
125135 assert.equal(fetches[0].url, '/begin');
126136 assert.equal(fetches[0].init.credentials, 'same-origin');
127137 assert.equal(fetches[0].init.headers['Content-Type'], 'application/json');
@@ -129,25 +139,11 @@ test('register: the trimmed name goes to begin and to finish, then reload', asyn
129139 assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]);
130140 assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } });
131141 assert.equal(fetches[1].init.credentials, 'same-origin');
132- assert.deepEqual(jsonBody(1), { ceremony: 'c1', name: 'Pixel 9', credential: { id: 'cid', type: 'public-key' } });
142+ assert.deepEqual(jsonBody(1), { ceremony: 'c1', credential: { id: 'cid', type: 'public-key' } }, 'the finish body has no name');
133143 assert.equal(nav, 'reload');
134144 });
135145
136-test('Enter in the name field submits: the event comes from the form or the field', async () => {
137- const btn = page('register');
138- await submitOn(name).p; // an event whose target is inside the form
139- assert.equal(fetches.length, 2);
140- assert.equal(nav, 'reload');
141- void btn;
142-});
143-
144-test('the add button is not a click target: only the submit runs it', async () => {
145- const btn = page('register');
146- await clickOn(btn);
147- assert.equal(fetches.length, 0, 'the click becomes a submit in a browser');
148-});
149-
150-test('a submit from another form is not ours', async () => {
146+test('a submit from another form is not ours and keeps its default', async () => {
151147 page('register');
152148 const other = el('form', {}, el('input'));
153149 dom.body.append(other);
@@ -157,67 +153,54 @@ test('a submit from another form is not ours', async () => {
157153 assert.equal(prevented(), false, 'other forms submit as they always did');
158154 });
159155
160-test('a click inside the login button reaches it, other clicks do nothing', async () => {
161- const btn = page('login');
162- const inner = el('span');
163- btn.append(inner);
164- await clickOn(inner);
165- assert.equal(fetches.length, 2);
166-
167- fetches = [];
168- await clickOn(el('div'));
169- assert.equal(fetches.length, 0);
170-});
171-
172-// While the ceremony waits for the authenticator the button is busy, the status
173-// says so, and a second click or Enter starts nothing.
174-test('busy: aria-busy, the waiting text, and a double click makes one begin', async () => {
156+// While the ceremony waits for the authenticator the button is busy and
157+// disabled, the status says so, and a second submit starts nothing: the
158+// disabled button is the guard.
159+test('busy: aria-busy, the waiting text, and a double submit makes one begin', async () => {
175160 const btn = page('login');
176- let release;
177- navigator.credentials.get = () => new Promise((resolve) => { release = () => resolve(CRED); });
161+ navigator.credentials.get = () => prompt(CRED);
178162
179- const first = clickOn(btn);
180- await new Promise((r) => setTimeout(r, 0));
163+ const first = submitForm(btn);
164+ await tick();
181165 assert.equal(btn.getAttribute('aria-busy'), 'true');
182166 assert.equal(btn.disabled, true);
183167 assert.equal(status.textContent, 'waiting for your passkey…');
184168
185- await clickOn(btn); // the double click
186- assert.equal(fetches.filter((f) => f.url === '/begin').length, 1, 'one begin only');
169+ await submitForm(btn); // the double click, or Enter
170+ assert.equal(beginCalls(), 1, 'one begin only');
187171
188- release();
172+ pending.forEach((release) => release());
189173 await first;
190- assert.equal(fetches.filter((f) => f.url === '/begin').length, 1);
174+ assert.equal(beginCalls(), 1);
191175 assert.equal(nav, 'assign:/');
192176 });
193177
194-test('a double submit makes one begin', async () => {
178+test('a double submit of the add form makes one begin', async () => {
195179 const btn = page('register');
196- let release;
197- navigator.credentials.create = () => new Promise((resolve) => { release = () => resolve(CRED); });
198- const a = submitOn(formOf(btn)).p;
199- await new Promise((r) => setTimeout(r, 0));
200- await submitOn(formOf(btn)).p;
201- assert.equal(fetches.filter((f) => f.url === '/begin').length, 1);
202- release();
203- await a;
180+ navigator.credentials.create = () => prompt(CRED);
181+ const first = submitForm(btn);
182+ await tick();
183+ await submitForm(btn);
184+ assert.equal(beginCalls(), 1);
185+ pending.forEach((release) => release());
186+ await first;
204187 assert.equal(nav, 'reload');
205188 });
206189
207190 test('after a failure the page is free for another try', async () => {
208191 const btn = page('register');
209192 answers['/finish'] = { ok: false, status: 400, body: { error: 'nope' } };
210- await submitOn(formOf(btn)).p;
193+ await submitForm(btn);
211194 assert.equal(status.textContent, 'nope');
212195 assert.equal(btn.disabled, false);
213- await submitOn(formOf(btn)).p;
214- assert.equal(fetches.filter((f) => f.url === '/begin').length, 2, 'the second try ran');
196+ await submitForm(btn);
197+ assert.equal(beginCalls(), 2, 'the second try ran');
215198 });
216199
217200 test('on failure the button is enabled again, not busy, and has the focus', async () => {
218201 const btn = page('login');
219202 answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } };
220- await clickOn(btn);
203+ await submitForm(btn);
221204 assert.equal(status.textContent, 'passkey not accepted');
222205 assert.equal(btn.disabled, false);
223206 assert.equal(btn.hasAttribute('aria-busy'), false);
@@ -227,7 +210,7 @@ test('on failure the button is enabled again, not busy, and has the focus', asyn
227210
228211 test('on success the button stays busy and disabled, without stealing focus', async () => {
229212 const btn = page('login');
230- await clickOn(btn);
213+ await submitForm(btn);
231214 assert.equal(btn.disabled, true);
232215 assert.equal(btn.getAttribute('aria-busy'), 'true');
233216 assert.deepEqual(dom.focused, []);
@@ -236,49 +219,82 @@ test('on success the button stays busy and disabled, without stealing focus', as
236219 test('begin refused: the server reason is shown, nothing else is called', async () => {
237220 const btn = page('login');
238221 answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } };
239- await clickOn(btn);
222+ await submitForm(btn);
240223 assert.equal(status.textContent, 'too many attempts');
241224 assert.equal(fetches.length, 1);
242225 assert.equal(nav, '');
243226 assert.equal(btn.disabled, false);
227+ assert.deepEqual(dom.focused, [btn]);
228+});
229+
230+// The server can refuse the name at begin: the field is what to fix, so it
231+// gets the focus. Any other refusal gives the button the focus.
232+test('a 400 from register/begin focuses the name field', async () => {
233+ const btn = page('register');
234+ answers['/begin'] = { ok: false, status: 400, body: { error: 'passkey name: text contains the control character U+0009' } };
235+ await submitForm(btn);
236+ assert.match(status.textContent, /control character/);
237+ assert.equal(navigator.created, undefined, 'create() is not called');
238+ assert.equal(btn.disabled, false);
239+ assert.deepEqual(dom.focused, [name]);
240+});
241+
242+test('other refusals of register give the focus back to the button', async () => {
243+ for (const [at, code] of [['/begin', 429], ['/begin', 403], ['/begin', 500], ['/finish', 400], ['/finish', 409]]) {
244+ const btn = page('register');
245+ answers = {
246+ '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } },
247+ '/finish': { ok: true, status: 204, body: undefined },
248+ [at]: { ok: false, status: code, body: { error: 'refused' } },
249+ };
250+ await submitForm(btn);
251+ assert.deepEqual(dom.focused, [btn], at + ' ' + code);
252+ }
253+});
254+
255+test('a 400 at begin on the login page focuses the button: there is no name', async () => {
256+ const btn = page('login');
257+ answers['/begin'] = { ok: false, status: 400, body: { error: 'malformed request' } };
258+ await submitForm(btn);
259+ assert.deepEqual(dom.focused, [btn]);
244260 });
245261
246262 test('a refusal without a JSON body falls back to the status', async () => {
247263 const btn = page('login');
248264 answers['/finish'] = { ok: false, status: 502, body: undefined };
249- await clickOn(btn);
265+ await submitForm(btn);
250266 assert.equal(status.textContent, 'the server answered 502');
251267 assert.equal(nav, '');
252268 });
253269
254270 test('a 403 without a reason says the session ended', async () => {
255271 for (const body of [undefined, { error: 'forbidden' }, {}]) {
272+ fetches = [];
256273 const btn = page('register');
257274 answers['/begin'] = { ok: false, status: 403, body };
258- await submitOn(formOf(btn)).p;
275+ await submitForm(btn);
259276 assert.equal(status.textContent, 'your session ended or this page is out of date; reload and sign in again');
260277 assert.equal(fetches.length, 1);
261- fetches = [];
262278 }
263279 // At finish too.
264280 const btn = page('login');
265281 answers['/begin'] = { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } };
266282 answers['/finish'] = { ok: false, status: 403, body: undefined };
267- await clickOn(btn);
283+ await submitForm(btn);
268284 assert.match(status.textContent, /your session ended/);
269285 });
270286
271287 test('a 403 with a reason of its own shows that reason', async () => {
272288 const btn = page('register');
273289 answers['/begin'] = { ok: false, status: 403, body: { error: 'the owner must sign in first' } };
274- await submitOn(formOf(btn)).p;
290+ await submitForm(btn);
275291 assert.equal(status.textContent, 'the owner must sign in first');
276292 });
277293
278294 test('the owner cancels the prompt', async () => {
279295 const btn = page('login');
280296 navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); };
281- await clickOn(btn);
297+ await submitForm(btn);
282298 assert.match(status.textContent, /cancelled, or it took too long/);
283299 assert.equal(fetches.length, 1, 'finish is not called');
284300 assert.equal(btn.disabled, false);
@@ -288,7 +304,7 @@ test('the owner cancels the prompt', async () => {
288304 test('AbortError says cancelled', async () => {
289305 const btn = page('login');
290306 navigator.credentials.get = async () => { throw Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }); };
291- await clickOn(btn);
307+ await submitForm(btn);
292308 assert.equal(status.textContent, 'cancelled');
293309 assert.equal(fetches.length, 1);
294310 assert.equal(btn.disabled, false);
@@ -297,7 +313,7 @@ test('AbortError says cancelled', async () => {
297313 test('an authenticator that already holds a passkey', async () => {
298314 const btn = page('register');
299315 navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); };
300- await submitOn(formOf(btn)).p;
316+ await submitForm(btn);
301317 assert.match(status.textContent, /already holds a passkey/);
302318 assert.equal(fetches.length, 1);
303319 });
@@ -305,7 +321,7 @@ test('an authenticator that already holds a passkey', async () => {
305321 test('a network failure is reported', async () => {
306322 const btn = page('login');
307323 globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); };
308- await clickOn(btn);
324+ await submitForm(btn);
309325 assert.equal(status.textContent, 'Failed to fetch');
310326 assert.equal(btn.disabled, false);
311327 });
@@ -313,7 +329,7 @@ test('a network failure is reported', async () => {
313329 test('a null credential is not sent', async () => {
314330 const btn = page('login');
315331 navigator.credentials.get = async () => null;
316- await clickOn(btn);
332+ await submitForm(btn);
317333 assert.equal(status.textContent, 'no passkey was returned');
318334 assert.equal(fetches.length, 1);
319335 });
@@ -325,7 +341,7 @@ test('a credential without toJSON is reported, not sent', async () => {
325341 const bare = { id: 'x' };
326342 navigator.credentials.get = async () => bare;
327343 navigator.credentials.create = async () => bare;
328- if (kind === 'login') await clickOn(btn); else await submitOn(formOf(btn)).p;
344+ await submitForm(btn);
329345 assert.match(status.textContent, /cannot send; update the browser/);
330346 assert.equal(fetches.length, 1, 'finish is not called');
331347 assert.equal(btn.disabled, false);
@@ -336,7 +352,7 @@ test('a credential without toJSON is reported, not sent', async () => {
336352 test('a begin answer that is not JSON', async () => {
337353 const btn = page('login');
338354 answers['/begin'] = { ok: true, status: 200, body: undefined }; // a 200 whose body is not JSON
339- await clickOn(btn);
355+ await submitForm(btn);
340356 assert.equal(status.textContent, 'the server sent an unexpected answer');
341357 assert.equal(parsed.length, 0);
342358 assert.equal(btn.disabled, false);
@@ -349,7 +365,7 @@ test('a begin answer with the wrong shape', async () => {
349365 parsed = [];
350366 const btn = page('login');
351367 answers['/begin'] = { ok: true, status: 200, body };
352- await clickOn(btn);
368+ await submitForm(btn);
353369 assert.equal(status.textContent, 'the server sent an unexpected answer', JSON.stringify(body));
354370 assert.equal(parsed.length, 0, 'nothing reached the browser API');
355371 assert.equal(fetches.length, 1);
@@ -360,7 +376,7 @@ test('a begin answer with the wrong shape', async () => {
360376 test('a name over 64 bytes (UTF-8) stops before begin', async () => {
361377 const btn = page('register');
362378 name.value = 'é'.repeat(33); // 33 characters, 66 bytes
363- await submitOn(formOf(btn)).p;
379+ await submitForm(btn);
364380 assert.match(status.textContent, /longer than 64 bytes/);
365381 assert.equal(fetches.length, 0, 'no request');
366382 assert.equal(navigator.created, undefined, 'create() is not called');
@@ -368,7 +384,7 @@ test('a name over 64 bytes (UTF-8) stops before begin', async () => {
368384 assert.deepEqual(dom.focused, [name], 'the field gets the focus');
369385 // The check is not a ceremony: the next try is not blocked.
370386 name.value = 'ok';
371- await submitOn(formOf(btn)).p;
387+ await submitForm(btn);
372388 assert.equal(fetches.length, 2);
373389 });
374390
@@ -377,9 +393,8 @@ test('64 bytes is fine, in ASCII or in two-byte characters', async () => {
377393 fetches = [];
378394 const btn = page('register'); // a success navigates away: each try is a new page
379395 name.value = ok;
380- await submitOn(formOf(btn)).p;
396+ await submitForm(btn);
381397 assert.equal(fetches.length, 2, ok.length + ' characters');
382- P.reset();
383398 }
384399 });
385400
@@ -388,7 +403,7 @@ test('control and direction characters stop before begin', async () => {
388403 fetches = [];
389404 const btn = page('register');
390405 name.value = bad;
391- await submitOn(formOf(btn)).p;
406+ await submitForm(btn);
392407 assert.match(status.textContent, /control or direction character/, JSON.stringify(bad));
393408 assert.equal(fetches.length, 0, JSON.stringify(bad));
394409 }
@@ -403,20 +418,11 @@ test('plain text with a zero width joiner, accents and emoji is fine', () => {
403418 test('an empty name is allowed: the server names the passkey', async () => {
404419 const btn = page('register');
405420 name.value = ' ';
406- await submitOn(formOf(btn)).p;
421+ await submitForm(btn);
407422 assert.deepEqual(JSON.parse(fetches[0].init.body), { name: '' });
408423 assert.equal(fetches.length, 2);
409424 });
410425
411-test('the server can still refuse the name at begin: its reason is shown', async () => {
412- const btn = page('register');
413- answers['/begin'] = { ok: false, status: 400, body: { error: 'passkey name: text contains the control character U+0009' } };
414- await submitOn(formOf(btn)).p;
415- assert.match(status.textContent, /control character/);
416- assert.equal(navigator.created, undefined);
417- assert.equal(btn.disabled, false);
418-});
419-
420426 test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => {
421427 const login = page('login');
422428 const register = el('button', { attrs: { 'data-passkey': 'register' } });
@@ -428,7 +434,7 @@ test('an old browser: the buttons are disabled with a reason, nothing is fetched
428434 assert.equal(status.textContent, 'this browser does not support passkeys');
429435
430436 login.disabled = false;
431- await clickOn(login);
437+ await submitForm(login);
432438 assert.equal(fetches.length, 0);
433439 assert.equal(status.textContent, 'this browser does not support passkeys');
434440 });
@@ -448,17 +454,17 @@ test('a supported browser keeps the buttons enabled', () => {
448454 assert.equal(status.textContent, '');
449455 });
450456
451-test('a disabled button ignores clicks', async () => {
457+test('a disabled button ignores a submit', async () => {
452458 const btn = page('login');
453459 btn.disabled = true;
454- await clickOn(btn);
460+ await submitForm(btn);
455461 assert.equal(fetches.length, 0);
456462 });
457463
458464 test('register needs parseCreationOptionsFromJSON', async () => {
459465 const btn = page('register');
460466 delete PublicKeyCredential.parseCreationOptionsFromJSON;
461- await submitOn(formOf(btn)).p;
467+ await submitForm(btn);
462468 assert.equal(fetches.length, 0);
463469 assert.equal(status.textContent, 'this browser does not support passkeys');
464470 });
@@ -2,9 +2,13 @@
2 // navigator.credentials stubbed. Run with2 // navigator.credentials stubbed. Run with
3 // node --test 'internal/adapters/web/jstest/*.test.mjs'3 // node --test 'internal/adapters/web/jstest/*.test.mjs'
4 import assert from 'node:assert/strict';4 import assert from 'node:assert/strict';
5-import { test, beforeEach } from 'node:test';5+import { test as nodeTest, beforeEach, afterEach } from 'node:test';
6 import { dom, el, installDom } from './dom.mjs';6 import { dom, el, installDom } from './dom.mjs';
7 7
8+// Every test has two seconds: a ceremony that never settles fails the test
9+// instead of hanging the run.
10+const test = (title, fn) => nodeTest(title, { timeout: 2000 }, fn);
11+
8 const doc = installDom({ parseHTML: () => [] });12 const doc = installDom({ parseHTML: () => [] });
9 // The script registers its listeners when it loads: record them.13 // The script registers its listeners when it loads: record them.
10 const listeners = [];14 const listeners = [];
@@ -20,32 +24,34 @@ const OPTIONS = { publicKey: { challenge: 'abc' } };
20 const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) };24 const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) };
21 25
22 let fetches; // {url, init}26 let fetches; // {url, init}
23-let answers; // url -> {ok, status, body} (body: object, or a function that throws, or undefined for no JSON)27+let answers; // url -> {ok, status, body} (body undefined: not JSON)
24 let parsed; // [kind, publicKey]28 let parsed; // [kind, publicKey]
25 let nav; // where the page was sent: 'assign:/' or 'reload'29 let nav; // where the page was sent: 'assign:/' or 'reload'
26 let status;30 let status;
27 let name;31 let name;
28 32
29-// page builds the DOM of the login page (a button) or the passkeys page (a form33+// page builds the DOM of the login page (a form with the sign-in button) or of
30-// with the name field and a submit button).34+// the passkeys page (a form with the name field and the add button). Both
35+// buttons are submit buttons of a [data-passkey-form].
31 function page(kind) {36 function page(kind) {
32 dom.body = el('body');37 dom.body = el('body');
33 dom.focused = [];38 dom.focused = [];
34 status = el('p', { attrs: { 'data-passkey-status': '' } });39 status = el('p', { attrs: { 'data-passkey-status': '' } });
35 name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' });40 name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' });
36- const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish' } });41+ const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish', type: 'submit' } });
37- if (kind === 'register') {42+ const form = el('form', { attrs: { 'data-passkey-form': '' } }, ...(kind === 'register' ? [name, btn] : [btn]));
38- const form = el('form', { attrs: { 'data-passkey-form': '' } }, name, btn);43+ dom.body.append(status, form);
39- dom.body.append(status, form);44+ btn.form = form;
40- btn.form = form;
41- } else {
42- dom.body.append(status, btn);
43- }
44 return btn;45 return btn;
45 }46 }
46 47
48+// pending holds the resolvers of the authenticator prompts a test left open;
49+// afterEach answers them, so that no promise outlives its test.
50+let pending = [];
51+const prompt = (value) => new Promise((resolve) => { pending.push(() => resolve(value)); });
52+afterEach(() => { pending.forEach((release) => release()); pending = []; });
53+
47 beforeEach(() => {54 beforeEach(() => {
48- P.reset();
49 fetches = [];55 fetches = [];
50 parsed = [];56 parsed = [];
51 nav = '';57 nav = '';
@@ -78,32 +84,36 @@ beforeEach(() => {
78 globalThis.location = { assign: (u) => { nav = 'assign:' + u; }, reload: () => { nav = 'reload'; } };84 globalThis.location = { assign: (u) => { nav = 'assign:' + u; }, reload: () => { nav = 'reload'; } };
79 });85 });
80 86
81-const clickOn = (target) => P.click({ target, preventDefault() {} });87+// submitOn sends a submit event from a form (a click on its submit button, or
82-// submitOn sends a submit event from the form of btn (a click on the submit88+// Enter in a field). It answers the promise of the handler and whether the
83-// button, or Enter in the field); it answers whether the page's own navigation89+// browser's own navigation was kept (no preventDefault).
84-// was kept (no preventDefault).
85 const submitOn = (target) => {90 const submitOn = (target) => {
86 let prevented = false;91 let prevented = false;
87 const p = P.submit({ target, preventDefault() { prevented = true; } });92 const p = P.submit({ target, preventDefault() { prevented = true; } });
88 return { p, prevented: () => prevented };93 return { p, prevented: () => prevented };
89 };94 };
90-const formOf = (btn) => btn.form;95+const submitForm = (btn) => submitOn(btn.form).p;
91 const jsonBody = (i) => JSON.parse(fetches[i].init.body);96 const jsonBody = (i) => JSON.parse(fetches[i].init.body);
97+const beginCalls = () => fetches.filter((f) => f.url === '/begin').length;
98+const tick = () => new Promise((r) => setTimeout(r, 0));
92 99
93-test('the script registers exactly one click and one submit listener', () => {100+test('the script registers exactly one submit listener and no click listener', () => {
94- assert.deepEqual(listeners.map(([t]) => t).sort(), ['click', 'submit']);101+ assert.deepEqual(listeners.map(([t]) => t), ['submit']);
95- assert.equal(listeners.find(([t]) => t === 'click')[1], P.click);102+ assert.equal(listeners[0][1], P.submit);
96- assert.equal(listeners.find(([t]) => t === 'submit')[1], P.submit);103+ assert.deepEqual(Object.keys(P).sort(), ['init', 'nameProblem', 'submit'], 'only what the tests need is exported');
97 });104 });
98 105
99 test('login: begin, get, finish with toJSON(), then home', async () => {106 test('login: begin, get, finish with toJSON(), then home', async () => {
100 const btn = page('login');107 const btn = page('login');
101- await clickOn(btn);108+ const { p, prevented } = submitOn(btn.form);
109+ await p;
102 110
111+ assert.equal(prevented(), true, 'the form never navigates by itself');
103 assert.equal(fetches.length, 2);112 assert.equal(fetches.length, 2);
104 assert.equal(fetches[0].url, '/begin');113 assert.equal(fetches[0].url, '/begin');
105 assert.equal(fetches[0].init.method, 'POST');114 assert.equal(fetches[0].init.method, 'POST');
106 assert.equal(fetches[0].init.credentials, 'same-origin');115 assert.equal(fetches[0].init.credentials, 'same-origin');
116+ assert.equal(fetches[0].init.body, undefined, 'a sign-in sends no name');
107 assert.deepEqual(parsed, [['get', OPTIONS.publicKey]]);117 assert.deepEqual(parsed, [['get', OPTIONS.publicKey]]);
108 assert.deepEqual(navigator.got, { publicKey: { parsedGet: OPTIONS.publicKey } });118 assert.deepEqual(navigator.got, { publicKey: { parsedGet: OPTIONS.publicKey } });
109 119
@@ -111,17 +121,17 @@ test('login: begin, get, finish with toJSON(), then home', async () => {
111 assert.equal(fetches[1].init.method, 'POST');121 assert.equal(fetches[1].init.method, 'POST');
112 assert.equal(fetches[1].init.credentials, 'same-origin');122 assert.equal(fetches[1].init.credentials, 'same-origin');
113 assert.equal(fetches[1].init.headers['Content-Type'], 'application/json');123 assert.equal(fetches[1].init.headers['Content-Type'], 'application/json');
114- assert.deepEqual(jsonBody(1), { ceremony: 'c1', name: '', credential: { id: 'cid', type: 'public-key' } });124+ assert.deepEqual(jsonBody(1), { ceremony: 'c1', credential: { id: 'cid', type: 'public-key' } });
115 assert.equal(nav, 'assign:/');125 assert.equal(nav, 'assign:/');
116 assert.equal(btn.disabled, true, 'stays disabled while the page navigates');126 assert.equal(btn.disabled, true, 'stays disabled while the page navigates');
117 });127 });
118 128
119-test('register: the trimmed name goes to begin and to finish, then reload', async () => {129+test('register: the trimmed name goes to begin only, then reload', async () => {
120 const btn = page('register');130 const btn = page('register');
121- const { p, prevented } = submitOn(formOf(btn));131+ const { p, prevented } = submitOn(btn.form);
122 await p;132 await p;
123 133
124- assert.equal(prevented(), true, 'the form never navigates by itself');134+ assert.equal(prevented(), true);
125 assert.equal(fetches[0].url, '/begin');135 assert.equal(fetches[0].url, '/begin');
126 assert.equal(fetches[0].init.credentials, 'same-origin');136 assert.equal(fetches[0].init.credentials, 'same-origin');
127 assert.equal(fetches[0].init.headers['Content-Type'], 'application/json');137 assert.equal(fetches[0].init.headers['Content-Type'], 'application/json');
@@ -129,25 +139,11 @@ test('register: the trimmed name goes to begin and to finish, then reload', asyn
129 assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]);139 assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]);
130 assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } });140 assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } });
131 assert.equal(fetches[1].init.credentials, 'same-origin');141 assert.equal(fetches[1].init.credentials, 'same-origin');
132- assert.deepEqual(jsonBody(1), { ceremony: 'c1', name: 'Pixel 9', credential: { id: 'cid', type: 'public-key' } });142+ assert.deepEqual(jsonBody(1), { ceremony: 'c1', credential: { id: 'cid', type: 'public-key' } }, 'the finish body has no name');
133 assert.equal(nav, 'reload');143 assert.equal(nav, 'reload');
134 });144 });
135 145
136-test('Enter in the name field submits: the event comes from the form or the field', async () => {146+test('a submit from another form is not ours and keeps its default', async () => {
137- const btn = page('register');
138- await submitOn(name).p; // an event whose target is inside the form
139- assert.equal(fetches.length, 2);
140- assert.equal(nav, 'reload');
141- void btn;
142-});
143-
144-test('the add button is not a click target: only the submit runs it', async () => {
145- const btn = page('register');
146- await clickOn(btn);
147- assert.equal(fetches.length, 0, 'the click becomes a submit in a browser');
148-});
149-
150-test('a submit from another form is not ours', async () => {
151 page('register');147 page('register');
152 const other = el('form', {}, el('input'));148 const other = el('form', {}, el('input'));
153 dom.body.append(other);149 dom.body.append(other);
@@ -157,67 +153,54 @@ test('a submit from another form is not ours', async () => {
157 assert.equal(prevented(), false, 'other forms submit as they always did');153 assert.equal(prevented(), false, 'other forms submit as they always did');
158 });154 });
159 155
160-test('a click inside the login button reaches it, other clicks do nothing', async () => {156+// While the ceremony waits for the authenticator the button is busy and
161- const btn = page('login');157+// disabled, the status says so, and a second submit starts nothing: the
162- const inner = el('span');158+// disabled button is the guard.
163- btn.append(inner);159+test('busy: aria-busy, the waiting text, and a double submit makes one begin', async () => {
164- await clickOn(inner);
165- assert.equal(fetches.length, 2);
166-
167- fetches = [];
168- await clickOn(el('div'));
169- assert.equal(fetches.length, 0);
170-});
171-
172-// While the ceremony waits for the authenticator the button is busy, the status
173-// says so, and a second click or Enter starts nothing.
174-test('busy: aria-busy, the waiting text, and a double click makes one begin', async () => {
175 const btn = page('login');160 const btn = page('login');
176- let release;161+ navigator.credentials.get = () => prompt(CRED);
177- navigator.credentials.get = () => new Promise((resolve) => { release = () => resolve(CRED); });
178 162
179- const first = clickOn(btn);163+ const first = submitForm(btn);
180- await new Promise((r) => setTimeout(r, 0));164+ await tick();
181 assert.equal(btn.getAttribute('aria-busy'), 'true');165 assert.equal(btn.getAttribute('aria-busy'), 'true');
182 assert.equal(btn.disabled, true);166 assert.equal(btn.disabled, true);
183 assert.equal(status.textContent, 'waiting for your passkey…');167 assert.equal(status.textContent, 'waiting for your passkey…');
184 168
185- await clickOn(btn); // the double click169+ await submitForm(btn); // the double click, or Enter
186- assert.equal(fetches.filter((f) => f.url === '/begin').length, 1, 'one begin only');170+ assert.equal(beginCalls(), 1, 'one begin only');
187 171
188- release();172+ pending.forEach((release) => release());
189 await first;173 await first;
190- assert.equal(fetches.filter((f) => f.url === '/begin').length, 1);174+ assert.equal(beginCalls(), 1);
191 assert.equal(nav, 'assign:/');175 assert.equal(nav, 'assign:/');
192 });176 });
193 177
194-test('a double submit makes one begin', async () => {178+test('a double submit of the add form makes one begin', async () => {
195 const btn = page('register');179 const btn = page('register');
196- let release;180+ navigator.credentials.create = () => prompt(CRED);
197- navigator.credentials.create = () => new Promise((resolve) => { release = () => resolve(CRED); });181+ const first = submitForm(btn);
198- const a = submitOn(formOf(btn)).p;182+ await tick();
199- await new Promise((r) => setTimeout(r, 0));183+ await submitForm(btn);
200- await submitOn(formOf(btn)).p;184+ assert.equal(beginCalls(), 1);
201- assert.equal(fetches.filter((f) => f.url === '/begin').length, 1);185+ pending.forEach((release) => release());
202- release();186+ await first;
203- await a;
204 assert.equal(nav, 'reload');187 assert.equal(nav, 'reload');
205 });188 });
206 189
207 test('after a failure the page is free for another try', async () => {190 test('after a failure the page is free for another try', async () => {
208 const btn = page('register');191 const btn = page('register');
209 answers['/finish'] = { ok: false, status: 400, body: { error: 'nope' } };192 answers['/finish'] = { ok: false, status: 400, body: { error: 'nope' } };
210- await submitOn(formOf(btn)).p;193+ await submitForm(btn);
211 assert.equal(status.textContent, 'nope');194 assert.equal(status.textContent, 'nope');
212 assert.equal(btn.disabled, false);195 assert.equal(btn.disabled, false);
213- await submitOn(formOf(btn)).p;196+ await submitForm(btn);
214- assert.equal(fetches.filter((f) => f.url === '/begin').length, 2, 'the second try ran');197+ assert.equal(beginCalls(), 2, 'the second try ran');
215 });198 });
216 199
217 test('on failure the button is enabled again, not busy, and has the focus', async () => {200 test('on failure the button is enabled again, not busy, and has the focus', async () => {
218 const btn = page('login');201 const btn = page('login');
219 answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } };202 answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } };
220- await clickOn(btn);203+ await submitForm(btn);
221 assert.equal(status.textContent, 'passkey not accepted');204 assert.equal(status.textContent, 'passkey not accepted');
222 assert.equal(btn.disabled, false);205 assert.equal(btn.disabled, false);
223 assert.equal(btn.hasAttribute('aria-busy'), false);206 assert.equal(btn.hasAttribute('aria-busy'), false);
@@ -227,7 +210,7 @@ test('on failure the button is enabled again, not busy, and has the focus', asyn
227 210
228 test('on success the button stays busy and disabled, without stealing focus', async () => {211 test('on success the button stays busy and disabled, without stealing focus', async () => {
229 const btn = page('login');212 const btn = page('login');
230- await clickOn(btn);213+ await submitForm(btn);
231 assert.equal(btn.disabled, true);214 assert.equal(btn.disabled, true);
232 assert.equal(btn.getAttribute('aria-busy'), 'true');215 assert.equal(btn.getAttribute('aria-busy'), 'true');
233 assert.deepEqual(dom.focused, []);216 assert.deepEqual(dom.focused, []);
@@ -236,49 +219,82 @@ test('on success the button stays busy and disabled, without stealing focus', as
236 test('begin refused: the server reason is shown, nothing else is called', async () => {219 test('begin refused: the server reason is shown, nothing else is called', async () => {
237 const btn = page('login');220 const btn = page('login');
238 answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } };221 answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } };
239- await clickOn(btn);222+ await submitForm(btn);
240 assert.equal(status.textContent, 'too many attempts');223 assert.equal(status.textContent, 'too many attempts');
241 assert.equal(fetches.length, 1);224 assert.equal(fetches.length, 1);
242 assert.equal(nav, '');225 assert.equal(nav, '');
243 assert.equal(btn.disabled, false);226 assert.equal(btn.disabled, false);
227+ assert.deepEqual(dom.focused, [btn]);
228+});
229+
230+// The server can refuse the name at begin: the field is what to fix, so it
231+// gets the focus. Any other refusal gives the button the focus.
232+test('a 400 from register/begin focuses the name field', async () => {
233+ const btn = page('register');
234+ answers['/begin'] = { ok: false, status: 400, body: { error: 'passkey name: text contains the control character U+0009' } };
235+ await submitForm(btn);
236+ assert.match(status.textContent, /control character/);
237+ assert.equal(navigator.created, undefined, 'create() is not called');
238+ assert.equal(btn.disabled, false);
239+ assert.deepEqual(dom.focused, [name]);
240+});
241+
242+test('other refusals of register give the focus back to the button', async () => {
243+ for (const [at, code] of [['/begin', 429], ['/begin', 403], ['/begin', 500], ['/finish', 400], ['/finish', 409]]) {
244+ const btn = page('register');
245+ answers = {
246+ '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } },
247+ '/finish': { ok: true, status: 204, body: undefined },
248+ [at]: { ok: false, status: code, body: { error: 'refused' } },
249+ };
250+ await submitForm(btn);
251+ assert.deepEqual(dom.focused, [btn], at + ' ' + code);
252+ }
253+});
254+
255+test('a 400 at begin on the login page focuses the button: there is no name', async () => {
256+ const btn = page('login');
257+ answers['/begin'] = { ok: false, status: 400, body: { error: 'malformed request' } };
258+ await submitForm(btn);
259+ assert.deepEqual(dom.focused, [btn]);
244 });260 });
245 261
246 test('a refusal without a JSON body falls back to the status', async () => {262 test('a refusal without a JSON body falls back to the status', async () => {
247 const btn = page('login');263 const btn = page('login');
248 answers['/finish'] = { ok: false, status: 502, body: undefined };264 answers['/finish'] = { ok: false, status: 502, body: undefined };
249- await clickOn(btn);265+ await submitForm(btn);
250 assert.equal(status.textContent, 'the server answered 502');266 assert.equal(status.textContent, 'the server answered 502');
251 assert.equal(nav, '');267 assert.equal(nav, '');
252 });268 });
253 269
254 test('a 403 without a reason says the session ended', async () => {270 test('a 403 without a reason says the session ended', async () => {
255 for (const body of [undefined, { error: 'forbidden' }, {}]) {271 for (const body of [undefined, { error: 'forbidden' }, {}]) {
272+ fetches = [];
256 const btn = page('register');273 const btn = page('register');
257 answers['/begin'] = { ok: false, status: 403, body };274 answers['/begin'] = { ok: false, status: 403, body };
258- await submitOn(formOf(btn)).p;275+ await submitForm(btn);
259 assert.equal(status.textContent, 'your session ended or this page is out of date; reload and sign in again');276 assert.equal(status.textContent, 'your session ended or this page is out of date; reload and sign in again');
260 assert.equal(fetches.length, 1);277 assert.equal(fetches.length, 1);
261- fetches = [];
262 }278 }
263 // At finish too.279 // At finish too.
264 const btn = page('login');280 const btn = page('login');
265 answers['/begin'] = { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } };281 answers['/begin'] = { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } };
266 answers['/finish'] = { ok: false, status: 403, body: undefined };282 answers['/finish'] = { ok: false, status: 403, body: undefined };
267- await clickOn(btn);283+ await submitForm(btn);
268 assert.match(status.textContent, /your session ended/);284 assert.match(status.textContent, /your session ended/);
269 });285 });
270 286
271 test('a 403 with a reason of its own shows that reason', async () => {287 test('a 403 with a reason of its own shows that reason', async () => {
272 const btn = page('register');288 const btn = page('register');
273 answers['/begin'] = { ok: false, status: 403, body: { error: 'the owner must sign in first' } };289 answers['/begin'] = { ok: false, status: 403, body: { error: 'the owner must sign in first' } };
274- await submitOn(formOf(btn)).p;290+ await submitForm(btn);
275 assert.equal(status.textContent, 'the owner must sign in first');291 assert.equal(status.textContent, 'the owner must sign in first');
276 });292 });
277 293
278 test('the owner cancels the prompt', async () => {294 test('the owner cancels the prompt', async () => {
279 const btn = page('login');295 const btn = page('login');
280 navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); };296 navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); };
281- await clickOn(btn);297+ await submitForm(btn);
282 assert.match(status.textContent, /cancelled, or it took too long/);298 assert.match(status.textContent, /cancelled, or it took too long/);
283 assert.equal(fetches.length, 1, 'finish is not called');299 assert.equal(fetches.length, 1, 'finish is not called');
284 assert.equal(btn.disabled, false);300 assert.equal(btn.disabled, false);
@@ -288,7 +304,7 @@ test('the owner cancels the prompt', async () => {
288 test('AbortError says cancelled', async () => {304 test('AbortError says cancelled', async () => {
289 const btn = page('login');305 const btn = page('login');
290 navigator.credentials.get = async () => { throw Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }); };306 navigator.credentials.get = async () => { throw Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }); };
291- await clickOn(btn);307+ await submitForm(btn);
292 assert.equal(status.textContent, 'cancelled');308 assert.equal(status.textContent, 'cancelled');
293 assert.equal(fetches.length, 1);309 assert.equal(fetches.length, 1);
294 assert.equal(btn.disabled, false);310 assert.equal(btn.disabled, false);
@@ -297,7 +313,7 @@ test('AbortError says cancelled', async () => {
297 test('an authenticator that already holds a passkey', async () => {313 test('an authenticator that already holds a passkey', async () => {
298 const btn = page('register');314 const btn = page('register');
299 navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); };315 navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); };
300- await submitOn(formOf(btn)).p;316+ await submitForm(btn);
301 assert.match(status.textContent, /already holds a passkey/);317 assert.match(status.textContent, /already holds a passkey/);
302 assert.equal(fetches.length, 1);318 assert.equal(fetches.length, 1);
303 });319 });
@@ -305,7 +321,7 @@ test('an authenticator that already holds a passkey', async () => {
305 test('a network failure is reported', async () => {321 test('a network failure is reported', async () => {
306 const btn = page('login');322 const btn = page('login');
307 globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); };323 globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); };
308- await clickOn(btn);324+ await submitForm(btn);
309 assert.equal(status.textContent, 'Failed to fetch');325 assert.equal(status.textContent, 'Failed to fetch');
310 assert.equal(btn.disabled, false);326 assert.equal(btn.disabled, false);
311 });327 });
@@ -313,7 +329,7 @@ test('a network failure is reported', async () => {
313 test('a null credential is not sent', async () => {329 test('a null credential is not sent', async () => {
314 const btn = page('login');330 const btn = page('login');
315 navigator.credentials.get = async () => null;331 navigator.credentials.get = async () => null;
316- await clickOn(btn);332+ await submitForm(btn);
317 assert.equal(status.textContent, 'no passkey was returned');333 assert.equal(status.textContent, 'no passkey was returned');
318 assert.equal(fetches.length, 1);334 assert.equal(fetches.length, 1);
319 });335 });
@@ -325,7 +341,7 @@ test('a credential without toJSON is reported, not sent', async () => {
325 const bare = { id: 'x' };341 const bare = { id: 'x' };
326 navigator.credentials.get = async () => bare;342 navigator.credentials.get = async () => bare;
327 navigator.credentials.create = async () => bare;343 navigator.credentials.create = async () => bare;
328- if (kind === 'login') await clickOn(btn); else await submitOn(formOf(btn)).p;344+ await submitForm(btn);
329 assert.match(status.textContent, /cannot send; update the browser/);345 assert.match(status.textContent, /cannot send; update the browser/);
330 assert.equal(fetches.length, 1, 'finish is not called');346 assert.equal(fetches.length, 1, 'finish is not called');
331 assert.equal(btn.disabled, false);347 assert.equal(btn.disabled, false);
@@ -336,7 +352,7 @@ test('a credential without toJSON is reported, not sent', async () => {
336 test('a begin answer that is not JSON', async () => {352 test('a begin answer that is not JSON', async () => {
337 const btn = page('login');353 const btn = page('login');
338 answers['/begin'] = { ok: true, status: 200, body: undefined }; // a 200 whose body is not JSON354 answers['/begin'] = { ok: true, status: 200, body: undefined }; // a 200 whose body is not JSON
339- await clickOn(btn);355+ await submitForm(btn);
340 assert.equal(status.textContent, 'the server sent an unexpected answer');356 assert.equal(status.textContent, 'the server sent an unexpected answer');
341 assert.equal(parsed.length, 0);357 assert.equal(parsed.length, 0);
342 assert.equal(btn.disabled, false);358 assert.equal(btn.disabled, false);
@@ -349,7 +365,7 @@ test('a begin answer with the wrong shape', async () => {
349 parsed = [];365 parsed = [];
350 const btn = page('login');366 const btn = page('login');
351 answers['/begin'] = { ok: true, status: 200, body };367 answers['/begin'] = { ok: true, status: 200, body };
352- await clickOn(btn);368+ await submitForm(btn);
353 assert.equal(status.textContent, 'the server sent an unexpected answer', JSON.stringify(body));369 assert.equal(status.textContent, 'the server sent an unexpected answer', JSON.stringify(body));
354 assert.equal(parsed.length, 0, 'nothing reached the browser API');370 assert.equal(parsed.length, 0, 'nothing reached the browser API');
355 assert.equal(fetches.length, 1);371 assert.equal(fetches.length, 1);
@@ -360,7 +376,7 @@ test('a begin answer with the wrong shape', async () => {
360 test('a name over 64 bytes (UTF-8) stops before begin', async () => {376 test('a name over 64 bytes (UTF-8) stops before begin', async () => {
361 const btn = page('register');377 const btn = page('register');
362 name.value = 'é'.repeat(33); // 33 characters, 66 bytes378 name.value = 'é'.repeat(33); // 33 characters, 66 bytes
363- await submitOn(formOf(btn)).p;379+ await submitForm(btn);
364 assert.match(status.textContent, /longer than 64 bytes/);380 assert.match(status.textContent, /longer than 64 bytes/);
365 assert.equal(fetches.length, 0, 'no request');381 assert.equal(fetches.length, 0, 'no request');
366 assert.equal(navigator.created, undefined, 'create() is not called');382 assert.equal(navigator.created, undefined, 'create() is not called');
@@ -368,7 +384,7 @@ test('a name over 64 bytes (UTF-8) stops before begin', async () => {
368 assert.deepEqual(dom.focused, [name], 'the field gets the focus');384 assert.deepEqual(dom.focused, [name], 'the field gets the focus');
369 // The check is not a ceremony: the next try is not blocked.385 // The check is not a ceremony: the next try is not blocked.
370 name.value = 'ok';386 name.value = 'ok';
371- await submitOn(formOf(btn)).p;387+ await submitForm(btn);
372 assert.equal(fetches.length, 2);388 assert.equal(fetches.length, 2);
373 });389 });
374 390
@@ -377,9 +393,8 @@ test('64 bytes is fine, in ASCII or in two-byte characters', async () => {
377 fetches = [];393 fetches = [];
378 const btn = page('register'); // a success navigates away: each try is a new page394 const btn = page('register'); // a success navigates away: each try is a new page
379 name.value = ok;395 name.value = ok;
380- await submitOn(formOf(btn)).p;396+ await submitForm(btn);
381 assert.equal(fetches.length, 2, ok.length + ' characters');397 assert.equal(fetches.length, 2, ok.length + ' characters');
382- P.reset();
383 }398 }
384 });399 });
385 400
@@ -388,7 +403,7 @@ test('control and direction characters stop before begin', async () => {
388 fetches = [];403 fetches = [];
389 const btn = page('register');404 const btn = page('register');
390 name.value = bad;405 name.value = bad;
391- await submitOn(formOf(btn)).p;406+ await submitForm(btn);
392 assert.match(status.textContent, /control or direction character/, JSON.stringify(bad));407 assert.match(status.textContent, /control or direction character/, JSON.stringify(bad));
393 assert.equal(fetches.length, 0, JSON.stringify(bad));408 assert.equal(fetches.length, 0, JSON.stringify(bad));
394 }409 }
@@ -403,20 +418,11 @@ test('plain text with a zero width joiner, accents and emoji is fine', () => {
403 test('an empty name is allowed: the server names the passkey', async () => {418 test('an empty name is allowed: the server names the passkey', async () => {
404 const btn = page('register');419 const btn = page('register');
405 name.value = ' ';420 name.value = ' ';
406- await submitOn(formOf(btn)).p;421+ await submitForm(btn);
407 assert.deepEqual(JSON.parse(fetches[0].init.body), { name: '' });422 assert.deepEqual(JSON.parse(fetches[0].init.body), { name: '' });
408 assert.equal(fetches.length, 2);423 assert.equal(fetches.length, 2);
409 });424 });
410 425
411-test('the server can still refuse the name at begin: its reason is shown', async () => {
412- const btn = page('register');
413- answers['/begin'] = { ok: false, status: 400, body: { error: 'passkey name: text contains the control character U+0009' } };
414- await submitOn(formOf(btn)).p;
415- assert.match(status.textContent, /control character/);
416- assert.equal(navigator.created, undefined);
417- assert.equal(btn.disabled, false);
418-});
419-
420 test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => {426 test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => {
421 const login = page('login');427 const login = page('login');
422 const register = el('button', { attrs: { 'data-passkey': 'register' } });428 const register = el('button', { attrs: { 'data-passkey': 'register' } });
@@ -428,7 +434,7 @@ test('an old browser: the buttons are disabled with a reason, nothing is fetched
428 assert.equal(status.textContent, 'this browser does not support passkeys');434 assert.equal(status.textContent, 'this browser does not support passkeys');
429 435
430 login.disabled = false;436 login.disabled = false;
431- await clickOn(login);437+ await submitForm(login);
432 assert.equal(fetches.length, 0);438 assert.equal(fetches.length, 0);
433 assert.equal(status.textContent, 'this browser does not support passkeys');439 assert.equal(status.textContent, 'this browser does not support passkeys');
434 });440 });
@@ -448,17 +454,17 @@ test('a supported browser keeps the buttons enabled', () => {
448 assert.equal(status.textContent, '');454 assert.equal(status.textContent, '');
449 });455 });
450 456
451-test('a disabled button ignores clicks', async () => {457+test('a disabled button ignores a submit', async () => {
452 const btn = page('login');458 const btn = page('login');
453 btn.disabled = true;459 btn.disabled = true;
454- await clickOn(btn);460+ await submitForm(btn);
455 assert.equal(fetches.length, 0);461 assert.equal(fetches.length, 0);
456 });462 });
457 463
458 test('register needs parseCreationOptionsFromJSON', async () => {464 test('register needs parseCreationOptionsFromJSON', async () => {
459 const btn = page('register');465 const btn = page('register');
460 delete PublicKeyCredential.parseCreationOptionsFromJSON;466 delete PublicKeyCredential.parseCreationOptionsFromJSON;
461- await submitOn(formOf(btn)).p;467+ await submitForm(btn);
462 assert.equal(fetches.length, 0);468 assert.equal(fetches.length, 0);
463 assert.equal(status.textContent, 'this browser does not support passkeys');469 assert.equal(status.textContent, 'this browser does not support passkeys');
464 });470 });
M internal/adapters/web/pages.templ +3 -3
@@ -189,9 +189,9 @@ templ PasskeyLogin(l Layout, p PasskeyLoginPage) {
189189 @Shell(l, nil) {
190190 <section class="state" aria-labelledby="state-title">
191191 <h1 id="state-title">{ p.Title }</h1>
192- <p class="state-act">
193- <button type="button" class="btn primary" data-js data-passkey="login" data-begin={ p.Begin } data-finish={ p.Finish }>{ p.Button }</button>
194- </p>
192+ <form class="state-act" data-js data-passkey-form>
193+ <button type="submit" class="btn primary" data-passkey="login" data-begin={ p.Begin } data-finish={ p.Finish }>{ p.Button }</button>
194+ </form>
195195 <p class="dim" role="status" aria-live="polite" data-passkey-status></p>
196196 for _, line := range p.Lines {
197197 <p class="dim">{ line }</p>
@@ -189,9 +189,9 @@ templ PasskeyLogin(l Layout, p PasskeyLoginPage) {
189 @Shell(l, nil) {189 @Shell(l, nil) {
190 <section class="state" aria-labelledby="state-title">190 <section class="state" aria-labelledby="state-title">
191 <h1 id="state-title">{ p.Title }</h1>191 <h1 id="state-title">{ p.Title }</h1>
192- <p class="state-act">192+ <form class="state-act" data-js data-passkey-form>
193- <button type="button" class="btn primary" data-js data-passkey="login" data-begin={ p.Begin } data-finish={ p.Finish }>{ p.Button }</button>193+ <button type="submit" class="btn primary" data-passkey="login" data-begin={ p.Begin } data-finish={ p.Finish }>{ p.Button }</button>
194- </p>194+ </form>
195 <p class="dim" role="status" aria-live="polite" data-passkey-status></p>195 <p class="dim" role="status" aria-live="polite" data-passkey-status></p>
196 for _, line := range p.Lines {196 for _, line := range p.Lines {
197 <p class="dim">{ line }</p>197 <p class="dim">{ line }</p>
M internal/adapters/web/pages_templ.go +5 -5 generated file: collapsed
M internal/adapters/web/passkeys_test.go +11 -0
@@ -461,6 +461,17 @@ func TestPasskeyLoginPage(t *testing.T) {
461461 assert.Contains(t, body, "<title>sign in")
462462 assert.Contains(t, body, "Sign in with a passkey")
463463 assert.Contains(t, body, `data-passkey="login"`)
464+ // One entry point for the script: the button is the submit button of a form.
465+ start := strings.Index(body, "<form class=\"state-act\"")
466+ require.GreaterOrEqual(t, start, 0, "the sign-in form is on the page")
467+ form := body[start:]
468+ end := strings.Index(form, "</form>")
469+ require.GreaterOrEqual(t, end, 0)
470+ form = form[:end]
471+ assert.Contains(t, form, "data-passkey-form")
472+ assert.Contains(t, form, `type="submit"`)
473+ assert.Contains(t, form, `data-passkey="login"`)
474+ assert.NotContains(t, form, "action=", "the script handles the submit; the form never posts by itself")
464475 assert.Contains(t, body, `data-begin="/-/passkeys/login/begin"`)
465476 assert.Contains(t, body, `data-finish="/-/passkeys/login/finish"`)
466477 assert.Contains(t, body, "ermite web login")
@@ -461,6 +461,17 @@ func TestPasskeyLoginPage(t *testing.T) {
461 assert.Contains(t, body, "<title>sign in")461 assert.Contains(t, body, "<title>sign in")
462 assert.Contains(t, body, "Sign in with a passkey")462 assert.Contains(t, body, "Sign in with a passkey")
463 assert.Contains(t, body, `data-passkey="login"`)463 assert.Contains(t, body, `data-passkey="login"`)
464+ // One entry point for the script: the button is the submit button of a form.
465+ start := strings.Index(body, "<form class=\"state-act\"")
466+ require.GreaterOrEqual(t, start, 0, "the sign-in form is on the page")
467+ form := body[start:]
468+ end := strings.Index(form, "</form>")
469+ require.GreaterOrEqual(t, end, 0)
470+ form = form[:end]
471+ assert.Contains(t, form, "data-passkey-form")
472+ assert.Contains(t, form, `type="submit"`)
473+ assert.Contains(t, form, `data-passkey="login"`)
474+ assert.NotContains(t, form, "action=", "the script handles the submit; the form never posts by itself")
464 assert.Contains(t, body, `data-begin="/-/passkeys/login/begin"`)475 assert.Contains(t, body, `data-begin="/-/passkeys/login/begin"`)
465 assert.Contains(t, body, `data-finish="/-/passkeys/login/finish"`)476 assert.Contains(t, body, `data-finish="/-/passkeys/login/finish"`)
466 assert.Contains(t, body, "ermite web login")477 assert.Contains(t, body, "ermite web login")
M internal/adapters/web/static/js/passkey.js +28 -34
@@ -1,9 +1,12 @@
11 // passkey.js: sign in with a passkey (/-/login) and add one (/-/passkeys).
22 //
3-// One delegated click handler (the login button) and one delegated submit
4-// handler (the "add a passkey" form, so that Enter in the name field works).
5-// The button carries the two endpoints (data-begin, data-finish). The flow is
6-// the one of the WebAuthn JSON API: POST begin, hand options.publicKey to the
3+// One delegated submit handler for both pages: the sign-in button and the "add
4+// a passkey" button are the submit buttons of a [data-passkey-form], so that a
5+// click, Enter in the name field and the keyboard all end in the same place.
6+// The button carries the two endpoints (data-begin, data-finish). While a
7+// ceremony runs the button is disabled, and a disabled submit button makes the
8+// form submit nothing: that is the only guard against a second prompt. The flow
9+// is the one of the WebAuthn JSON API: POST begin, hand options.publicKey to the
710 // browser (parse*OptionsFromJSON, then navigator.credentials.get|create), POST
811 // the result (toJSON()) to finish. Nothing is inline: the CSP only allows
912 // scripts from this origin. The core is tested in Node by
@@ -26,10 +29,6 @@ window.ermitePasskey = (() => {
2629 const statusEl = () => document.querySelector('[data-passkey-status]');
2730 const say = (text) => { const s = statusEl(); if (s) s.textContent = text; };
2831
29- // A ceremony runs at a time: a second click or Enter while one waits for the
30- // authenticator would start another prompt.
31- let busy = false;
32-
3332 // nameProblem says why a passkey name would be refused, or '' when it is fine.
3433 // It mirrors the server (domain.ParsePasskeyName): at most 64 bytes of UTF-8,
3534 // one line, no control character and no bidirectional mark, override or isolate.
@@ -62,8 +61,10 @@ window.ermitePasskey = (() => {
6261 async function failure(res) {
6362 let reason = '';
6463 try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ }
65- if (res.status === 403 && (!reason || reason === 'forbidden')) return new Error(SESSION_ENDED);
66- return new Error(reason || 'the server answered ' + res.status);
64+ const e = new Error(reason || 'the server answered ' + res.status);
65+ if (res.status === 403 && (!reason || reason === 'forbidden')) e.message = SESSION_ENDED;
66+ e.status = res.status;
67+ return e;
6768 }
6869
6970 // begin posts to the begin endpoint and checks the shape of the answer.
@@ -74,7 +75,11 @@ window.ermitePasskey = (() => {
7475 init.body = JSON.stringify({ name });
7576 }
7677 const res = await fetch(btn.dataset.begin, init);
77- if (!res.ok) throw await failure(res);
78+ if (!res.ok) {
79+ const e = await failure(res);
80+ e.atBegin = true; // a 400 here is the server refusing the name
81+ throw e;
82+ }
7883 let answer;
7984 try { answer = await res.json(); } catch (_) { throw new Error(UNEXPECTED); }
8085 const ok = answer && typeof answer.ceremony === 'string' && answer.ceremony !== '' &&
@@ -86,7 +91,7 @@ window.ermitePasskey = (() => {
8691
8792 async function run(btn) {
8893 const kind = btn.dataset.passkey;
89- if (!parser[kind] || busy) return;
94+ if (!parser[kind]) return;
9095 if (!supported(kind)) { say(UNSUPPORTED); return; }
9196
9297 const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null;
@@ -96,11 +101,11 @@ window.ermitePasskey = (() => {
96101 if (problem) { say(problem); nameEl.focus(); return; }
97102 }
98103
99- busy = true;
100104 btn.disabled = true;
101105 btn.setAttribute('aria-busy', 'true');
102106 say(WAITING);
103107 let done = false;
108+ let failed = null;
104109 try {
105110 const { ceremony, options } = await begin(btn, kind, name);
106111 const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey);
@@ -113,7 +118,7 @@ window.ermitePasskey = (() => {
113118 method: 'POST',
114119 credentials: 'same-origin',
115120 headers: { 'Content-Type': 'application/json' },
116- body: JSON.stringify({ ceremony, name, credential: cred.toJSON() }),
121+ body: JSON.stringify({ ceremony, credential: cred.toJSON() }),
117122 });
118123 if (!finished.ok) throw await failure(finished);
119124 done = true;
@@ -121,30 +126,23 @@ window.ermitePasskey = (() => {
121126 else location.reload();
122127 } catch (e) {
123128 say(messageOf(e));
129+ failed = e;
124130 } finally {
125131 if (!done) {
126- busy = false;
127132 btn.removeAttribute('aria-busy');
128133 btn.disabled = false;
129- // A disabled button cannot hold the focus: give it back once enabled.
130- btn.focus();
134+ // A disabled button cannot hold the focus. When the server refused the
135+ // name the field is what to fix; otherwise the button, to try again.
136+ if (nameEl && failed && failed.atBegin && failed.status === 400) nameEl.focus();
137+ else btn.focus();
131138 }
132139 }
133140 }
134141
135- // click handles the login button. The add button is a submit button of a
136- // form: its click is left to become a submit, which submit() handles.
137- function click(ev) {
138- const btn = ev.target && ev.target.closest && ev.target.closest('[data-passkey]');
139- if (!btn || btn.disabled || btn.closest('[data-passkey-form]')) return;
140- if (ev.preventDefault) ev.preventDefault();
141- return run(btn);
142- }
143-
144- // submit handles the add form, by a click on its button or Enter in the field.
142+ // submit handles both forms. The event comes from the form itself.
145143 function submit(ev) {
146- const form = ev.target && ev.target.closest && ev.target.closest('[data-passkey-form]');
147- if (!form) return;
144+ const form = ev.target;
145+ if (!form || typeof form.matches !== 'function' || !form.matches('[data-passkey-form]')) return;
148146 if (ev.preventDefault) ev.preventDefault(); // never a real navigation
149147 const btn = form.querySelector('[data-passkey]');
150148 if (!btn || btn.disabled) return;
@@ -164,12 +162,8 @@ window.ermitePasskey = (() => {
164162 }
165163
166164 if (typeof document.addEventListener === 'function') {
167- document.addEventListener('click', click);
168165 document.addEventListener('submit', submit);
169166 init();
170167 }
171- // reset forgets a ceremony in progress. A page never needs it (a success
172- // navigates away); the tests share one module between pages.
173- const reset = () => { busy = false; };
174- return { click, submit, init, run, nameProblem, reset };
168+ return { submit, init, nameProblem };
175169 })();
@@ -1,9 +1,12 @@
1 // passkey.js: sign in with a passkey (/-/login) and add one (/-/passkeys).1 // passkey.js: sign in with a passkey (/-/login) and add one (/-/passkeys).
2 //2 //
3-// One delegated click handler (the login button) and one delegated submit3+// One delegated submit handler for both pages: the sign-in button and the "add
4-// handler (the "add a passkey" form, so that Enter in the name field works).4+// a passkey" button are the submit buttons of a [data-passkey-form], so that a
5-// The button carries the two endpoints (data-begin, data-finish). The flow is5+// click, Enter in the name field and the keyboard all end in the same place.
6-// the one of the WebAuthn JSON API: POST begin, hand options.publicKey to the6+// The button carries the two endpoints (data-begin, data-finish). While a
7+// ceremony runs the button is disabled, and a disabled submit button makes the
8+// form submit nothing: that is the only guard against a second prompt. The flow
9+// is the one of the WebAuthn JSON API: POST begin, hand options.publicKey to the
7 // browser (parse*OptionsFromJSON, then navigator.credentials.get|create), POST10 // browser (parse*OptionsFromJSON, then navigator.credentials.get|create), POST
8 // the result (toJSON()) to finish. Nothing is inline: the CSP only allows11 // the result (toJSON()) to finish. Nothing is inline: the CSP only allows
9 // scripts from this origin. The core is tested in Node by12 // scripts from this origin. The core is tested in Node by
@@ -26,10 +29,6 @@ window.ermitePasskey = (() => {
26 const statusEl = () => document.querySelector('[data-passkey-status]');29 const statusEl = () => document.querySelector('[data-passkey-status]');
27 const say = (text) => { const s = statusEl(); if (s) s.textContent = text; };30 const say = (text) => { const s = statusEl(); if (s) s.textContent = text; };
28 31
29- // A ceremony runs at a time: a second click or Enter while one waits for the
30- // authenticator would start another prompt.
31- let busy = false;
32-
33 // nameProblem says why a passkey name would be refused, or '' when it is fine.32 // nameProblem says why a passkey name would be refused, or '' when it is fine.
34 // It mirrors the server (domain.ParsePasskeyName): at most 64 bytes of UTF-8,33 // It mirrors the server (domain.ParsePasskeyName): at most 64 bytes of UTF-8,
35 // one line, no control character and no bidirectional mark, override or isolate.34 // one line, no control character and no bidirectional mark, override or isolate.
@@ -62,8 +61,10 @@ window.ermitePasskey = (() => {
62 async function failure(res) {61 async function failure(res) {
63 let reason = '';62 let reason = '';
64 try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ }63 try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ }
65- if (res.status === 403 && (!reason || reason === 'forbidden')) return new Error(SESSION_ENDED);64+ const e = new Error(reason || 'the server answered ' + res.status);
66- return new Error(reason || 'the server answered ' + res.status);65+ if (res.status === 403 && (!reason || reason === 'forbidden')) e.message = SESSION_ENDED;
66+ e.status = res.status;
67+ return e;
67 }68 }
68 69
69 // begin posts to the begin endpoint and checks the shape of the answer.70 // begin posts to the begin endpoint and checks the shape of the answer.
@@ -74,7 +75,11 @@ window.ermitePasskey = (() => {
74 init.body = JSON.stringify({ name });75 init.body = JSON.stringify({ name });
75 }76 }
76 const res = await fetch(btn.dataset.begin, init);77 const res = await fetch(btn.dataset.begin, init);
77- if (!res.ok) throw await failure(res);78+ if (!res.ok) {
79+ const e = await failure(res);
80+ e.atBegin = true; // a 400 here is the server refusing the name
81+ throw e;
82+ }
78 let answer;83 let answer;
79 try { answer = await res.json(); } catch (_) { throw new Error(UNEXPECTED); }84 try { answer = await res.json(); } catch (_) { throw new Error(UNEXPECTED); }
80 const ok = answer && typeof answer.ceremony === 'string' && answer.ceremony !== '' &&85 const ok = answer && typeof answer.ceremony === 'string' && answer.ceremony !== '' &&
@@ -86,7 +91,7 @@ window.ermitePasskey = (() => {
86 91
87 async function run(btn) {92 async function run(btn) {
88 const kind = btn.dataset.passkey;93 const kind = btn.dataset.passkey;
89- if (!parser[kind] || busy) return;94+ if (!parser[kind]) return;
90 if (!supported(kind)) { say(UNSUPPORTED); return; }95 if (!supported(kind)) { say(UNSUPPORTED); return; }
91 96
92 const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null;97 const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null;
@@ -96,11 +101,11 @@ window.ermitePasskey = (() => {
96 if (problem) { say(problem); nameEl.focus(); return; }101 if (problem) { say(problem); nameEl.focus(); return; }
97 }102 }
98 103
99- busy = true;
100 btn.disabled = true;104 btn.disabled = true;
101 btn.setAttribute('aria-busy', 'true');105 btn.setAttribute('aria-busy', 'true');
102 say(WAITING);106 say(WAITING);
103 let done = false;107 let done = false;
108+ let failed = null;
104 try {109 try {
105 const { ceremony, options } = await begin(btn, kind, name);110 const { ceremony, options } = await begin(btn, kind, name);
106 const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey);111 const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey);
@@ -113,7 +118,7 @@ window.ermitePasskey = (() => {
113 method: 'POST',118 method: 'POST',
114 credentials: 'same-origin',119 credentials: 'same-origin',
115 headers: { 'Content-Type': 'application/json' },120 headers: { 'Content-Type': 'application/json' },
116- body: JSON.stringify({ ceremony, name, credential: cred.toJSON() }),121+ body: JSON.stringify({ ceremony, credential: cred.toJSON() }),
117 });122 });
118 if (!finished.ok) throw await failure(finished);123 if (!finished.ok) throw await failure(finished);
119 done = true;124 done = true;
@@ -121,30 +126,23 @@ window.ermitePasskey = (() => {
121 else location.reload();126 else location.reload();
122 } catch (e) {127 } catch (e) {
123 say(messageOf(e));128 say(messageOf(e));
129+ failed = e;
124 } finally {130 } finally {
125 if (!done) {131 if (!done) {
126- busy = false;
127 btn.removeAttribute('aria-busy');132 btn.removeAttribute('aria-busy');
128 btn.disabled = false;133 btn.disabled = false;
129- // A disabled button cannot hold the focus: give it back once enabled.134+ // A disabled button cannot hold the focus. When the server refused the
130- btn.focus();135+ // name the field is what to fix; otherwise the button, to try again.
136+ if (nameEl && failed && failed.atBegin && failed.status === 400) nameEl.focus();
137+ else btn.focus();
131 }138 }
132 }139 }
133 }140 }
134 141
135- // click handles the login button. The add button is a submit button of a142+ // submit handles both forms. The event comes from the form itself.
136- // form: its click is left to become a submit, which submit() handles.
137- function click(ev) {
138- const btn = ev.target && ev.target.closest && ev.target.closest('[data-passkey]');
139- if (!btn || btn.disabled || btn.closest('[data-passkey-form]')) return;
140- if (ev.preventDefault) ev.preventDefault();
141- return run(btn);
142- }
143-
144- // submit handles the add form, by a click on its button or Enter in the field.
145 function submit(ev) {143 function submit(ev) {
146- const form = ev.target && ev.target.closest && ev.target.closest('[data-passkey-form]');144+ const form = ev.target;
147- if (!form) return;145+ if (!form || typeof form.matches !== 'function' || !form.matches('[data-passkey-form]')) return;
148 if (ev.preventDefault) ev.preventDefault(); // never a real navigation146 if (ev.preventDefault) ev.preventDefault(); // never a real navigation
149 const btn = form.querySelector('[data-passkey]');147 const btn = form.querySelector('[data-passkey]');
150 if (!btn || btn.disabled) return;148 if (!btn || btn.disabled) return;
@@ -164,12 +162,8 @@ window.ermitePasskey = (() => {
164 }162 }
165 163
166 if (typeof document.addEventListener === 'function') {164 if (typeof document.addEventListener === 'function') {
167- document.addEventListener('click', click);
168 document.addEventListener('submit', submit);165 document.addEventListener('submit', submit);
169 init();166 init();
170 }167 }
171- // reset forgets a ceremony in progress. A page never needs it (a success168+ return { submit, init, nameProblem };
172- // navigates away); the tests share one module between pages.
173- const reset = () => { busy = false; };
174- return { click, submit, init, run, nameProblem, reset };
175 })();169 })();

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close