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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 @@ | |||
| 2 | 2 | // navigator.credentials stubbed. Run with | |
| 3 | 3 | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |
| 4 | 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 | 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 | 12 | const doc = installDom({ parseHTML: () => [] }); | |
| 9 | 13 | // The script registers its listeners when it loads: record them. | |
| 10 | 14 | const listeners = []; | |
| @@ -20,32 +24,34 @@ const OPTIONS = { publicKey: { challenge: 'abc' } }; | |||
| 20 | 24 | const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) }; | |
| 21 | 25 | ||
| 22 | 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 | 28 | let parsed; // [kind, publicKey] | |
| 25 | 29 | let nav; // where the page was sent: 'assign:/' or 'reload' | |
| 26 | 30 | let status; | |
| 27 | 31 | let name; | |
| 28 | 32 | ||
| 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]. | |
| 31 | 36 | function page(kind) { | |
| 32 | 37 | dom.body = el('body'); | |
| 33 | 38 | dom.focused = []; | |
| 34 | 39 | status = el('p', { attrs: { 'data-passkey-status': '' } }); | |
| 35 | 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' } }); | |
| 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; | |
| 44 | 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 | 54 | beforeEach(() => { | |
| 48 | - | P.reset(); | |
| 49 | 55 | fetches = []; | |
| 50 | 56 | parsed = []; | |
| 51 | 57 | nav = ''; | |
| @@ -78,32 +84,36 @@ beforeEach(() => { | |||
| 78 | 84 | globalThis.location = { assign: (u) => { nav = 'assign:' + u; }, reload: () => { nav = 'reload'; } }; | |
| 79 | 85 | }); | |
| 80 | 86 | ||
| 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). | |
| 85 | 90 | const submitOn = (target) => { | |
| 86 | 91 | let prevented = false; | |
| 87 | 92 | const p = P.submit({ target, preventDefault() { prevented = true; } }); | |
| 88 | 93 | return { p, prevented: () => prevented }; | |
| 89 | 94 | }; | |
| 90 | - | const formOf = (btn) => btn.form; | |
| 95 | + | const submitForm = (btn) => submitOn(btn.form).p; | |
| 91 | 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', () => { | |
| 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'); | |
| 97 | 104 | }); | |
| 98 | 105 | ||
| 99 | 106 | test('login: begin, get, finish with toJSON(), then home', async () => { | |
| 100 | 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 | 112 | assert.equal(fetches.length, 2); | |
| 104 | 113 | assert.equal(fetches[0].url, '/begin'); | |
| 105 | 114 | assert.equal(fetches[0].init.method, 'POST'); | |
| 106 | 115 | assert.equal(fetches[0].init.credentials, 'same-origin'); | |
| 116 | + | assert.equal(fetches[0].init.body, undefined, 'a sign-in sends no name'); | |
| 107 | 117 | assert.deepEqual(parsed, [['get', OPTIONS.publicKey]]); | |
| 108 | 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 | 121 | assert.equal(fetches[1].init.method, 'POST'); | |
| 112 | 122 | assert.equal(fetches[1].init.credentials, 'same-origin'); | |
| 113 | 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 | 125 | assert.equal(nav, 'assign:/'); | |
| 116 | 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 | 130 | const btn = page('register'); | |
| 121 | - | const { p, prevented } = submitOn(formOf(btn)); | |
| 131 | + | const { p, prevented } = submitOn(btn.form); | |
| 122 | 132 | await p; | |
| 123 | 133 | ||
| 124 | - | assert.equal(prevented(), true, 'the form never navigates by itself'); | |
| 134 | + | assert.equal(prevented(), true); | |
| 125 | 135 | assert.equal(fetches[0].url, '/begin'); | |
| 126 | 136 | assert.equal(fetches[0].init.credentials, 'same-origin'); | |
| 127 | 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 | 139 | assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]); | |
| 130 | 140 | assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } }); | |
| 131 | 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 | 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 () => { | |
| 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 () => { | |
| 151 | 147 | page('register'); | |
| 152 | 148 | const other = el('form', {}, el('input')); | |
| 153 | 149 | dom.body.append(other); | |
| @@ -157,67 +153,54 @@ test('a submit from another form is not ours', async () => { | |||
| 157 | 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 () => { | |
| 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 () => { | |
| 175 | 160 | const btn = page('login'); | |
| 176 | - | let release; | |
| 177 | - | navigator.credentials.get = () => new Promise((resolve) => { release = () => resolve(CRED); }); | |
| 161 | + | navigator.credentials.get = () => prompt(CRED); | |
| 178 | 162 | ||
| 179 | - | const first = clickOn(btn); | |
| 180 | - | await new Promise((r) => setTimeout(r, 0)); | |
| 163 | + | const first = submitForm(btn); | |
| 164 | + | await tick(); | |
| 181 | 165 | assert.equal(btn.getAttribute('aria-busy'), 'true'); | |
| 182 | 166 | assert.equal(btn.disabled, true); | |
| 183 | 167 | assert.equal(status.textContent, 'waiting for your passkey…'); | |
| 184 | 168 | ||
| 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'); | |
| 187 | 171 | ||
| 188 | - | release(); | |
| 172 | + | pending.forEach((release) => release()); | |
| 189 | 173 | await first; | |
| 190 | - | assert.equal(fetches.filter((f) => f.url === '/begin').length, 1); | |
| 174 | + | assert.equal(beginCalls(), 1); | |
| 191 | 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 | 179 | 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; | |
| 204 | 187 | assert.equal(nav, 'reload'); | |
| 205 | 188 | }); | |
| 206 | 189 | ||
| 207 | 190 | test('after a failure the page is free for another try', async () => { | |
| 208 | 191 | const btn = page('register'); | |
| 209 | 192 | answers['/finish'] = { ok: false, status: 400, body: { error: 'nope' } }; | |
| 210 | - | await submitOn(formOf(btn)).p; | |
| 193 | + | await submitForm(btn); | |
| 211 | 194 | assert.equal(status.textContent, 'nope'); | |
| 212 | 195 | 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'); | |
| 215 | 198 | }); | |
| 216 | 199 | ||
| 217 | 200 | test('on failure the button is enabled again, not busy, and has the focus', async () => { | |
| 218 | 201 | const btn = page('login'); | |
| 219 | 202 | answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } }; | |
| 220 | - | await clickOn(btn); | |
| 203 | + | await submitForm(btn); | |
| 221 | 204 | assert.equal(status.textContent, 'passkey not accepted'); | |
| 222 | 205 | assert.equal(btn.disabled, false); | |
| 223 | 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 | 211 | test('on success the button stays busy and disabled, without stealing focus', async () => { | |
| 229 | 212 | const btn = page('login'); | |
| 230 | - | await clickOn(btn); | |
| 213 | + | await submitForm(btn); | |
| 231 | 214 | assert.equal(btn.disabled, true); | |
| 232 | 215 | assert.equal(btn.getAttribute('aria-busy'), 'true'); | |
| 233 | 216 | assert.deepEqual(dom.focused, []); | |
| @@ -236,49 +219,82 @@ test('on success the button stays busy and disabled, without stealing focus', as | |||
| 236 | 219 | test('begin refused: the server reason is shown, nothing else is called', async () => { | |
| 237 | 220 | const btn = page('login'); | |
| 238 | 221 | answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } }; | |
| 239 | - | await clickOn(btn); | |
| 222 | + | await submitForm(btn); | |
| 240 | 223 | assert.equal(status.textContent, 'too many attempts'); | |
| 241 | 224 | assert.equal(fetches.length, 1); | |
| 242 | 225 | assert.equal(nav, ''); | |
| 243 | 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 | 262 | test('a refusal without a JSON body falls back to the status', async () => { | |
| 247 | 263 | const btn = page('login'); | |
| 248 | 264 | answers['/finish'] = { ok: false, status: 502, body: undefined }; | |
| 249 | - | await clickOn(btn); | |
| 265 | + | await submitForm(btn); | |
| 250 | 266 | assert.equal(status.textContent, 'the server answered 502'); | |
| 251 | 267 | assert.equal(nav, ''); | |
| 252 | 268 | }); | |
| 253 | 269 | ||
| 254 | 270 | test('a 403 without a reason says the session ended', async () => { | |
| 255 | 271 | for (const body of [undefined, { error: 'forbidden' }, {}]) { | |
| 272 | + | fetches = []; | |
| 256 | 273 | const btn = page('register'); | |
| 257 | 274 | answers['/begin'] = { ok: false, status: 403, body }; | |
| 258 | - | await submitOn(formOf(btn)).p; | |
| 275 | + | await submitForm(btn); | |
| 259 | 276 | assert.equal(status.textContent, 'your session ended or this page is out of date; reload and sign in again'); | |
| 260 | 277 | assert.equal(fetches.length, 1); | |
| 261 | - | fetches = []; | |
| 262 | 278 | } | |
| 263 | 279 | // At finish too. | |
| 264 | 280 | const btn = page('login'); | |
| 265 | 281 | answers['/begin'] = { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } }; | |
| 266 | 282 | answers['/finish'] = { ok: false, status: 403, body: undefined }; | |
| 267 | - | await clickOn(btn); | |
| 283 | + | await submitForm(btn); | |
| 268 | 284 | assert.match(status.textContent, /your session ended/); | |
| 269 | 285 | }); | |
| 270 | 286 | ||
| 271 | 287 | test('a 403 with a reason of its own shows that reason', async () => { | |
| 272 | 288 | const btn = page('register'); | |
| 273 | 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 | 291 | assert.equal(status.textContent, 'the owner must sign in first'); | |
| 276 | 292 | }); | |
| 277 | 293 | ||
| 278 | 294 | test('the owner cancels the prompt', async () => { | |
| 279 | 295 | const btn = page('login'); | |
| 280 | 296 | navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); }; | |
| 281 | - | await clickOn(btn); | |
| 297 | + | await submitForm(btn); | |
| 282 | 298 | assert.match(status.textContent, /cancelled, or it took too long/); | |
| 283 | 299 | assert.equal(fetches.length, 1, 'finish is not called'); | |
| 284 | 300 | assert.equal(btn.disabled, false); | |
| @@ -288,7 +304,7 @@ test('the owner cancels the prompt', async () => { | |||
| 288 | 304 | test('AbortError says cancelled', async () => { | |
| 289 | 305 | const btn = page('login'); | |
| 290 | 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 | 308 | assert.equal(status.textContent, 'cancelled'); | |
| 293 | 309 | assert.equal(fetches.length, 1); | |
| 294 | 310 | assert.equal(btn.disabled, false); | |
| @@ -297,7 +313,7 @@ test('AbortError says cancelled', async () => { | |||
| 297 | 313 | test('an authenticator that already holds a passkey', async () => { | |
| 298 | 314 | const btn = page('register'); | |
| 299 | 315 | navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); }; | |
| 300 | - | await submitOn(formOf(btn)).p; | |
| 316 | + | await submitForm(btn); | |
| 301 | 317 | assert.match(status.textContent, /already holds a passkey/); | |
| 302 | 318 | assert.equal(fetches.length, 1); | |
| 303 | 319 | }); | |
| @@ -305,7 +321,7 @@ test('an authenticator that already holds a passkey', async () => { | |||
| 305 | 321 | test('a network failure is reported', async () => { | |
| 306 | 322 | const btn = page('login'); | |
| 307 | 323 | globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); }; | |
| 308 | - | await clickOn(btn); | |
| 324 | + | await submitForm(btn); | |
| 309 | 325 | assert.equal(status.textContent, 'Failed to fetch'); | |
| 310 | 326 | assert.equal(btn.disabled, false); | |
| 311 | 327 | }); | |
| @@ -313,7 +329,7 @@ test('a network failure is reported', async () => { | |||
| 313 | 329 | test('a null credential is not sent', async () => { | |
| 314 | 330 | const btn = page('login'); | |
| 315 | 331 | navigator.credentials.get = async () => null; | |
| 316 | - | await clickOn(btn); | |
| 332 | + | await submitForm(btn); | |
| 317 | 333 | assert.equal(status.textContent, 'no passkey was returned'); | |
| 318 | 334 | assert.equal(fetches.length, 1); | |
| 319 | 335 | }); | |
| @@ -325,7 +341,7 @@ test('a credential without toJSON is reported, not sent', async () => { | |||
| 325 | 341 | const bare = { id: 'x' }; | |
| 326 | 342 | navigator.credentials.get = async () => bare; | |
| 327 | 343 | navigator.credentials.create = async () => bare; | |
| 328 | - | if (kind === 'login') await clickOn(btn); else await submitOn(formOf(btn)).p; | |
| 344 | + | await submitForm(btn); | |
| 329 | 345 | assert.match(status.textContent, /cannot send; update the browser/); | |
| 330 | 346 | assert.equal(fetches.length, 1, 'finish is not called'); | |
| 331 | 347 | assert.equal(btn.disabled, false); | |
| @@ -336,7 +352,7 @@ test('a credential without toJSON is reported, not sent', async () => { | |||
| 336 | 352 | test('a begin answer that is not JSON', async () => { | |
| 337 | 353 | const btn = page('login'); | |
| 338 | 354 | answers['/begin'] = { ok: true, status: 200, body: undefined }; // a 200 whose body is not JSON | |
| 339 | - | await clickOn(btn); | |
| 355 | + | await submitForm(btn); | |
| 340 | 356 | assert.equal(status.textContent, 'the server sent an unexpected answer'); | |
| 341 | 357 | assert.equal(parsed.length, 0); | |
| 342 | 358 | assert.equal(btn.disabled, false); | |
| @@ -349,7 +365,7 @@ test('a begin answer with the wrong shape', async () => { | |||
| 349 | 365 | parsed = []; | |
| 350 | 366 | const btn = page('login'); | |
| 351 | 367 | answers['/begin'] = { ok: true, status: 200, body }; | |
| 352 | - | await clickOn(btn); | |
| 368 | + | await submitForm(btn); | |
| 353 | 369 | assert.equal(status.textContent, 'the server sent an unexpected answer', JSON.stringify(body)); | |
| 354 | 370 | assert.equal(parsed.length, 0, 'nothing reached the browser API'); | |
| 355 | 371 | assert.equal(fetches.length, 1); | |
| @@ -360,7 +376,7 @@ test('a begin answer with the wrong shape', async () => { | |||
| 360 | 376 | test('a name over 64 bytes (UTF-8) stops before begin', async () => { | |
| 361 | 377 | const btn = page('register'); | |
| 362 | 378 | name.value = 'é'.repeat(33); // 33 characters, 66 bytes | |
| 363 | - | await submitOn(formOf(btn)).p; | |
| 379 | + | await submitForm(btn); | |
| 364 | 380 | assert.match(status.textContent, /longer than 64 bytes/); | |
| 365 | 381 | assert.equal(fetches.length, 0, 'no request'); | |
| 366 | 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 | 384 | assert.deepEqual(dom.focused, [name], 'the field gets the focus'); | |
| 369 | 385 | // The check is not a ceremony: the next try is not blocked. | |
| 370 | 386 | name.value = 'ok'; | |
| 371 | - | await submitOn(formOf(btn)).p; | |
| 387 | + | await submitForm(btn); | |
| 372 | 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 | 393 | fetches = []; | |
| 378 | 394 | const btn = page('register'); // a success navigates away: each try is a new page | |
| 379 | 395 | name.value = ok; | |
| 380 | - | await submitOn(formOf(btn)).p; | |
| 396 | + | await submitForm(btn); | |
| 381 | 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 | 403 | fetches = []; | |
| 389 | 404 | const btn = page('register'); | |
| 390 | 405 | name.value = bad; | |
| 391 | - | await submitOn(formOf(btn)).p; | |
| 406 | + | await submitForm(btn); | |
| 392 | 407 | assert.match(status.textContent, /control or direction character/, JSON.stringify(bad)); | |
| 393 | 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 | 418 | test('an empty name is allowed: the server names the passkey', async () => { | |
| 404 | 419 | const btn = page('register'); | |
| 405 | 420 | name.value = ' '; | |
| 406 | - | await submitOn(formOf(btn)).p; | |
| 421 | + | await submitForm(btn); | |
| 407 | 422 | assert.deepEqual(JSON.parse(fetches[0].init.body), { name: '' }); | |
| 408 | 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 | 426 | test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => { | |
| 421 | 427 | const login = page('login'); | |
| 422 | 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 | 434 | assert.equal(status.textContent, 'this browser does not support passkeys'); | |
| 429 | 435 | ||
| 430 | 436 | login.disabled = false; | |
| 431 | - | await clickOn(login); | |
| 437 | + | await submitForm(login); | |
| 432 | 438 | assert.equal(fetches.length, 0); | |
| 433 | 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 | 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 | 458 | const btn = page('login'); | |
| 453 | 459 | btn.disabled = true; | |
| 454 | - | await clickOn(btn); | |
| 460 | + | await submitForm(btn); | |
| 455 | 461 | assert.equal(fetches.length, 0); | |
| 456 | 462 | }); | |
| 457 | 463 | ||
| 458 | 464 | test('register needs parseCreationOptionsFromJSON', async () => { | |
| 459 | 465 | const btn = page('register'); | |
| 460 | 466 | delete PublicKeyCredential.parseCreationOptionsFromJSON; | |
| 461 | - | await submitOn(formOf(btn)).p; | |
| 467 | + | await submitForm(btn); | |
| 462 | 468 | assert.equal(fetches.length, 0); | |
| 463 | 469 | assert.equal(status.textContent, 'this browser does not support passkeys'); | |
| 464 | 470 | }); | |
| @@ -2,9 +2,13 @@ | |||||
| 2 | // navigator.credentials stubbed. Run with | 2 | // 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 form | 33 | + | // 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 submit | 88 | + | // 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 navigation | 89 | + | // 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 click | 169 | + | 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 JSON | 354 | 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 bytes | 378 | 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 page | 394 | 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) { | |||
| 189 | 189 | @Shell(l, nil) { | |
| 190 | 190 | <section class="state" aria-labelledby="state-title"> | |
| 191 | 191 | <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> | |
| 195 | 195 | <p class="dim" role="status" aria-live="polite" data-passkey-status></p> | |
| 196 | 196 | for _, line := range p.Lines { | |
| 197 | 197 | <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
| @@ -1055,14 +1055,14 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component { | |||
| 1055 | 1055 | if templ_7745c5c3_Err != nil { | |
| 1056 | 1056 | return templ_7745c5c3_Err | |
| 1057 | 1057 | } | |
| 1058 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</h1><p class=\"state-act\"><button type=\"button\" class=\"btn primary\" data-js data-passkey=\"login\" data-begin=\"") | |
| 1058 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</h1><form class=\"state-act\" data-js data-passkey-form><button type=\"submit\" class=\"btn primary\" data-passkey=\"login\" data-begin=\"") | |
| 1059 | 1059 | if templ_7745c5c3_Err != nil { | |
| 1060 | 1060 | return templ_7745c5c3_Err | |
| 1061 | 1061 | } | |
| 1062 | 1062 | var templ_7745c5c3_Var53 string | |
| 1063 | 1063 | templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin) | |
| 1064 | 1064 | if templ_7745c5c3_Err != nil { | |
| 1065 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 95} | |
| 1065 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 87} | |
| 1066 | 1066 | } | |
| 1067 | 1067 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53) | |
| 1068 | 1068 | if templ_7745c5c3_Err != nil { | |
| @@ -1075,7 +1075,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component { | |||
| 1075 | 1075 | var templ_7745c5c3_Var54 string | |
| 1076 | 1076 | templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish) | |
| 1077 | 1077 | if templ_7745c5c3_Err != nil { | |
| 1078 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 120} | |
| 1078 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 112} | |
| 1079 | 1079 | } | |
| 1080 | 1080 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54) | |
| 1081 | 1081 | if templ_7745c5c3_Err != nil { | |
| @@ -1088,13 +1088,13 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component { | |||
| 1088 | 1088 | var templ_7745c5c3_Var55 string | |
| 1089 | 1089 | templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button) | |
| 1090 | 1090 | if templ_7745c5c3_Err != nil { | |
| 1091 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 133} | |
| 1091 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 125} | |
| 1092 | 1092 | } | |
| 1093 | 1093 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) | |
| 1094 | 1094 | if templ_7745c5c3_Err != nil { | |
| 1095 | 1095 | return templ_7745c5c3_Err | |
| 1096 | 1096 | } | |
| 1097 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</button></p><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>") | |
| 1097 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</button></form><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>") | |
| 1098 | 1098 | if templ_7745c5c3_Err != nil { | |
| 1099 | 1099 | return templ_7745c5c3_Err | |
| 1100 | 1100 | } | |
| @@ -1055,14 +1055,14 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component { | |||||
| 1055 | if templ_7745c5c3_Err != nil { | 1055 | if templ_7745c5c3_Err != nil { | ||
| 1056 | return templ_7745c5c3_Err | 1056 | return templ_7745c5c3_Err | ||
| 1057 | } | 1057 | } | ||
| 1058 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</h1><p class=\"state-act\"><button type=\"button\" class=\"btn primary\" data-js data-passkey=\"login\" data-begin=\"") | 1058 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</h1><form class=\"state-act\" data-js data-passkey-form><button type=\"submit\" class=\"btn primary\" data-passkey=\"login\" data-begin=\"") |
| 1059 | if templ_7745c5c3_Err != nil { | 1059 | if templ_7745c5c3_Err != nil { | ||
| 1060 | return templ_7745c5c3_Err | 1060 | return templ_7745c5c3_Err | ||
| 1061 | } | 1061 | } | ||
| 1062 | var templ_7745c5c3_Var53 string | 1062 | var templ_7745c5c3_Var53 string | ||
| 1063 | templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin) | 1063 | templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin) | ||
| 1064 | if templ_7745c5c3_Err != nil { | 1064 | if templ_7745c5c3_Err != nil { | ||
| 1065 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 95} | 1065 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 87} |
| 1066 | } | 1066 | } | ||
| 1067 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53) | 1067 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53) | ||
| 1068 | if templ_7745c5c3_Err != nil { | 1068 | if templ_7745c5c3_Err != nil { | ||
| @@ -1075,7 +1075,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component { | |||||
| 1075 | var templ_7745c5c3_Var54 string | 1075 | var templ_7745c5c3_Var54 string | ||
| 1076 | templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish) | 1076 | templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish) | ||
| 1077 | if templ_7745c5c3_Err != nil { | 1077 | if templ_7745c5c3_Err != nil { | ||
| 1078 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 120} | 1078 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 112} |
| 1079 | } | 1079 | } | ||
| 1080 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54) | 1080 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54) | ||
| 1081 | if templ_7745c5c3_Err != nil { | 1081 | if templ_7745c5c3_Err != nil { | ||
| @@ -1088,13 +1088,13 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component { | |||||
| 1088 | var templ_7745c5c3_Var55 string | 1088 | var templ_7745c5c3_Var55 string | ||
| 1089 | templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button) | 1089 | templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button) | ||
| 1090 | if templ_7745c5c3_Err != nil { | 1090 | if templ_7745c5c3_Err != nil { | ||
| 1091 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 133} | 1091 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 125} |
| 1092 | } | 1092 | } | ||
| 1093 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) | 1093 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) | ||
| 1094 | if templ_7745c5c3_Err != nil { | 1094 | if templ_7745c5c3_Err != nil { | ||
| 1095 | return templ_7745c5c3_Err | 1095 | return templ_7745c5c3_Err | ||
| 1096 | } | 1096 | } | ||
| 1097 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</button></p><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>") | 1097 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</button></form><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>") |
| 1098 | if templ_7745c5c3_Err != nil { | 1098 | if templ_7745c5c3_Err != nil { | ||
| 1099 | return templ_7745c5c3_Err | 1099 | return templ_7745c5c3_Err | ||
| 1100 | } | 1100 | } | ||
M internal/adapters/web/passkeys_test.go +11 -0
| @@ -461,6 +461,17 @@ func TestPasskeyLoginPage(t *testing.T) { | |||
| 461 | 461 | assert.Contains(t, body, "<title>sign in") | |
| 462 | 462 | assert.Contains(t, body, "Sign in with a passkey") | |
| 463 | 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 | 475 | assert.Contains(t, body, `data-begin="/-/passkeys/login/begin"`) | |
| 465 | 476 | assert.Contains(t, body, `data-finish="/-/passkeys/login/finish"`) | |
| 466 | 477 | 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 @@ | |||
| 1 | 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 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 | |
| 7 | 10 | // browser (parse*OptionsFromJSON, then navigator.credentials.get|create), POST | |
| 8 | 11 | // the result (toJSON()) to finish. Nothing is inline: the CSP only allows | |
| 9 | 12 | // scripts from this origin. The core is tested in Node by | |
| @@ -26,10 +29,6 @@ window.ermitePasskey = (() => { | |||
| 26 | 29 | const statusEl = () => document.querySelector('[data-passkey-status]'); | |
| 27 | 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 | 32 | // nameProblem says why a passkey name would be refused, or '' when it is fine. | |
| 34 | 33 | // It mirrors the server (domain.ParsePasskeyName): at most 64 bytes of UTF-8, | |
| 35 | 34 | // one line, no control character and no bidirectional mark, override or isolate. | |
| @@ -62,8 +61,10 @@ window.ermitePasskey = (() => { | |||
| 62 | 61 | async function failure(res) { | |
| 63 | 62 | let reason = ''; | |
| 64 | 63 | 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; | |
| 67 | 68 | } | |
| 68 | 69 | ||
| 69 | 70 | // begin posts to the begin endpoint and checks the shape of the answer. | |
| @@ -74,7 +75,11 @@ window.ermitePasskey = (() => { | |||
| 74 | 75 | init.body = JSON.stringify({ name }); | |
| 75 | 76 | } | |
| 76 | 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 | 83 | let answer; | |
| 79 | 84 | try { answer = await res.json(); } catch (_) { throw new Error(UNEXPECTED); } | |
| 80 | 85 | const ok = answer && typeof answer.ceremony === 'string' && answer.ceremony !== '' && | |
| @@ -86,7 +91,7 @@ window.ermitePasskey = (() => { | |||
| 86 | 91 | ||
| 87 | 92 | async function run(btn) { | |
| 88 | 93 | const kind = btn.dataset.passkey; | |
| 89 | - | if (!parser[kind] || busy) return; | |
| 94 | + | if (!parser[kind]) return; | |
| 90 | 95 | if (!supported(kind)) { say(UNSUPPORTED); return; } | |
| 91 | 96 | ||
| 92 | 97 | const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null; | |
| @@ -96,11 +101,11 @@ window.ermitePasskey = (() => { | |||
| 96 | 101 | if (problem) { say(problem); nameEl.focus(); return; } | |
| 97 | 102 | } | |
| 98 | 103 | ||
| 99 | - | busy = true; | |
| 100 | 104 | btn.disabled = true; | |
| 101 | 105 | btn.setAttribute('aria-busy', 'true'); | |
| 102 | 106 | say(WAITING); | |
| 103 | 107 | let done = false; | |
| 108 | + | let failed = null; | |
| 104 | 109 | try { | |
| 105 | 110 | const { ceremony, options } = await begin(btn, kind, name); | |
| 106 | 111 | const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey); | |
| @@ -113,7 +118,7 @@ window.ermitePasskey = (() => { | |||
| 113 | 118 | method: 'POST', | |
| 114 | 119 | credentials: 'same-origin', | |
| 115 | 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 | 123 | if (!finished.ok) throw await failure(finished); | |
| 119 | 124 | done = true; | |
| @@ -121,30 +126,23 @@ window.ermitePasskey = (() => { | |||
| 121 | 126 | else location.reload(); | |
| 122 | 127 | } catch (e) { | |
| 123 | 128 | say(messageOf(e)); | |
| 129 | + | failed = e; | |
| 124 | 130 | } finally { | |
| 125 | 131 | if (!done) { | |
| 126 | - | busy = false; | |
| 127 | 132 | btn.removeAttribute('aria-busy'); | |
| 128 | 133 | 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(); | |
| 131 | 138 | } | |
| 132 | 139 | } | |
| 133 | 140 | } | |
| 134 | 141 | ||
| 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. | |
| 145 | 143 | 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; | |
| 148 | 146 | if (ev.preventDefault) ev.preventDefault(); // never a real navigation | |
| 149 | 147 | const btn = form.querySelector('[data-passkey]'); | |
| 150 | 148 | if (!btn || btn.disabled) return; | |
| @@ -164,12 +162,8 @@ window.ermitePasskey = (() => { | |||
| 164 | 162 | } | |
| 165 | 163 | ||
| 166 | 164 | if (typeof document.addEventListener === 'function') { | |
| 167 | - | document.addEventListener('click', click); | |
| 168 | 165 | document.addEventListener('submit', submit); | |
| 169 | 166 | init(); | |
| 170 | 167 | } | |
| 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 }; | |
| 175 | 169 | })(); | |
| @@ -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 submit | 3 | + | // 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 is | 5 | + | // 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 the | 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 | |||
| 7 | // browser (parse*OptionsFromJSON, then navigator.credentials.get|create), POST | 10 | // browser (parse*OptionsFromJSON, then navigator.credentials.get|create), POST | ||
| 8 | // the result (toJSON()) to finish. Nothing is inline: the CSP only allows | 11 | // the result (toJSON()) to finish. Nothing is inline: the CSP only allows | ||
| 9 | // scripts from this origin. The core is tested in Node by | 12 | // 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 a | 142 | + | // 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 navigation | 146 | 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 success | 168 | + | 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 | })(); | ||