detached f867fa3

fix(web): harden the passkey script and put the add button in a form

- check the name (64 UTF-8 bytes, no control or direction character) before
  begin, and send it to register/begin
- show a busy state while the authenticator is prompted, give the focus back
  after a failure or a cancel, and run one ceremony at a time
- add the name field and the button in a form, so Enter submits
- check the shape of the begin answer and that the credential has toJSON
- say that the session ended on a 403 that carries no reason
commit
f867fa3e1ed568c29e1867cadf98ca12b59e9f1e
author
brice · · 2026-10-12 15:49 UTC
parent
6125ca9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +394 -57
M internal/adapters/web/jstest/dom.mjs +3 -1
@@ -143,10 +143,11 @@ export class El {
143143 return null;
144144 }
145145 scrollIntoView() {}
146+ focus() { dom.focused.push(this); }
146147 click() { dom.clicked.push(this); }
147148 }
148149
149-export const dom = { clicked: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 };
150+export const dom = { clicked: [], focused: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 };
150151
151152 // flushFrames runs the frames held since the last flush.
152153 export function flushFrames() {
@@ -160,6 +161,7 @@ export function flushFrames() {
160161 export function installDom({ parseHTML } = {}) {
161162 dom.body = new El('body');
162163 dom.clicked = [];
164+ dom.focused = [];
163165 const doc = {
164166 body: dom.body,
165167 createElement(tag) {
@@ -143,10 +143,11 @@ export class El {
143 return null;143 return null;
144 }144 }
145 scrollIntoView() {}145 scrollIntoView() {}
146+ focus() { dom.focused.push(this); }
146 click() { dom.clicked.push(this); }147 click() { dom.clicked.push(this); }
147 }148 }
148 149
149-export const dom = { clicked: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 };150+export const dom = { clicked: [], focused: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 };
150 151
151 // flushFrames runs the frames held since the last flush.152 // flushFrames runs the frames held since the last flush.
152 export function flushFrames() {153 export function flushFrames() {
@@ -160,6 +161,7 @@ export function flushFrames() {
160 export function installDom({ parseHTML } = {}) {161 export function installDom({ parseHTML } = {}) {
161 dom.body = new El('body');162 dom.body = new El('body');
162 dom.clicked = [];163 dom.clicked = [];
164+ dom.focused = [];
163 const doc = {165 const doc = {
164 body: dom.body,166 body: dom.body,
165 createElement(tag) {167 createElement(tag) {
M internal/adapters/web/jstest/passkey.test.mjs +280 -31
@@ -3,10 +3,12 @@
33 // node --test 'internal/adapters/web/jstest/*.test.mjs'
44 import assert from 'node:assert/strict';
55 import { test, beforeEach } from 'node:test';
6-import { dom, el, installDom, settle } from './dom.mjs';
6+import { dom, el, installDom } from './dom.mjs';
77
88 const doc = installDom({ parseHTML: () => [] });
9-doc.addEventListener = () => { dom.listened = true; };
9+// The script registers its listeners when it loads: record them.
10+const listeners = [];
11+doc.addEventListener = (type, fn) => { listeners.push([type, fn]); };
1012 // Node defines navigator as a getter: replace it, as a browser test would.
1113 const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true });
1214 setNavigator({ credentials: {} });
@@ -18,33 +20,50 @@ const OPTIONS = { publicKey: { challenge: 'abc' } };
1820 const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) };
1921
2022 let fetches; // {url, init}
21-let answers; // url -> {ok, status, body}
23+let answers; // url -> {ok, status, body} (body: object, or a function that throws, or undefined for no JSON)
2224 let parsed; // [kind, publicKey]
2325 let nav; // where the page was sent: 'assign:/' or 'reload'
2426 let status;
2527 let name;
2628
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).
2731 function page(kind) {
2832 dom.body = el('body');
33+ dom.focused = [];
2934 status = el('p', { attrs: { 'data-passkey-status': '' } });
3035 name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' });
3136 const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish' } });
32- dom.body.append(status, name, btn);
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+ }
3344 return btn;
3445 }
3546
3647 beforeEach(() => {
48+ P.reset();
3749 fetches = [];
3850 parsed = [];
3951 nav = '';
4052 answers = {
4153 '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } },
42- '/finish': { ok: true, status: 204, body: null },
54+ '/finish': { ok: true, status: 204, body: undefined },
4355 };
4456 globalThis.fetch = async (url, init) => {
4557 fetches.push({ url, init });
4658 const a = answers[url];
47- return { ok: a.ok, status: a.status, json: async () => { if (a.body === null) throw new Error('no body'); return a.body; } };
59+ return {
60+ ok: a.ok,
61+ status: a.status,
62+ json: async () => {
63+ if (a.body === undefined) throw new SyntaxError('Unexpected end of JSON input');
64+ return a.body;
65+ },
66+ };
4867 };
4968 globalThis.PublicKeyCredential = {
5069 parseRequestOptionsFromJSON: (o) => { parsed.push(['get', o]); return { parsedGet: o }; },
@@ -60,6 +79,22 @@ beforeEach(() => {
6079 });
6180
6281 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).
85+const submitOn = (target) => {
86+ let prevented = false;
87+ const p = P.submit({ target, preventDefault() { prevented = true; } });
88+ return { p, prevented: () => prevented };
89+};
90+const formOf = (btn) => btn.form;
91+const jsonBody = (i) => JSON.parse(fetches[i].init.body);
92+
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);
97+});
6398
6499 test('login: begin, get, finish with toJSON(), then home', async () => {
65100 const btn = page('login');
@@ -74,24 +109,55 @@ test('login: begin, get, finish with toJSON(), then home', async () => {
74109
75110 assert.equal(fetches[1].url, '/finish');
76111 assert.equal(fetches[1].init.method, 'POST');
112+ assert.equal(fetches[1].init.credentials, 'same-origin');
77113 assert.equal(fetches[1].init.headers['Content-Type'], 'application/json');
78- assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: '', credential: { id: 'cid', type: 'public-key' } });
114+ assert.deepEqual(jsonBody(1), { ceremony: 'c1', name: '', credential: { id: 'cid', type: 'public-key' } });
79115 assert.equal(nav, 'assign:/');
80- assert.equal(status.textContent, '');
81116 assert.equal(btn.disabled, true, 'stays disabled while the page navigates');
82117 });
83118
84-test('register: create with the trimmed name, then reload', async () => {
119+test('register: the trimmed name goes to begin and to finish, then reload', async () => {
85120 const btn = page('register');
86- await clickOn(btn);
121+ const { p, prevented } = submitOn(formOf(btn));
122+ await p;
87123
124+ assert.equal(prevented(), true, 'the form never navigates by itself');
125+ assert.equal(fetches[0].url, '/begin');
126+ assert.equal(fetches[0].init.credentials, 'same-origin');
127+ assert.equal(fetches[0].init.headers['Content-Type'], 'application/json');
128+ assert.deepEqual(JSON.parse(fetches[0].init.body), { name: 'Pixel 9' });
88129 assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]);
89130 assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } });
90- assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: 'Pixel 9', credential: { id: 'cid', type: 'public-key' } });
131+ assert.equal(fetches[1].init.credentials, 'same-origin');
132+ assert.deepEqual(jsonBody(1), { ceremony: 'c1', name: 'Pixel 9', credential: { id: 'cid', type: 'public-key' } });
91133 assert.equal(nav, 'reload');
92134 });
93135
94-test('a click inside the button reaches it, other clicks do nothing', async () => {
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 () => {
151+ page('register');
152+ const other = el('form', {}, el('input'));
153+ dom.body.append(other);
154+ const { p, prevented } = submitOn(other);
155+ await p;
156+ assert.equal(fetches.length, 0);
157+ assert.equal(prevented(), false, 'other forms submit as they always did');
158+});
159+
160+test('a click inside the login button reaches it, other clicks do nothing', async () => {
95161 const btn = page('login');
96162 const inner = el('span');
97163 btn.append(inner);
@@ -103,33 +169,112 @@ test('a click inside the button reaches it, other clicks do nothing', async () =
103169 assert.equal(fetches.length, 0);
104170 });
105171
106-test('begin refused: the server reason is shown, nothing else is called', async () => {
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 () => {
107175 const btn = page('login');
108- answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } };
109- await clickOn(btn);
110- assert.equal(status.textContent, 'too many attempts');
111- assert.equal(fetches.length, 1);
112- assert.equal(nav, '');
113- assert.equal(btn.disabled, false, 'the button is usable again');
176+ let release;
177+ navigator.credentials.get = () => new Promise((resolve) => { release = () => resolve(CRED); });
178+
179+ const first = clickOn(btn);
180+ await new Promise((r) => setTimeout(r, 0));
181+ assert.equal(btn.getAttribute('aria-busy'), 'true');
182+ assert.equal(btn.disabled, true);
183+ assert.equal(status.textContent, 'waiting for your passkey…');
184+
185+ await clickOn(btn); // the double click
186+ assert.equal(fetches.filter((f) => f.url === '/begin').length, 1, 'one begin only');
187+
188+ release();
189+ await first;
190+ assert.equal(fetches.filter((f) => f.url === '/begin').length, 1);
191+ assert.equal(nav, 'assign:/');
192+});
193+
194+test('a double submit makes one begin', async () => {
195+ 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;
204+ assert.equal(nav, 'reload');
205+});
206+
207+test('after a failure the page is free for another try', async () => {
208+ const btn = page('register');
209+ answers['/finish'] = { ok: false, status: 400, body: { error: 'nope' } };
210+ await submitOn(formOf(btn)).p;
211+ assert.equal(status.textContent, 'nope');
212+ 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');
114215 });
115216
116-test('finish refused: the reason is shown and the page stays', async () => {
217+test('on failure the button is enabled again, not busy, and has the focus', async () => {
117218 const btn = page('login');
118219 answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } };
119220 await clickOn(btn);
120221 assert.equal(status.textContent, 'passkey not accepted');
222+ assert.equal(btn.disabled, false);
223+ assert.equal(btn.hasAttribute('aria-busy'), false);
224+ assert.equal(nav, '');
225+ assert.deepEqual(dom.focused, [btn], 'the focus is given back');
226+});
227+
228+test('on success the button stays busy and disabled, without stealing focus', async () => {
229+ const btn = page('login');
230+ await clickOn(btn);
231+ assert.equal(btn.disabled, true);
232+ assert.equal(btn.getAttribute('aria-busy'), 'true');
233+ assert.deepEqual(dom.focused, []);
234+});
235+
236+test('begin refused: the server reason is shown, nothing else is called', async () => {
237+ const btn = page('login');
238+ answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } };
239+ await clickOn(btn);
240+ assert.equal(status.textContent, 'too many attempts');
241+ assert.equal(fetches.length, 1);
121242 assert.equal(nav, '');
122243 assert.equal(btn.disabled, false);
123244 });
124245
125246 test('a refusal without a JSON body falls back to the status', async () => {
126- const btn = page('register');
127- answers['/finish'] = { ok: false, status: 502, body: null };
247+ const btn = page('login');
248+ answers['/finish'] = { ok: false, status: 502, body: undefined };
128249 await clickOn(btn);
129250 assert.equal(status.textContent, 'the server answered 502');
130251 assert.equal(nav, '');
131252 });
132253
254+test('a 403 without a reason says the session ended', async () => {
255+ for (const body of [undefined, { error: 'forbidden' }, {}]) {
256+ const btn = page('register');
257+ answers['/begin'] = { ok: false, status: 403, body };
258+ await submitOn(formOf(btn)).p;
259+ 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);
261+ fetches = [];
262+ }
263+ // At finish too.
264+ const btn = page('login');
265+ answers['/begin'] = { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } };
266+ answers['/finish'] = { ok: false, status: 403, body: undefined };
267+ await clickOn(btn);
268+ assert.match(status.textContent, /your session ended/);
269+});
270+
271+test('a 403 with a reason of its own shows that reason', async () => {
272+ const btn = page('register');
273+ answers['/begin'] = { ok: false, status: 403, body: { error: 'the owner must sign in first' } };
274+ await submitOn(formOf(btn)).p;
275+ assert.equal(status.textContent, 'the owner must sign in first');
276+});
277+
133278 test('the owner cancels the prompt', async () => {
134279 const btn = page('login');
135280 navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); };
@@ -137,12 +282,22 @@ test('the owner cancels the prompt', async () => {
137282 assert.match(status.textContent, /cancelled, or it took too long/);
138283 assert.equal(fetches.length, 1, 'finish is not called');
139284 assert.equal(btn.disabled, false);
285+ assert.deepEqual(dom.focused, [btn]);
286+});
287+
288+test('AbortError says cancelled', async () => {
289+ const btn = page('login');
290+ navigator.credentials.get = async () => { throw Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }); };
291+ await clickOn(btn);
292+ assert.equal(status.textContent, 'cancelled');
293+ assert.equal(fetches.length, 1);
294+ assert.equal(btn.disabled, false);
140295 });
141296
142297 test('an authenticator that already holds a passkey', async () => {
143298 const btn = page('register');
144299 navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); };
145- await clickOn(btn);
300+ await submitOn(formOf(btn)).p;
146301 assert.match(status.textContent, /already holds a passkey/);
147302 assert.equal(fetches.length, 1);
148303 });
@@ -163,6 +318,105 @@ test('a null credential is not sent', async () => {
163318 assert.equal(fetches.length, 1);
164319 });
165320
321+test('a credential without toJSON is reported, not sent', async () => {
322+ for (const kind of ['login', 'register']) {
323+ fetches = [];
324+ const btn = page(kind);
325+ const bare = { id: 'x' };
326+ navigator.credentials.get = async () => bare;
327+ navigator.credentials.create = async () => bare;
328+ if (kind === 'login') await clickOn(btn); else await submitOn(formOf(btn)).p;
329+ assert.match(status.textContent, /cannot send; update the browser/);
330+ assert.equal(fetches.length, 1, 'finish is not called');
331+ assert.equal(btn.disabled, false);
332+ }
333+});
334+
335+// The begin answer is checked before it reaches the browser API.
336+test('a begin answer that is not JSON', async () => {
337+ const btn = page('login');
338+ answers['/begin'] = { ok: true, status: 200, body: undefined }; // a 200 whose body is not JSON
339+ await clickOn(btn);
340+ assert.equal(status.textContent, 'the server sent an unexpected answer');
341+ assert.equal(parsed.length, 0);
342+ assert.equal(btn.disabled, false);
343+});
344+
345+test('a begin answer with the wrong shape', async () => {
346+ for (const body of [null, 'x', [], {}, { ceremony: 'c1' }, { ceremony: 'c1', options: {} }, { ceremony: 'c1', options: { publicKey: 'x' } },
347+ { ceremony: '', options: OPTIONS }, { ceremony: 5, options: OPTIONS }, { options: OPTIONS }, { ceremony: 'c1', options: null }]) {
348+ fetches = [];
349+ parsed = [];
350+ const btn = page('login');
351+ answers['/begin'] = { ok: true, status: 200, body };
352+ await clickOn(btn);
353+ assert.equal(status.textContent, 'the server sent an unexpected answer', JSON.stringify(body));
354+ assert.equal(parsed.length, 0, 'nothing reached the browser API');
355+ assert.equal(fetches.length, 1);
356+ }
357+});
358+
359+// A name the server would refuse is caught before the owner is prompted.
360+test('a name over 64 bytes (UTF-8) stops before begin', async () => {
361+ const btn = page('register');
362+ name.value = 'é'.repeat(33); // 33 characters, 66 bytes
363+ await submitOn(formOf(btn)).p;
364+ assert.match(status.textContent, /longer than 64 bytes/);
365+ assert.equal(fetches.length, 0, 'no request');
366+ assert.equal(navigator.created, undefined, 'create() is not called');
367+ assert.notEqual(btn.disabled, true, 'the button was never disabled');
368+ assert.deepEqual(dom.focused, [name], 'the field gets the focus');
369+ // The check is not a ceremony: the next try is not blocked.
370+ name.value = 'ok';
371+ await submitOn(formOf(btn)).p;
372+ assert.equal(fetches.length, 2);
373+});
374+
375+test('64 bytes is fine, in ASCII or in two-byte characters', async () => {
376+ for (const ok of ['a'.repeat(64), 'é'.repeat(32)]) {
377+ fetches = [];
378+ const btn = page('register'); // a success navigates away: each try is a new page
379+ name.value = ok;
380+ await submitOn(formOf(btn)).p;
381+ assert.equal(fetches.length, 2, ok.length + ' characters');
382+ P.reset();
383+ }
384+});
385+
386+test('control and direction characters stop before begin', async () => {
387+ for (const bad of ['a\u0000b', 'a\tb', 'a\nb', 'a\x7fb', 'a\u0085b', 'evil \u202e txt', 'a\u2066b', 'a\u200eb', 'a\u061cb', 'a\u2028b', 'a\u2029b']) {
388+ fetches = [];
389+ const btn = page('register');
390+ name.value = bad;
391+ await submitOn(formOf(btn)).p;
392+ assert.match(status.textContent, /control or direction character/, JSON.stringify(bad));
393+ assert.equal(fetches.length, 0, JSON.stringify(bad));
394+ }
395+});
396+
397+test('plain text with a zero width joiner, accents and emoji is fine', () => {
398+ for (const ok of ['Pixel 9', 'clé de ma mère', 'a\u200db', '🔑 key', '']) {
399+ assert.equal(P.nameProblem(ok), '', JSON.stringify(ok));
400+ }
401+});
402+
403+test('an empty name is allowed: the server names the passkey', async () => {
404+ const btn = page('register');
405+ name.value = ' ';
406+ await submitOn(formOf(btn)).p;
407+ assert.deepEqual(JSON.parse(fetches[0].init.body), { name: '' });
408+ assert.equal(fetches.length, 2);
409+});
410+
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+
166420 test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => {
167421 const login = page('login');
168422 const register = el('button', { attrs: { 'data-passkey': 'register' } });
@@ -179,7 +433,7 @@ test('an old browser: the buttons are disabled with a reason, nothing is fetched
179433 assert.equal(status.textContent, 'this browser does not support passkeys');
180434 });
181435
182-test('no PublicKeyCredential at all', async () => {
436+test('no PublicKeyCredential at all', () => {
183437 const btn = page('login');
184438 globalThis.PublicKeyCredential = undefined;
185439 P.init();
@@ -201,15 +455,10 @@ test('a disabled button ignores clicks', async () => {
201455 assert.equal(fetches.length, 0);
202456 });
203457
204-test('only login supports needs parseRequestOptionsFromJSON, register parseCreationOptionsFromJSON', async () => {
458+test('register needs parseCreationOptionsFromJSON', async () => {
205459 const btn = page('register');
206460 delete PublicKeyCredential.parseCreationOptionsFromJSON;
207- await clickOn(btn);
461+ await submitOn(formOf(btn)).p;
208462 assert.equal(fetches.length, 0);
209463 assert.equal(status.textContent, 'this browser does not support passkeys');
210464 });
211-
212-test('the script registers one delegated click listener', () => {
213- assert.equal(dom.listened, true);
214- void settle;
215-});
@@ -3,10 +3,12 @@
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, beforeEach } from 'node:test';
6-import { dom, el, installDom, settle } from './dom.mjs';6+import { dom, el, installDom } from './dom.mjs';
7 7
8 const doc = installDom({ parseHTML: () => [] });8 const doc = installDom({ parseHTML: () => [] });
9-doc.addEventListener = () => { dom.listened = true; };9+// The script registers its listeners when it loads: record them.
10+const listeners = [];
11+doc.addEventListener = (type, fn) => { listeners.push([type, fn]); };
10 // Node defines navigator as a getter: replace it, as a browser test would.12 // Node defines navigator as a getter: replace it, as a browser test would.
11 const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true });13 const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true });
12 setNavigator({ credentials: {} });14 setNavigator({ credentials: {} });
@@ -18,33 +20,50 @@ const OPTIONS = { publicKey: { challenge: 'abc' } };
18 const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) };20 const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) };
19 21
20 let fetches; // {url, init}22 let fetches; // {url, init}
21-let answers; // url -> {ok, status, body}23+let answers; // url -> {ok, status, body} (body: object, or a function that throws, or undefined for no JSON)
22 let parsed; // [kind, publicKey]24 let parsed; // [kind, publicKey]
23 let nav; // where the page was sent: 'assign:/' or 'reload'25 let nav; // where the page was sent: 'assign:/' or 'reload'
24 let status;26 let status;
25 let name;27 let name;
26 28
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).
27 function page(kind) {31 function page(kind) {
28 dom.body = el('body');32 dom.body = el('body');
33+ dom.focused = [];
29 status = el('p', { attrs: { 'data-passkey-status': '' } });34 status = el('p', { attrs: { 'data-passkey-status': '' } });
30 name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' });35 name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' });
31 const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish' } });36 const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish' } });
32- dom.body.append(status, name, btn);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+ }
33 return btn;44 return btn;
34 }45 }
35 46
36 beforeEach(() => {47 beforeEach(() => {
48+ P.reset();
37 fetches = [];49 fetches = [];
38 parsed = [];50 parsed = [];
39 nav = '';51 nav = '';
40 answers = {52 answers = {
41 '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } },53 '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } },
42- '/finish': { ok: true, status: 204, body: null },54+ '/finish': { ok: true, status: 204, body: undefined },
43 };55 };
44 globalThis.fetch = async (url, init) => {56 globalThis.fetch = async (url, init) => {
45 fetches.push({ url, init });57 fetches.push({ url, init });
46 const a = answers[url];58 const a = answers[url];
47- return { ok: a.ok, status: a.status, json: async () => { if (a.body === null) throw new Error('no body'); return a.body; } };59+ return {
60+ ok: a.ok,
61+ status: a.status,
62+ json: async () => {
63+ if (a.body === undefined) throw new SyntaxError('Unexpected end of JSON input');
64+ return a.body;
65+ },
66+ };
48 };67 };
49 globalThis.PublicKeyCredential = {68 globalThis.PublicKeyCredential = {
50 parseRequestOptionsFromJSON: (o) => { parsed.push(['get', o]); return { parsedGet: o }; },69 parseRequestOptionsFromJSON: (o) => { parsed.push(['get', o]); return { parsedGet: o }; },
@@ -60,6 +79,22 @@ beforeEach(() => {
60 });79 });
61 80
62 const clickOn = (target) => P.click({ target, preventDefault() {} });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).
85+const submitOn = (target) => {
86+ let prevented = false;
87+ const p = P.submit({ target, preventDefault() { prevented = true; } });
88+ return { p, prevented: () => prevented };
89+};
90+const formOf = (btn) => btn.form;
91+const jsonBody = (i) => JSON.parse(fetches[i].init.body);
92+
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);
97+});
63 98
64 test('login: begin, get, finish with toJSON(), then home', async () => {99 test('login: begin, get, finish with toJSON(), then home', async () => {
65 const btn = page('login');100 const btn = page('login');
@@ -74,24 +109,55 @@ test('login: begin, get, finish with toJSON(), then home', async () => {
74 109
75 assert.equal(fetches[1].url, '/finish');110 assert.equal(fetches[1].url, '/finish');
76 assert.equal(fetches[1].init.method, 'POST');111 assert.equal(fetches[1].init.method, 'POST');
112+ assert.equal(fetches[1].init.credentials, 'same-origin');
77 assert.equal(fetches[1].init.headers['Content-Type'], 'application/json');113 assert.equal(fetches[1].init.headers['Content-Type'], 'application/json');
78- assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: '', credential: { id: 'cid', type: 'public-key' } });114+ assert.deepEqual(jsonBody(1), { ceremony: 'c1', name: '', credential: { id: 'cid', type: 'public-key' } });
79 assert.equal(nav, 'assign:/');115 assert.equal(nav, 'assign:/');
80- assert.equal(status.textContent, '');
81 assert.equal(btn.disabled, true, 'stays disabled while the page navigates');116 assert.equal(btn.disabled, true, 'stays disabled while the page navigates');
82 });117 });
83 118
84-test('register: create with the trimmed name, then reload', async () => {119+test('register: the trimmed name goes to begin and to finish, then reload', async () => {
85 const btn = page('register');120 const btn = page('register');
86- await clickOn(btn);121+ const { p, prevented } = submitOn(formOf(btn));
122+ await p;
87 123
124+ assert.equal(prevented(), true, 'the form never navigates by itself');
125+ assert.equal(fetches[0].url, '/begin');
126+ assert.equal(fetches[0].init.credentials, 'same-origin');
127+ assert.equal(fetches[0].init.headers['Content-Type'], 'application/json');
128+ assert.deepEqual(JSON.parse(fetches[0].init.body), { name: 'Pixel 9' });
88 assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]);129 assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]);
89 assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } });130 assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } });
90- assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: 'Pixel 9', credential: { id: 'cid', type: 'public-key' } });131+ assert.equal(fetches[1].init.credentials, 'same-origin');
132+ assert.deepEqual(jsonBody(1), { ceremony: 'c1', name: 'Pixel 9', credential: { id: 'cid', type: 'public-key' } });
91 assert.equal(nav, 'reload');133 assert.equal(nav, 'reload');
92 });134 });
93 135
94-test('a click inside the button reaches it, other clicks do nothing', async () => {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 () => {
151+ page('register');
152+ const other = el('form', {}, el('input'));
153+ dom.body.append(other);
154+ const { p, prevented } = submitOn(other);
155+ await p;
156+ assert.equal(fetches.length, 0);
157+ assert.equal(prevented(), false, 'other forms submit as they always did');
158+});
159+
160+test('a click inside the login button reaches it, other clicks do nothing', async () => {
95 const btn = page('login');161 const btn = page('login');
96 const inner = el('span');162 const inner = el('span');
97 btn.append(inner);163 btn.append(inner);
@@ -103,33 +169,112 @@ test('a click inside the button reaches it, other clicks do nothing', async () =
103 assert.equal(fetches.length, 0);169 assert.equal(fetches.length, 0);
104 });170 });
105 171
106-test('begin refused: the server reason is shown, nothing else is called', async () => {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 () => {
107 const btn = page('login');175 const btn = page('login');
108- answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } };176+ let release;
109- await clickOn(btn);177+ navigator.credentials.get = () => new Promise((resolve) => { release = () => resolve(CRED); });
110- assert.equal(status.textContent, 'too many attempts');178+
111- assert.equal(fetches.length, 1);179+ const first = clickOn(btn);
112- assert.equal(nav, '');180+ await new Promise((r) => setTimeout(r, 0));
113- assert.equal(btn.disabled, false, 'the button is usable again');181+ assert.equal(btn.getAttribute('aria-busy'), 'true');
182+ assert.equal(btn.disabled, true);
183+ assert.equal(status.textContent, 'waiting for your passkey…');
184+
185+ await clickOn(btn); // the double click
186+ assert.equal(fetches.filter((f) => f.url === '/begin').length, 1, 'one begin only');
187+
188+ release();
189+ await first;
190+ assert.equal(fetches.filter((f) => f.url === '/begin').length, 1);
191+ assert.equal(nav, 'assign:/');
192+});
193+
194+test('a double submit makes one begin', async () => {
195+ 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;
204+ assert.equal(nav, 'reload');
205+});
206+
207+test('after a failure the page is free for another try', async () => {
208+ const btn = page('register');
209+ answers['/finish'] = { ok: false, status: 400, body: { error: 'nope' } };
210+ await submitOn(formOf(btn)).p;
211+ assert.equal(status.textContent, 'nope');
212+ 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');
114 });215 });
115 216
116-test('finish refused: the reason is shown and the page stays', async () => {217+test('on failure the button is enabled again, not busy, and has the focus', async () => {
117 const btn = page('login');218 const btn = page('login');
118 answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } };219 answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } };
119 await clickOn(btn);220 await clickOn(btn);
120 assert.equal(status.textContent, 'passkey not accepted');221 assert.equal(status.textContent, 'passkey not accepted');
222+ assert.equal(btn.disabled, false);
223+ assert.equal(btn.hasAttribute('aria-busy'), false);
224+ assert.equal(nav, '');
225+ assert.deepEqual(dom.focused, [btn], 'the focus is given back');
226+});
227+
228+test('on success the button stays busy and disabled, without stealing focus', async () => {
229+ const btn = page('login');
230+ await clickOn(btn);
231+ assert.equal(btn.disabled, true);
232+ assert.equal(btn.getAttribute('aria-busy'), 'true');
233+ assert.deepEqual(dom.focused, []);
234+});
235+
236+test('begin refused: the server reason is shown, nothing else is called', async () => {
237+ const btn = page('login');
238+ answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } };
239+ await clickOn(btn);
240+ assert.equal(status.textContent, 'too many attempts');
241+ assert.equal(fetches.length, 1);
121 assert.equal(nav, '');242 assert.equal(nav, '');
122 assert.equal(btn.disabled, false);243 assert.equal(btn.disabled, false);
123 });244 });
124 245
125 test('a refusal without a JSON body falls back to the status', async () => {246 test('a refusal without a JSON body falls back to the status', async () => {
126- const btn = page('register');247+ const btn = page('login');
127- answers['/finish'] = { ok: false, status: 502, body: null };248+ answers['/finish'] = { ok: false, status: 502, body: undefined };
128 await clickOn(btn);249 await clickOn(btn);
129 assert.equal(status.textContent, 'the server answered 502');250 assert.equal(status.textContent, 'the server answered 502');
130 assert.equal(nav, '');251 assert.equal(nav, '');
131 });252 });
132 253
254+test('a 403 without a reason says the session ended', async () => {
255+ for (const body of [undefined, { error: 'forbidden' }, {}]) {
256+ const btn = page('register');
257+ answers['/begin'] = { ok: false, status: 403, body };
258+ await submitOn(formOf(btn)).p;
259+ 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);
261+ fetches = [];
262+ }
263+ // At finish too.
264+ const btn = page('login');
265+ answers['/begin'] = { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } };
266+ answers['/finish'] = { ok: false, status: 403, body: undefined };
267+ await clickOn(btn);
268+ assert.match(status.textContent, /your session ended/);
269+});
270+
271+test('a 403 with a reason of its own shows that reason', async () => {
272+ const btn = page('register');
273+ answers['/begin'] = { ok: false, status: 403, body: { error: 'the owner must sign in first' } };
274+ await submitOn(formOf(btn)).p;
275+ assert.equal(status.textContent, 'the owner must sign in first');
276+});
277+
133 test('the owner cancels the prompt', async () => {278 test('the owner cancels the prompt', async () => {
134 const btn = page('login');279 const btn = page('login');
135 navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); };280 navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); };
@@ -137,12 +282,22 @@ test('the owner cancels the prompt', async () => {
137 assert.match(status.textContent, /cancelled, or it took too long/);282 assert.match(status.textContent, /cancelled, or it took too long/);
138 assert.equal(fetches.length, 1, 'finish is not called');283 assert.equal(fetches.length, 1, 'finish is not called');
139 assert.equal(btn.disabled, false);284 assert.equal(btn.disabled, false);
285+ assert.deepEqual(dom.focused, [btn]);
286+});
287+
288+test('AbortError says cancelled', async () => {
289+ const btn = page('login');
290+ navigator.credentials.get = async () => { throw Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }); };
291+ await clickOn(btn);
292+ assert.equal(status.textContent, 'cancelled');
293+ assert.equal(fetches.length, 1);
294+ assert.equal(btn.disabled, false);
140 });295 });
141 296
142 test('an authenticator that already holds a passkey', async () => {297 test('an authenticator that already holds a passkey', async () => {
143 const btn = page('register');298 const btn = page('register');
144 navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); };299 navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); };
145- await clickOn(btn);300+ await submitOn(formOf(btn)).p;
146 assert.match(status.textContent, /already holds a passkey/);301 assert.match(status.textContent, /already holds a passkey/);
147 assert.equal(fetches.length, 1);302 assert.equal(fetches.length, 1);
148 });303 });
@@ -163,6 +318,105 @@ test('a null credential is not sent', async () => {
163 assert.equal(fetches.length, 1);318 assert.equal(fetches.length, 1);
164 });319 });
165 320
321+test('a credential without toJSON is reported, not sent', async () => {
322+ for (const kind of ['login', 'register']) {
323+ fetches = [];
324+ const btn = page(kind);
325+ const bare = { id: 'x' };
326+ navigator.credentials.get = async () => bare;
327+ navigator.credentials.create = async () => bare;
328+ if (kind === 'login') await clickOn(btn); else await submitOn(formOf(btn)).p;
329+ assert.match(status.textContent, /cannot send; update the browser/);
330+ assert.equal(fetches.length, 1, 'finish is not called');
331+ assert.equal(btn.disabled, false);
332+ }
333+});
334+
335+// The begin answer is checked before it reaches the browser API.
336+test('a begin answer that is not JSON', async () => {
337+ const btn = page('login');
338+ answers['/begin'] = { ok: true, status: 200, body: undefined }; // a 200 whose body is not JSON
339+ await clickOn(btn);
340+ assert.equal(status.textContent, 'the server sent an unexpected answer');
341+ assert.equal(parsed.length, 0);
342+ assert.equal(btn.disabled, false);
343+});
344+
345+test('a begin answer with the wrong shape', async () => {
346+ for (const body of [null, 'x', [], {}, { ceremony: 'c1' }, { ceremony: 'c1', options: {} }, { ceremony: 'c1', options: { publicKey: 'x' } },
347+ { ceremony: '', options: OPTIONS }, { ceremony: 5, options: OPTIONS }, { options: OPTIONS }, { ceremony: 'c1', options: null }]) {
348+ fetches = [];
349+ parsed = [];
350+ const btn = page('login');
351+ answers['/begin'] = { ok: true, status: 200, body };
352+ await clickOn(btn);
353+ assert.equal(status.textContent, 'the server sent an unexpected answer', JSON.stringify(body));
354+ assert.equal(parsed.length, 0, 'nothing reached the browser API');
355+ assert.equal(fetches.length, 1);
356+ }
357+});
358+
359+// A name the server would refuse is caught before the owner is prompted.
360+test('a name over 64 bytes (UTF-8) stops before begin', async () => {
361+ const btn = page('register');
362+ name.value = 'é'.repeat(33); // 33 characters, 66 bytes
363+ await submitOn(formOf(btn)).p;
364+ assert.match(status.textContent, /longer than 64 bytes/);
365+ assert.equal(fetches.length, 0, 'no request');
366+ assert.equal(navigator.created, undefined, 'create() is not called');
367+ assert.notEqual(btn.disabled, true, 'the button was never disabled');
368+ assert.deepEqual(dom.focused, [name], 'the field gets the focus');
369+ // The check is not a ceremony: the next try is not blocked.
370+ name.value = 'ok';
371+ await submitOn(formOf(btn)).p;
372+ assert.equal(fetches.length, 2);
373+});
374+
375+test('64 bytes is fine, in ASCII or in two-byte characters', async () => {
376+ for (const ok of ['a'.repeat(64), 'é'.repeat(32)]) {
377+ fetches = [];
378+ const btn = page('register'); // a success navigates away: each try is a new page
379+ name.value = ok;
380+ await submitOn(formOf(btn)).p;
381+ assert.equal(fetches.length, 2, ok.length + ' characters');
382+ P.reset();
383+ }
384+});
385+
386+test('control and direction characters stop before begin', async () => {
387+ for (const bad of ['a\u0000b', 'a\tb', 'a\nb', 'a\x7fb', 'a\u0085b', 'evil \u202e txt', 'a\u2066b', 'a\u200eb', 'a\u061cb', 'a\u2028b', 'a\u2029b']) {
388+ fetches = [];
389+ const btn = page('register');
390+ name.value = bad;
391+ await submitOn(formOf(btn)).p;
392+ assert.match(status.textContent, /control or direction character/, JSON.stringify(bad));
393+ assert.equal(fetches.length, 0, JSON.stringify(bad));
394+ }
395+});
396+
397+test('plain text with a zero width joiner, accents and emoji is fine', () => {
398+ for (const ok of ['Pixel 9', 'clé de ma mère', 'a\u200db', '🔑 key', '']) {
399+ assert.equal(P.nameProblem(ok), '', JSON.stringify(ok));
400+ }
401+});
402+
403+test('an empty name is allowed: the server names the passkey', async () => {
404+ const btn = page('register');
405+ name.value = ' ';
406+ await submitOn(formOf(btn)).p;
407+ assert.deepEqual(JSON.parse(fetches[0].init.body), { name: '' });
408+ assert.equal(fetches.length, 2);
409+});
410+
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+
166 test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => {420 test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => {
167 const login = page('login');421 const login = page('login');
168 const register = el('button', { attrs: { 'data-passkey': 'register' } });422 const register = el('button', { attrs: { 'data-passkey': 'register' } });
@@ -179,7 +433,7 @@ test('an old browser: the buttons are disabled with a reason, nothing is fetched
179 assert.equal(status.textContent, 'this browser does not support passkeys');433 assert.equal(status.textContent, 'this browser does not support passkeys');
180 });434 });
181 435
182-test('no PublicKeyCredential at all', async () => {436+test('no PublicKeyCredential at all', () => {
183 const btn = page('login');437 const btn = page('login');
184 globalThis.PublicKeyCredential = undefined;438 globalThis.PublicKeyCredential = undefined;
185 P.init();439 P.init();
@@ -201,15 +455,10 @@ test('a disabled button ignores clicks', async () => {
201 assert.equal(fetches.length, 0);455 assert.equal(fetches.length, 0);
202 });456 });
203 457
204-test('only login supports needs parseRequestOptionsFromJSON, register parseCreationOptionsFromJSON', async () => {458+test('register needs parseCreationOptionsFromJSON', async () => {
205 const btn = page('register');459 const btn = page('register');
206 delete PublicKeyCredential.parseCreationOptionsFromJSON;460 delete PublicKeyCredential.parseCreationOptionsFromJSON;
207- await clickOn(btn);461+ await submitOn(formOf(btn)).p;
208 assert.equal(fetches.length, 0);462 assert.equal(fetches.length, 0);
209 assert.equal(status.textContent, 'this browser does not support passkeys');463 assert.equal(status.textContent, 'this browser does not support passkeys');
210 });464 });
211-
212-test('the script registers one delegated click listener', () => {
213- assert.equal(dom.listened, true);
214- void settle;
215-});
M internal/adapters/web/pages.templ +4 -4
@@ -259,11 +259,11 @@ templ Passkeys(l Layout, p PasskeysPage) {
259259 if p.Full {
260260 <p class="dim">{ itoa(p.Max) } passkeys are registered, the most there can be. Remove one to add another.</p>
261261 } else {
262- <div class="passkey-add">
262+ <form class="passkey-add" data-js data-passkey-form>
263263 <label for="passkey-name" class="sr">Name of the new passkey</label>
264- <input id="passkey-name" type="text" maxlength="64" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/>
265- <button type="button" class="btn primary" data-js data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button>
266- </div>
264+ <input id="passkey-name" type="text" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/>
265+ <button type="submit" class="btn primary" data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button>
266+ </form>
267267 }
268268 <p class="dim" role="status" aria-live="polite" data-passkey-status></p>
269269 }
@@ -259,11 +259,11 @@ templ Passkeys(l Layout, p PasskeysPage) {
259 if p.Full {259 if p.Full {
260 <p class="dim">{ itoa(p.Max) } passkeys are registered, the most there can be. Remove one to add another.</p>260 <p class="dim">{ itoa(p.Max) } passkeys are registered, the most there can be. Remove one to add another.</p>
261 } else {261 } else {
262- <div class="passkey-add">262+ <form class="passkey-add" data-js data-passkey-form>
263 <label for="passkey-name" class="sr">Name of the new passkey</label>263 <label for="passkey-name" class="sr">Name of the new passkey</label>
264- <input id="passkey-name" type="text" maxlength="64" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/>264+ <input id="passkey-name" type="text" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/>
265- <button type="button" class="btn primary" data-js data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button>265+ <button type="submit" class="btn primary" data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button>
266- </div>266+ </form>
267 }267 }
268 <p class="dim" role="status" aria-live="polite" data-passkey-status></p>268 <p class="dim" role="status" aria-live="polite" data-passkey-status></p>
269 }269 }
M internal/adapters/web/pages_templ.go +4 -4 generated file: collapsed
M internal/adapters/web/passkeys_test.go +8 -0
@@ -364,6 +364,14 @@ func TestPasskeysPage(t *testing.T) {
364364 assert.Contains(t, body, "never")
365365 assert.Contains(t, body, `action="/-/passkeys/`+base64.RawURLEncoding.EncodeToString(list[0].ID)+`/remove"`)
366366 assert.Contains(t, body, `data-passkey="register"`)
367+ // TS-004: the name field and the add button are one form, so Enter submits.
368+ form := body[strings.Index(body, "<form class=\"passkey-add\""):]
369+ form = form[:strings.Index(form, "</form>")]
370+ assert.Contains(t, form, "data-passkey-form")
371+ assert.Contains(t, form, `data-passkey-name`)
372+ assert.Contains(t, form, `type="submit"`)
373+ assert.Contains(t, form, `data-passkey="register"`)
374+ assert.NotContains(t, form, "action=", "the script handles the submit; the form never posts by itself")
367375 assert.Contains(t, body, `data-begin="/-/passkeys/register/begin"`)
368376 assert.Contains(t, body, `data-finish="/-/passkeys/register/finish"`)
369377 assert.Contains(t, body, "data-passkey-status")
@@ -364,6 +364,14 @@ func TestPasskeysPage(t *testing.T) {
364 assert.Contains(t, body, "never")364 assert.Contains(t, body, "never")
365 assert.Contains(t, body, `action="/-/passkeys/`+base64.RawURLEncoding.EncodeToString(list[0].ID)+`/remove"`)365 assert.Contains(t, body, `action="/-/passkeys/`+base64.RawURLEncoding.EncodeToString(list[0].ID)+`/remove"`)
366 assert.Contains(t, body, `data-passkey="register"`)366 assert.Contains(t, body, `data-passkey="register"`)
367+ // TS-004: the name field and the add button are one form, so Enter submits.
368+ form := body[strings.Index(body, "<form class=\"passkey-add\""):]
369+ form = form[:strings.Index(form, "</form>")]
370+ assert.Contains(t, form, "data-passkey-form")
371+ assert.Contains(t, form, `data-passkey-name`)
372+ assert.Contains(t, form, `type="submit"`)
373+ assert.Contains(t, form, `data-passkey="register"`)
374+ assert.NotContains(t, form, "action=", "the script handles the submit; the form never posts by itself")
367 assert.Contains(t, body, `data-begin="/-/passkeys/register/begin"`)375 assert.Contains(t, body, `data-begin="/-/passkeys/register/begin"`)
368 assert.Contains(t, body, `data-finish="/-/passkeys/register/finish"`)376 assert.Contains(t, body, `data-finish="/-/passkeys/register/finish"`)
369 assert.Contains(t, body, "data-passkey-status")377 assert.Contains(t, body, "data-passkey-status")
M internal/adapters/web/static/js/passkey.js +95 -17
@@ -1,13 +1,19 @@
11 // passkey.js: sign in with a passkey (/-/login) and add one (/-/passkeys).
22 //
3-// One delegated click handler on [data-passkey="login"|"register"]. The button
4-// carries the two endpoints (data-begin, data-finish). The flow is the one of
5-// the WebAuthn JSON API: POST begin, hand options.publicKey to the browser
6-// (parse*OptionsFromJSON, then navigator.credentials.get|create), POST the
7-// result (toJSON()) to finish. Nothing is inline: the CSP only allows scripts
8-// from this origin. The core is tested in Node by jstest/passkey.test.mjs.
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
7+// browser (parse*OptionsFromJSON, then navigator.credentials.get|create), POST
8+// the result (toJSON()) to finish. Nothing is inline: the CSP only allows
9+// scripts from this origin. The core is tested in Node by
10+// jstest/passkey.test.mjs.
911 window.ermitePasskey = (() => {
1012 const UNSUPPORTED = 'this browser does not support passkeys';
13+ const UNEXPECTED = 'the server sent an unexpected answer';
14+ const SESSION_ENDED = 'your session ended or this page is out of date; reload and sign in again';
15+ const WAITING = 'waiting for your passkey…';
16+ const MAX_NAME_BYTES = 64;
1117
1218 // The parse function each ceremony needs; absent in a browser that predates
1319 // the WebAuthn JSON API.
@@ -20,6 +26,28 @@ window.ermitePasskey = (() => {
2026 const statusEl = () => document.querySelector('[data-passkey-status]');
2127 const say = (text) => { const s = statusEl(); if (s) s.textContent = text; };
2228
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.
34+ // 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.
36+ // The server stays the judge; this only saves the owner a prompt that would end
37+ // in a refusal.
38+ function nameProblem(name) {
39+ if (new TextEncoder().encode(name).length > MAX_NAME_BYTES) {
40+ return 'the name is longer than ' + MAX_NAME_BYTES + ' bytes';
41+ }
42+ // C0 and C1 controls (this includes tab and line breaks), the line and
43+ // paragraph separators, and the bidirectional marks (U+200E, U+200F, U+061C,
44+ // U+202A to U+202E, U+2066 to U+2069).
45+ if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/.test(name)) {
46+ return 'the name has a control or direction character';
47+ }
48+ return '';
49+ }
50+
2351 // messageOf turns what a step threw into one line for the owner.
2452 function messageOf(e) {
2553 if (e && e.name === 'NotAllowedError') return 'cancelled, or it took too long; try again';
@@ -29,35 +57,63 @@ window.ermitePasskey = (() => {
2957 }
3058
3159 // failure reads the error of a refused request: the server's one-line
32- // reason when it sent one, the status otherwise.
60+ // reason when it sent one, the status otherwise. A 403 without a reason of
61+ // its own is a session that ended, or a page older than the server's state.
3362 async function failure(res) {
3463 let reason = '';
3564 try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ }
65+ if (res.status === 403 && (!reason || reason === 'forbidden')) return new Error(SESSION_ENDED);
3666 return new Error(reason || 'the server answered ' + res.status);
3767 }
3868
69+ // begin posts to the begin endpoint and checks the shape of the answer.
70+ async function begin(btn, kind, name) {
71+ const init = { method: 'POST', credentials: 'same-origin' };
72+ if (kind === 'register') {
73+ init.headers = { 'Content-Type': 'application/json' };
74+ init.body = JSON.stringify({ name });
75+ }
76+ const res = await fetch(btn.dataset.begin, init);
77+ if (!res.ok) throw await failure(res);
78+ let answer;
79+ try { answer = await res.json(); } catch (_) { throw new Error(UNEXPECTED); }
80+ const ok = answer && typeof answer.ceremony === 'string' && answer.ceremony !== '' &&
81+ answer.options && typeof answer.options === 'object' &&
82+ answer.options.publicKey && typeof answer.options.publicKey === 'object';
83+ if (!ok) throw new Error(UNEXPECTED);
84+ return answer;
85+ }
86+
3987 async function run(btn) {
4088 const kind = btn.dataset.passkey;
41- if (!parser[kind]) return;
89+ if (!parser[kind] || busy) return;
4290 if (!supported(kind)) { say(UNSUPPORTED); return; }
91+
92+ const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null;
93+ const name = nameEl ? nameEl.value.trim() : '';
94+ if (nameEl) {
95+ const problem = nameProblem(name);
96+ if (problem) { say(problem); nameEl.focus(); return; }
97+ }
98+
99+ busy = true;
43100 btn.disabled = true;
44- say('');
101+ btn.setAttribute('aria-busy', 'true');
102+ say(WAITING);
45103 let done = false;
46104 try {
47- const begun = await fetch(btn.dataset.begin, { method: 'POST', credentials: 'same-origin' });
48- if (!begun.ok) throw await failure(begun);
49- const { ceremony, options } = await begun.json();
105+ const { ceremony, options } = await begin(btn, kind, name);
50106 const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey);
51107 const cred = kind === 'login'
52108 ? await navigator.credentials.get({ publicKey })
53109 : await navigator.credentials.create({ publicKey });
54110 if (!cred) throw new Error('no passkey was returned');
55- const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null;
111+ if (typeof cred.toJSON !== 'function') throw new Error('this browser returned a passkey this page cannot send; update the browser');
56112 const finished = await fetch(btn.dataset.finish, {
57113 method: 'POST',
58114 credentials: 'same-origin',
59115 headers: { 'Content-Type': 'application/json' },
60- body: JSON.stringify({ ceremony, name: nameEl ? nameEl.value.trim() : '', credential: cred.toJSON() }),
116+ body: JSON.stringify({ ceremony, name, credential: cred.toJSON() }),
61117 });
62118 if (!finished.ok) throw await failure(finished);
63119 done = true;
@@ -66,17 +122,35 @@ window.ermitePasskey = (() => {
66122 } catch (e) {
67123 say(messageOf(e));
68124 } finally {
69- if (!done) btn.disabled = false;
125+ if (!done) {
126+ busy = false;
127+ btn.removeAttribute('aria-busy');
128+ btn.disabled = false;
129+ // A disabled button cannot hold the focus: give it back once enabled.
130+ btn.focus();
131+ }
70132 }
71133 }
72134
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.
73137 function click(ev) {
74138 const btn = ev.target && ev.target.closest && ev.target.closest('[data-passkey]');
75- if (!btn || btn.disabled) return;
139+ if (!btn || btn.disabled || btn.closest('[data-passkey-form]')) return;
76140 if (ev.preventDefault) ev.preventDefault();
77141 return run(btn);
78142 }
79143
144+ // submit handles the add form, by a click on its button or Enter in the field.
145+ function submit(ev) {
146+ const form = ev.target && ev.target.closest && ev.target.closest('[data-passkey-form]');
147+ if (!form) return;
148+ if (ev.preventDefault) ev.preventDefault(); // never a real navigation
149+ const btn = form.querySelector('[data-passkey]');
150+ if (!btn || btn.disabled) return;
151+ return run(btn);
152+ }
153+
80154 // init disables the buttons of a browser that cannot do passkeys, and says why.
81155 function init() {
82156 let missing = false;
@@ -91,7 +165,11 @@ window.ermitePasskey = (() => {
91165
92166 if (typeof document.addEventListener === 'function') {
93167 document.addEventListener('click', click);
168+ document.addEventListener('submit', submit);
94169 init();
95170 }
96- return { click, init, run };
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 };
97175 })();
@@ -1,13 +1,19 @@
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 on [data-passkey="login"|"register"]. The button3+// One delegated click handler (the login button) and one delegated submit
4-// carries the two endpoints (data-begin, data-finish). The flow is the one of4+// handler (the "add a passkey" form, so that Enter in the name field works).
5-// the WebAuthn JSON API: POST begin, hand options.publicKey to the browser5+// The button carries the two endpoints (data-begin, data-finish). The flow is
6-// (parse*OptionsFromJSON, then navigator.credentials.get|create), POST the6+// the one of the WebAuthn JSON API: POST begin, hand options.publicKey to the
7-// result (toJSON()) to finish. Nothing is inline: the CSP only allows scripts7+// browser (parse*OptionsFromJSON, then navigator.credentials.get|create), POST
8-// from this origin. The core is tested in Node by jstest/passkey.test.mjs.8+// the result (toJSON()) to finish. Nothing is inline: the CSP only allows
9+// scripts from this origin. The core is tested in Node by
10+// jstest/passkey.test.mjs.
9 window.ermitePasskey = (() => {11 window.ermitePasskey = (() => {
10 const UNSUPPORTED = 'this browser does not support passkeys';12 const UNSUPPORTED = 'this browser does not support passkeys';
13+ const UNEXPECTED = 'the server sent an unexpected answer';
14+ const SESSION_ENDED = 'your session ended or this page is out of date; reload and sign in again';
15+ const WAITING = 'waiting for your passkey…';
16+ const MAX_NAME_BYTES = 64;
11 17
12 // The parse function each ceremony needs; absent in a browser that predates18 // The parse function each ceremony needs; absent in a browser that predates
13 // the WebAuthn JSON API.19 // the WebAuthn JSON API.
@@ -20,6 +26,28 @@ window.ermitePasskey = (() => {
20 const statusEl = () => document.querySelector('[data-passkey-status]');26 const statusEl = () => document.querySelector('[data-passkey-status]');
21 const say = (text) => { const s = statusEl(); if (s) s.textContent = text; };27 const say = (text) => { const s = statusEl(); if (s) s.textContent = text; };
22 28
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.
34+ // 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.
36+ // The server stays the judge; this only saves the owner a prompt that would end
37+ // in a refusal.
38+ function nameProblem(name) {
39+ if (new TextEncoder().encode(name).length > MAX_NAME_BYTES) {
40+ return 'the name is longer than ' + MAX_NAME_BYTES + ' bytes';
41+ }
42+ // C0 and C1 controls (this includes tab and line breaks), the line and
43+ // paragraph separators, and the bidirectional marks (U+200E, U+200F, U+061C,
44+ // U+202A to U+202E, U+2066 to U+2069).
45+ if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/.test(name)) {
46+ return 'the name has a control or direction character';
47+ }
48+ return '';
49+ }
50+
23 // messageOf turns what a step threw into one line for the owner.51 // messageOf turns what a step threw into one line for the owner.
24 function messageOf(e) {52 function messageOf(e) {
25 if (e && e.name === 'NotAllowedError') return 'cancelled, or it took too long; try again';53 if (e && e.name === 'NotAllowedError') return 'cancelled, or it took too long; try again';
@@ -29,35 +57,63 @@ window.ermitePasskey = (() => {
29 }57 }
30 58
31 // failure reads the error of a refused request: the server's one-line59 // failure reads the error of a refused request: the server's one-line
32- // reason when it sent one, the status otherwise.60+ // reason when it sent one, the status otherwise. A 403 without a reason of
61+ // its own is a session that ended, or a page older than the server's state.
33 async function failure(res) {62 async function failure(res) {
34 let reason = '';63 let reason = '';
35 try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ }64 try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ }
65+ if (res.status === 403 && (!reason || reason === 'forbidden')) return new Error(SESSION_ENDED);
36 return new Error(reason || 'the server answered ' + res.status);66 return new Error(reason || 'the server answered ' + res.status);
37 }67 }
38 68
69+ // begin posts to the begin endpoint and checks the shape of the answer.
70+ async function begin(btn, kind, name) {
71+ const init = { method: 'POST', credentials: 'same-origin' };
72+ if (kind === 'register') {
73+ init.headers = { 'Content-Type': 'application/json' };
74+ init.body = JSON.stringify({ name });
75+ }
76+ const res = await fetch(btn.dataset.begin, init);
77+ if (!res.ok) throw await failure(res);
78+ let answer;
79+ try { answer = await res.json(); } catch (_) { throw new Error(UNEXPECTED); }
80+ const ok = answer && typeof answer.ceremony === 'string' && answer.ceremony !== '' &&
81+ answer.options && typeof answer.options === 'object' &&
82+ answer.options.publicKey && typeof answer.options.publicKey === 'object';
83+ if (!ok) throw new Error(UNEXPECTED);
84+ return answer;
85+ }
86+
39 async function run(btn) {87 async function run(btn) {
40 const kind = btn.dataset.passkey;88 const kind = btn.dataset.passkey;
41- if (!parser[kind]) return;89+ if (!parser[kind] || busy) return;
42 if (!supported(kind)) { say(UNSUPPORTED); return; }90 if (!supported(kind)) { say(UNSUPPORTED); return; }
91+
92+ const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null;
93+ const name = nameEl ? nameEl.value.trim() : '';
94+ if (nameEl) {
95+ const problem = nameProblem(name);
96+ if (problem) { say(problem); nameEl.focus(); return; }
97+ }
98+
99+ busy = true;
43 btn.disabled = true;100 btn.disabled = true;
44- say('');101+ btn.setAttribute('aria-busy', 'true');
102+ say(WAITING);
45 let done = false;103 let done = false;
46 try {104 try {
47- const begun = await fetch(btn.dataset.begin, { method: 'POST', credentials: 'same-origin' });105+ const { ceremony, options } = await begin(btn, kind, name);
48- if (!begun.ok) throw await failure(begun);
49- const { ceremony, options } = await begun.json();
50 const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey);106 const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey);
51 const cred = kind === 'login'107 const cred = kind === 'login'
52 ? await navigator.credentials.get({ publicKey })108 ? await navigator.credentials.get({ publicKey })
53 : await navigator.credentials.create({ publicKey });109 : await navigator.credentials.create({ publicKey });
54 if (!cred) throw new Error('no passkey was returned');110 if (!cred) throw new Error('no passkey was returned');
55- const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null;111+ if (typeof cred.toJSON !== 'function') throw new Error('this browser returned a passkey this page cannot send; update the browser');
56 const finished = await fetch(btn.dataset.finish, {112 const finished = await fetch(btn.dataset.finish, {
57 method: 'POST',113 method: 'POST',
58 credentials: 'same-origin',114 credentials: 'same-origin',
59 headers: { 'Content-Type': 'application/json' },115 headers: { 'Content-Type': 'application/json' },
60- body: JSON.stringify({ ceremony, name: nameEl ? nameEl.value.trim() : '', credential: cred.toJSON() }),116+ body: JSON.stringify({ ceremony, name, credential: cred.toJSON() }),
61 });117 });
62 if (!finished.ok) throw await failure(finished);118 if (!finished.ok) throw await failure(finished);
63 done = true;119 done = true;
@@ -66,17 +122,35 @@ window.ermitePasskey = (() => {
66 } catch (e) {122 } catch (e) {
67 say(messageOf(e));123 say(messageOf(e));
68 } finally {124 } finally {
69- if (!done) btn.disabled = false;125+ if (!done) {
126+ busy = false;
127+ btn.removeAttribute('aria-busy');
128+ btn.disabled = false;
129+ // A disabled button cannot hold the focus: give it back once enabled.
130+ btn.focus();
131+ }
70 }132 }
71 }133 }
72 134
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.
73 function click(ev) {137 function click(ev) {
74 const btn = ev.target && ev.target.closest && ev.target.closest('[data-passkey]');138 const btn = ev.target && ev.target.closest && ev.target.closest('[data-passkey]');
75- if (!btn || btn.disabled) return;139+ if (!btn || btn.disabled || btn.closest('[data-passkey-form]')) return;
76 if (ev.preventDefault) ev.preventDefault();140 if (ev.preventDefault) ev.preventDefault();
77 return run(btn);141 return run(btn);
78 }142 }
79 143
144+ // submit handles the add form, by a click on its button or Enter in the field.
145+ function submit(ev) {
146+ const form = ev.target && ev.target.closest && ev.target.closest('[data-passkey-form]');
147+ if (!form) return;
148+ if (ev.preventDefault) ev.preventDefault(); // never a real navigation
149+ const btn = form.querySelector('[data-passkey]');
150+ if (!btn || btn.disabled) return;
151+ return run(btn);
152+ }
153+
80 // init disables the buttons of a browser that cannot do passkeys, and says why.154 // init disables the buttons of a browser that cannot do passkeys, and says why.
81 function init() {155 function init() {
82 let missing = false;156 let missing = false;
@@ -91,7 +165,11 @@ window.ermitePasskey = (() => {
91 165
92 if (typeof document.addEventListener === 'function') {166 if (typeof document.addEventListener === 'function') {
93 document.addEventListener('click', click);167 document.addEventListener('click', click);
168+ document.addEventListener('submit', submit);
94 init();169 init();
95 }170 }
96- return { click, init, run };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 };
97 })();175 })();

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