fix(web): harden the passkey script and put the add button in a form
- check the name (64 UTF-8 bytes, no control or direction character) before begin, and send it to register/begin - show a busy state while the authenticator is prompted, give the focus back after a failure or a cancel, and run one ceremony at a time - add the name field and the button in a form, so Enter submits - check the shape of the begin answer and that the credential has toJSON - say that the session ended on a 403 that carries no reason
- commit
- f867fa3e1ed568c29e1867cadf98ca12b59e9f1e
- author
- brice · · 2026-10-12 15:49 UTC
- parent
- 6125ca9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +394 -57
M internal/adapters/web/jstest/dom.mjs +3 -1
| @@ -143,10 +143,11 @@ export class El { | |||
| 143 | 143 | return null; | |
| 144 | 144 | } | |
| 145 | 145 | scrollIntoView() {} | |
| 146 | + | focus() { dom.focused.push(this); } | |
| 146 | 147 | click() { dom.clicked.push(this); } | |
| 147 | 148 | } | |
| 148 | 149 | ||
| 149 | - | export const dom = { clicked: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 }; | |
| 150 | + | export const dom = { clicked: [], focused: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 }; | |
| 150 | 151 | ||
| 151 | 152 | // flushFrames runs the frames held since the last flush. | |
| 152 | 153 | export function flushFrames() { | |
| @@ -160,6 +161,7 @@ export function flushFrames() { | |||
| 160 | 161 | export function installDom({ parseHTML } = {}) { | |
| 161 | 162 | dom.body = new El('body'); | |
| 162 | 163 | dom.clicked = []; | |
| 164 | + | dom.focused = []; | |
| 163 | 165 | const doc = { | |
| 164 | 166 | body: dom.body, | |
| 165 | 167 | createElement(tag) { | |
| @@ -143,10 +143,11 @@ export class El { | |||||
| 143 | return null; | 143 | return null; | ||
| 144 | } | 144 | } | ||
| 145 | scrollIntoView() {} | 145 | scrollIntoView() {} | ||
| 146 | + | focus() { dom.focused.push(this); } | |||
| 146 | click() { dom.clicked.push(this); } | 147 | click() { dom.clicked.push(this); } | ||
| 147 | } | 148 | } | ||
| 148 | 149 | ||||
| 149 | - | export const dom = { clicked: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 }; | 150 | + | export const dom = { clicked: [], focused: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 }; |
| 150 | 151 | ||||
| 151 | // flushFrames runs the frames held since the last flush. | 152 | // flushFrames runs the frames held since the last flush. | ||
| 152 | export function flushFrames() { | 153 | export function flushFrames() { | ||
| @@ -160,6 +161,7 @@ export function flushFrames() { | |||||
| 160 | export function installDom({ parseHTML } = {}) { | 161 | export function installDom({ parseHTML } = {}) { | ||
| 161 | dom.body = new El('body'); | 162 | dom.body = new El('body'); | ||
| 162 | dom.clicked = []; | 163 | dom.clicked = []; | ||
| 164 | + | dom.focused = []; | |||
| 163 | const doc = { | 165 | const doc = { | ||
| 164 | body: dom.body, | 166 | body: dom.body, | ||
| 165 | createElement(tag) { | 167 | createElement(tag) { | ||
M internal/adapters/web/jstest/passkey.test.mjs +280 -31
| @@ -3,10 +3,12 @@ | |||
| 3 | 3 | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |
| 4 | 4 | import assert from 'node:assert/strict'; | |
| 5 | 5 | import { test, beforeEach } from 'node:test'; | |
| 6 | - | import { dom, el, installDom, settle } from './dom.mjs'; | |
| 6 | + | import { dom, el, installDom } from './dom.mjs'; | |
| 7 | 7 | ||
| 8 | 8 | const doc = installDom({ parseHTML: () => [] }); | |
| 9 | - | doc.addEventListener = () => { dom.listened = true; }; | |
| 9 | + | // The script registers its listeners when it loads: record them. | |
| 10 | + | const listeners = []; | |
| 11 | + | doc.addEventListener = (type, fn) => { listeners.push([type, fn]); }; | |
| 10 | 12 | // Node defines navigator as a getter: replace it, as a browser test would. | |
| 11 | 13 | const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true }); | |
| 12 | 14 | setNavigator({ credentials: {} }); | |
| @@ -18,33 +20,50 @@ const OPTIONS = { publicKey: { challenge: 'abc' } }; | |||
| 18 | 20 | const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) }; | |
| 19 | 21 | ||
| 20 | 22 | let fetches; // {url, init} | |
| 21 | - | let answers; // url -> {ok, status, body} | |
| 23 | + | let answers; // url -> {ok, status, body} (body: object, or a function that throws, or undefined for no JSON) | |
| 22 | 24 | let parsed; // [kind, publicKey] | |
| 23 | 25 | let nav; // where the page was sent: 'assign:/' or 'reload' | |
| 24 | 26 | let status; | |
| 25 | 27 | let name; | |
| 26 | 28 | ||
| 29 | + | // page builds the DOM of the login page (a button) or the passkeys page (a form | |
| 30 | + | // with the name field and a submit button). | |
| 27 | 31 | function page(kind) { | |
| 28 | 32 | dom.body = el('body'); | |
| 33 | + | dom.focused = []; | |
| 29 | 34 | status = el('p', { attrs: { 'data-passkey-status': '' } }); | |
| 30 | 35 | name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' }); | |
| 31 | 36 | const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish' } }); | |
| 32 | - | dom.body.append(status, name, btn); | |
| 37 | + | if (kind === 'register') { | |
| 38 | + | const form = el('form', { attrs: { 'data-passkey-form': '' } }, name, btn); | |
| 39 | + | dom.body.append(status, form); | |
| 40 | + | btn.form = form; | |
| 41 | + | } else { | |
| 42 | + | dom.body.append(status, btn); | |
| 43 | + | } | |
| 33 | 44 | return btn; | |
| 34 | 45 | } | |
| 35 | 46 | ||
| 36 | 47 | beforeEach(() => { | |
| 48 | + | P.reset(); | |
| 37 | 49 | fetches = []; | |
| 38 | 50 | parsed = []; | |
| 39 | 51 | nav = ''; | |
| 40 | 52 | answers = { | |
| 41 | 53 | '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } }, | |
| 42 | - | '/finish': { ok: true, status: 204, body: null }, | |
| 54 | + | '/finish': { ok: true, status: 204, body: undefined }, | |
| 43 | 55 | }; | |
| 44 | 56 | globalThis.fetch = async (url, init) => { | |
| 45 | 57 | fetches.push({ url, init }); | |
| 46 | 58 | const a = answers[url]; | |
| 47 | - | return { ok: a.ok, status: a.status, json: async () => { if (a.body === null) throw new Error('no body'); return a.body; } }; | |
| 59 | + | return { | |
| 60 | + | ok: a.ok, | |
| 61 | + | status: a.status, | |
| 62 | + | json: async () => { | |
| 63 | + | if (a.body === undefined) throw new SyntaxError('Unexpected end of JSON input'); | |
| 64 | + | return a.body; | |
| 65 | + | }, | |
| 66 | + | }; | |
| 48 | 67 | }; | |
| 49 | 68 | globalThis.PublicKeyCredential = { | |
| 50 | 69 | parseRequestOptionsFromJSON: (o) => { parsed.push(['get', o]); return { parsedGet: o }; }, | |
| @@ -60,6 +79,22 @@ beforeEach(() => { | |||
| 60 | 79 | }); | |
| 61 | 80 | ||
| 62 | 81 | const clickOn = (target) => P.click({ target, preventDefault() {} }); | |
| 82 | + | // submitOn sends a submit event from the form of btn (a click on the submit | |
| 83 | + | // button, or Enter in the field); it answers whether the page's own navigation | |
| 84 | + | // was kept (no preventDefault). | |
| 85 | + | const submitOn = (target) => { | |
| 86 | + | let prevented = false; | |
| 87 | + | const p = P.submit({ target, preventDefault() { prevented = true; } }); | |
| 88 | + | return { p, prevented: () => prevented }; | |
| 89 | + | }; | |
| 90 | + | const formOf = (btn) => btn.form; | |
| 91 | + | const jsonBody = (i) => JSON.parse(fetches[i].init.body); | |
| 92 | + | ||
| 93 | + | test('the script registers exactly one click and one submit listener', () => { | |
| 94 | + | assert.deepEqual(listeners.map(([t]) => t).sort(), ['click', 'submit']); | |
| 95 | + | assert.equal(listeners.find(([t]) => t === 'click')[1], P.click); | |
| 96 | + | assert.equal(listeners.find(([t]) => t === 'submit')[1], P.submit); | |
| 97 | + | }); | |
| 63 | 98 | ||
| 64 | 99 | test('login: begin, get, finish with toJSON(), then home', async () => { | |
| 65 | 100 | const btn = page('login'); | |
| @@ -74,24 +109,55 @@ test('login: begin, get, finish with toJSON(), then home', async () => { | |||
| 74 | 109 | ||
| 75 | 110 | assert.equal(fetches[1].url, '/finish'); | |
| 76 | 111 | assert.equal(fetches[1].init.method, 'POST'); | |
| 112 | + | assert.equal(fetches[1].init.credentials, 'same-origin'); | |
| 77 | 113 | assert.equal(fetches[1].init.headers['Content-Type'], 'application/json'); | |
| 78 | - | assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: '', credential: { id: 'cid', type: 'public-key' } }); | |
| 114 | + | assert.deepEqual(jsonBody(1), { ceremony: 'c1', name: '', credential: { id: 'cid', type: 'public-key' } }); | |
| 79 | 115 | assert.equal(nav, 'assign:/'); | |
| 80 | - | assert.equal(status.textContent, ''); | |
| 81 | 116 | assert.equal(btn.disabled, true, 'stays disabled while the page navigates'); | |
| 82 | 117 | }); | |
| 83 | 118 | ||
| 84 | - | test('register: create with the trimmed name, then reload', async () => { | |
| 119 | + | test('register: the trimmed name goes to begin and to finish, then reload', async () => { | |
| 85 | 120 | const btn = page('register'); | |
| 86 | - | await clickOn(btn); | |
| 121 | + | const { p, prevented } = submitOn(formOf(btn)); | |
| 122 | + | await p; | |
| 87 | 123 | ||
| 124 | + | assert.equal(prevented(), true, 'the form never navigates by itself'); | |
| 125 | + | assert.equal(fetches[0].url, '/begin'); | |
| 126 | + | assert.equal(fetches[0].init.credentials, 'same-origin'); | |
| 127 | + | assert.equal(fetches[0].init.headers['Content-Type'], 'application/json'); | |
| 128 | + | assert.deepEqual(JSON.parse(fetches[0].init.body), { name: 'Pixel 9' }); | |
| 88 | 129 | assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]); | |
| 89 | 130 | assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } }); | |
| 90 | - | assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: 'Pixel 9', credential: { id: 'cid', type: 'public-key' } }); | |
| 131 | + | assert.equal(fetches[1].init.credentials, 'same-origin'); | |
| 132 | + | assert.deepEqual(jsonBody(1), { ceremony: 'c1', name: 'Pixel 9', credential: { id: 'cid', type: 'public-key' } }); | |
| 91 | 133 | assert.equal(nav, 'reload'); | |
| 92 | 134 | }); | |
| 93 | 135 | ||
| 94 | - | test('a click inside the button reaches it, other clicks do nothing', async () => { | |
| 136 | + | test('Enter in the name field submits: the event comes from the form or the field', async () => { | |
| 137 | + | const btn = page('register'); | |
| 138 | + | await submitOn(name).p; // an event whose target is inside the form | |
| 139 | + | assert.equal(fetches.length, 2); | |
| 140 | + | assert.equal(nav, 'reload'); | |
| 141 | + | void btn; | |
| 142 | + | }); | |
| 143 | + | ||
| 144 | + | test('the add button is not a click target: only the submit runs it', async () => { | |
| 145 | + | const btn = page('register'); | |
| 146 | + | await clickOn(btn); | |
| 147 | + | assert.equal(fetches.length, 0, 'the click becomes a submit in a browser'); | |
| 148 | + | }); | |
| 149 | + | ||
| 150 | + | test('a submit from another form is not ours', async () => { | |
| 151 | + | page('register'); | |
| 152 | + | const other = el('form', {}, el('input')); | |
| 153 | + | dom.body.append(other); | |
| 154 | + | const { p, prevented } = submitOn(other); | |
| 155 | + | await p; | |
| 156 | + | assert.equal(fetches.length, 0); | |
| 157 | + | assert.equal(prevented(), false, 'other forms submit as they always did'); | |
| 158 | + | }); | |
| 159 | + | ||
| 160 | + | test('a click inside the login button reaches it, other clicks do nothing', async () => { | |
| 95 | 161 | const btn = page('login'); | |
| 96 | 162 | const inner = el('span'); | |
| 97 | 163 | btn.append(inner); | |
| @@ -103,33 +169,112 @@ test('a click inside the button reaches it, other clicks do nothing', async () = | |||
| 103 | 169 | assert.equal(fetches.length, 0); | |
| 104 | 170 | }); | |
| 105 | 171 | ||
| 106 | - | test('begin refused: the server reason is shown, nothing else is called', async () => { | |
| 172 | + | // While the ceremony waits for the authenticator the button is busy, the status | |
| 173 | + | // says so, and a second click or Enter starts nothing. | |
| 174 | + | test('busy: aria-busy, the waiting text, and a double click makes one begin', async () => { | |
| 107 | 175 | const btn = page('login'); | |
| 108 | - | answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } }; | |
| 109 | - | await clickOn(btn); | |
| 110 | - | assert.equal(status.textContent, 'too many attempts'); | |
| 111 | - | assert.equal(fetches.length, 1); | |
| 112 | - | assert.equal(nav, ''); | |
| 113 | - | assert.equal(btn.disabled, false, 'the button is usable again'); | |
| 176 | + | let release; | |
| 177 | + | navigator.credentials.get = () => new Promise((resolve) => { release = () => resolve(CRED); }); | |
| 178 | + | ||
| 179 | + | const first = clickOn(btn); | |
| 180 | + | await new Promise((r) => setTimeout(r, 0)); | |
| 181 | + | assert.equal(btn.getAttribute('aria-busy'), 'true'); | |
| 182 | + | assert.equal(btn.disabled, true); | |
| 183 | + | assert.equal(status.textContent, 'waiting for your passkey…'); | |
| 184 | + | ||
| 185 | + | await clickOn(btn); // the double click | |
| 186 | + | assert.equal(fetches.filter((f) => f.url === '/begin').length, 1, 'one begin only'); | |
| 187 | + | ||
| 188 | + | release(); | |
| 189 | + | await first; | |
| 190 | + | assert.equal(fetches.filter((f) => f.url === '/begin').length, 1); | |
| 191 | + | assert.equal(nav, 'assign:/'); | |
| 192 | + | }); | |
| 193 | + | ||
| 194 | + | test('a double submit makes one begin', async () => { | |
| 195 | + | const btn = page('register'); | |
| 196 | + | let release; | |
| 197 | + | navigator.credentials.create = () => new Promise((resolve) => { release = () => resolve(CRED); }); | |
| 198 | + | const a = submitOn(formOf(btn)).p; | |
| 199 | + | await new Promise((r) => setTimeout(r, 0)); | |
| 200 | + | await submitOn(formOf(btn)).p; | |
| 201 | + | assert.equal(fetches.filter((f) => f.url === '/begin').length, 1); | |
| 202 | + | release(); | |
| 203 | + | await a; | |
| 204 | + | assert.equal(nav, 'reload'); | |
| 205 | + | }); | |
| 206 | + | ||
| 207 | + | test('after a failure the page is free for another try', async () => { | |
| 208 | + | const btn = page('register'); | |
| 209 | + | answers['/finish'] = { ok: false, status: 400, body: { error: 'nope' } }; | |
| 210 | + | await submitOn(formOf(btn)).p; | |
| 211 | + | assert.equal(status.textContent, 'nope'); | |
| 212 | + | assert.equal(btn.disabled, false); | |
| 213 | + | await submitOn(formOf(btn)).p; | |
| 214 | + | assert.equal(fetches.filter((f) => f.url === '/begin').length, 2, 'the second try ran'); | |
| 114 | 215 | }); | |
| 115 | 216 | ||
| 116 | - | test('finish refused: the reason is shown and the page stays', async () => { | |
| 217 | + | test('on failure the button is enabled again, not busy, and has the focus', async () => { | |
| 117 | 218 | const btn = page('login'); | |
| 118 | 219 | answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } }; | |
| 119 | 220 | await clickOn(btn); | |
| 120 | 221 | assert.equal(status.textContent, 'passkey not accepted'); | |
| 222 | + | assert.equal(btn.disabled, false); | |
| 223 | + | assert.equal(btn.hasAttribute('aria-busy'), false); | |
| 224 | + | assert.equal(nav, ''); | |
| 225 | + | assert.deepEqual(dom.focused, [btn], 'the focus is given back'); | |
| 226 | + | }); | |
| 227 | + | ||
| 228 | + | test('on success the button stays busy and disabled, without stealing focus', async () => { | |
| 229 | + | const btn = page('login'); | |
| 230 | + | await clickOn(btn); | |
| 231 | + | assert.equal(btn.disabled, true); | |
| 232 | + | assert.equal(btn.getAttribute('aria-busy'), 'true'); | |
| 233 | + | assert.deepEqual(dom.focused, []); | |
| 234 | + | }); | |
| 235 | + | ||
| 236 | + | test('begin refused: the server reason is shown, nothing else is called', async () => { | |
| 237 | + | const btn = page('login'); | |
| 238 | + | answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } }; | |
| 239 | + | await clickOn(btn); | |
| 240 | + | assert.equal(status.textContent, 'too many attempts'); | |
| 241 | + | assert.equal(fetches.length, 1); | |
| 121 | 242 | assert.equal(nav, ''); | |
| 122 | 243 | assert.equal(btn.disabled, false); | |
| 123 | 244 | }); | |
| 124 | 245 | ||
| 125 | 246 | test('a refusal without a JSON body falls back to the status', async () => { | |
| 126 | - | const btn = page('register'); | |
| 127 | - | answers['/finish'] = { ok: false, status: 502, body: null }; | |
| 247 | + | const btn = page('login'); | |
| 248 | + | answers['/finish'] = { ok: false, status: 502, body: undefined }; | |
| 128 | 249 | await clickOn(btn); | |
| 129 | 250 | assert.equal(status.textContent, 'the server answered 502'); | |
| 130 | 251 | assert.equal(nav, ''); | |
| 131 | 252 | }); | |
| 132 | 253 | ||
| 254 | + | test('a 403 without a reason says the session ended', async () => { | |
| 255 | + | for (const body of [undefined, { error: 'forbidden' }, {}]) { | |
| 256 | + | const btn = page('register'); | |
| 257 | + | answers['/begin'] = { ok: false, status: 403, body }; | |
| 258 | + | await submitOn(formOf(btn)).p; | |
| 259 | + | assert.equal(status.textContent, 'your session ended or this page is out of date; reload and sign in again'); | |
| 260 | + | assert.equal(fetches.length, 1); | |
| 261 | + | fetches = []; | |
| 262 | + | } | |
| 263 | + | // At finish too. | |
| 264 | + | const btn = page('login'); | |
| 265 | + | answers['/begin'] = { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } }; | |
| 266 | + | answers['/finish'] = { ok: false, status: 403, body: undefined }; | |
| 267 | + | await clickOn(btn); | |
| 268 | + | assert.match(status.textContent, /your session ended/); | |
| 269 | + | }); | |
| 270 | + | ||
| 271 | + | test('a 403 with a reason of its own shows that reason', async () => { | |
| 272 | + | const btn = page('register'); | |
| 273 | + | answers['/begin'] = { ok: false, status: 403, body: { error: 'the owner must sign in first' } }; | |
| 274 | + | await submitOn(formOf(btn)).p; | |
| 275 | + | assert.equal(status.textContent, 'the owner must sign in first'); | |
| 276 | + | }); | |
| 277 | + | ||
| 133 | 278 | test('the owner cancels the prompt', async () => { | |
| 134 | 279 | const btn = page('login'); | |
| 135 | 280 | navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); }; | |
| @@ -137,12 +282,22 @@ test('the owner cancels the prompt', async () => { | |||
| 137 | 282 | assert.match(status.textContent, /cancelled, or it took too long/); | |
| 138 | 283 | assert.equal(fetches.length, 1, 'finish is not called'); | |
| 139 | 284 | assert.equal(btn.disabled, false); | |
| 285 | + | assert.deepEqual(dom.focused, [btn]); | |
| 286 | + | }); | |
| 287 | + | ||
| 288 | + | test('AbortError says cancelled', async () => { | |
| 289 | + | const btn = page('login'); | |
| 290 | + | navigator.credentials.get = async () => { throw Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }); }; | |
| 291 | + | await clickOn(btn); | |
| 292 | + | assert.equal(status.textContent, 'cancelled'); | |
| 293 | + | assert.equal(fetches.length, 1); | |
| 294 | + | assert.equal(btn.disabled, false); | |
| 140 | 295 | }); | |
| 141 | 296 | ||
| 142 | 297 | test('an authenticator that already holds a passkey', async () => { | |
| 143 | 298 | const btn = page('register'); | |
| 144 | 299 | navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); }; | |
| 145 | - | await clickOn(btn); | |
| 300 | + | await submitOn(formOf(btn)).p; | |
| 146 | 301 | assert.match(status.textContent, /already holds a passkey/); | |
| 147 | 302 | assert.equal(fetches.length, 1); | |
| 148 | 303 | }); | |
| @@ -163,6 +318,105 @@ test('a null credential is not sent', async () => { | |||
| 163 | 318 | assert.equal(fetches.length, 1); | |
| 164 | 319 | }); | |
| 165 | 320 | ||
| 321 | + | test('a credential without toJSON is reported, not sent', async () => { | |
| 322 | + | for (const kind of ['login', 'register']) { | |
| 323 | + | fetches = []; | |
| 324 | + | const btn = page(kind); | |
| 325 | + | const bare = { id: 'x' }; | |
| 326 | + | navigator.credentials.get = async () => bare; | |
| 327 | + | navigator.credentials.create = async () => bare; | |
| 328 | + | if (kind === 'login') await clickOn(btn); else await submitOn(formOf(btn)).p; | |
| 329 | + | assert.match(status.textContent, /cannot send; update the browser/); | |
| 330 | + | assert.equal(fetches.length, 1, 'finish is not called'); | |
| 331 | + | assert.equal(btn.disabled, false); | |
| 332 | + | } | |
| 333 | + | }); | |
| 334 | + | ||
| 335 | + | // The begin answer is checked before it reaches the browser API. | |
| 336 | + | test('a begin answer that is not JSON', async () => { | |
| 337 | + | const btn = page('login'); | |
| 338 | + | answers['/begin'] = { ok: true, status: 200, body: undefined }; // a 200 whose body is not JSON | |
| 339 | + | await clickOn(btn); | |
| 340 | + | assert.equal(status.textContent, 'the server sent an unexpected answer'); | |
| 341 | + | assert.equal(parsed.length, 0); | |
| 342 | + | assert.equal(btn.disabled, false); | |
| 343 | + | }); | |
| 344 | + | ||
| 345 | + | test('a begin answer with the wrong shape', async () => { | |
| 346 | + | for (const body of [null, 'x', [], {}, { ceremony: 'c1' }, { ceremony: 'c1', options: {} }, { ceremony: 'c1', options: { publicKey: 'x' } }, | |
| 347 | + | { ceremony: '', options: OPTIONS }, { ceremony: 5, options: OPTIONS }, { options: OPTIONS }, { ceremony: 'c1', options: null }]) { | |
| 348 | + | fetches = []; | |
| 349 | + | parsed = []; | |
| 350 | + | const btn = page('login'); | |
| 351 | + | answers['/begin'] = { ok: true, status: 200, body }; | |
| 352 | + | await clickOn(btn); | |
| 353 | + | assert.equal(status.textContent, 'the server sent an unexpected answer', JSON.stringify(body)); | |
| 354 | + | assert.equal(parsed.length, 0, 'nothing reached the browser API'); | |
| 355 | + | assert.equal(fetches.length, 1); | |
| 356 | + | } | |
| 357 | + | }); | |
| 358 | + | ||
| 359 | + | // A name the server would refuse is caught before the owner is prompted. | |
| 360 | + | test('a name over 64 bytes (UTF-8) stops before begin', async () => { | |
| 361 | + | const btn = page('register'); | |
| 362 | + | name.value = 'é'.repeat(33); // 33 characters, 66 bytes | |
| 363 | + | await submitOn(formOf(btn)).p; | |
| 364 | + | assert.match(status.textContent, /longer than 64 bytes/); | |
| 365 | + | assert.equal(fetches.length, 0, 'no request'); | |
| 366 | + | assert.equal(navigator.created, undefined, 'create() is not called'); | |
| 367 | + | assert.notEqual(btn.disabled, true, 'the button was never disabled'); | |
| 368 | + | assert.deepEqual(dom.focused, [name], 'the field gets the focus'); | |
| 369 | + | // The check is not a ceremony: the next try is not blocked. | |
| 370 | + | name.value = 'ok'; | |
| 371 | + | await submitOn(formOf(btn)).p; | |
| 372 | + | assert.equal(fetches.length, 2); | |
| 373 | + | }); | |
| 374 | + | ||
| 375 | + | test('64 bytes is fine, in ASCII or in two-byte characters', async () => { | |
| 376 | + | for (const ok of ['a'.repeat(64), 'é'.repeat(32)]) { | |
| 377 | + | fetches = []; | |
| 378 | + | const btn = page('register'); // a success navigates away: each try is a new page | |
| 379 | + | name.value = ok; | |
| 380 | + | await submitOn(formOf(btn)).p; | |
| 381 | + | assert.equal(fetches.length, 2, ok.length + ' characters'); | |
| 382 | + | P.reset(); | |
| 383 | + | } | |
| 384 | + | }); | |
| 385 | + | ||
| 386 | + | test('control and direction characters stop before begin', async () => { | |
| 387 | + | for (const bad of ['a\u0000b', 'a\tb', 'a\nb', 'a\x7fb', 'a\u0085b', 'evil \u202e txt', 'a\u2066b', 'a\u200eb', 'a\u061cb', 'a\u2028b', 'a\u2029b']) { | |
| 388 | + | fetches = []; | |
| 389 | + | const btn = page('register'); | |
| 390 | + | name.value = bad; | |
| 391 | + | await submitOn(formOf(btn)).p; | |
| 392 | + | assert.match(status.textContent, /control or direction character/, JSON.stringify(bad)); | |
| 393 | + | assert.equal(fetches.length, 0, JSON.stringify(bad)); | |
| 394 | + | } | |
| 395 | + | }); | |
| 396 | + | ||
| 397 | + | test('plain text with a zero width joiner, accents and emoji is fine', () => { | |
| 398 | + | for (const ok of ['Pixel 9', 'clé de ma mère', 'a\u200db', '🔑 key', '']) { | |
| 399 | + | assert.equal(P.nameProblem(ok), '', JSON.stringify(ok)); | |
| 400 | + | } | |
| 401 | + | }); | |
| 402 | + | ||
| 403 | + | test('an empty name is allowed: the server names the passkey', async () => { | |
| 404 | + | const btn = page('register'); | |
| 405 | + | name.value = ' '; | |
| 406 | + | await submitOn(formOf(btn)).p; | |
| 407 | + | assert.deepEqual(JSON.parse(fetches[0].init.body), { name: '' }); | |
| 408 | + | assert.equal(fetches.length, 2); | |
| 409 | + | }); | |
| 410 | + | ||
| 411 | + | test('the server can still refuse the name at begin: its reason is shown', async () => { | |
| 412 | + | const btn = page('register'); | |
| 413 | + | answers['/begin'] = { ok: false, status: 400, body: { error: 'passkey name: text contains the control character U+0009' } }; | |
| 414 | + | await submitOn(formOf(btn)).p; | |
| 415 | + | assert.match(status.textContent, /control character/); | |
| 416 | + | assert.equal(navigator.created, undefined); | |
| 417 | + | assert.equal(btn.disabled, false); | |
| 418 | + | }); | |
| 419 | + | ||
| 166 | 420 | test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => { | |
| 167 | 421 | const login = page('login'); | |
| 168 | 422 | const register = el('button', { attrs: { 'data-passkey': 'register' } }); | |
| @@ -179,7 +433,7 @@ test('an old browser: the buttons are disabled with a reason, nothing is fetched | |||
| 179 | 433 | assert.equal(status.textContent, 'this browser does not support passkeys'); | |
| 180 | 434 | }); | |
| 181 | 435 | ||
| 182 | - | test('no PublicKeyCredential at all', async () => { | |
| 436 | + | test('no PublicKeyCredential at all', () => { | |
| 183 | 437 | const btn = page('login'); | |
| 184 | 438 | globalThis.PublicKeyCredential = undefined; | |
| 185 | 439 | P.init(); | |
| @@ -201,15 +455,10 @@ test('a disabled button ignores clicks', async () => { | |||
| 201 | 455 | assert.equal(fetches.length, 0); | |
| 202 | 456 | }); | |
| 203 | 457 | ||
| 204 | - | test('only login supports needs parseRequestOptionsFromJSON, register parseCreationOptionsFromJSON', async () => { | |
| 458 | + | test('register needs parseCreationOptionsFromJSON', async () => { | |
| 205 | 459 | const btn = page('register'); | |
| 206 | 460 | delete PublicKeyCredential.parseCreationOptionsFromJSON; | |
| 207 | - | await clickOn(btn); | |
| 461 | + | await submitOn(formOf(btn)).p; | |
| 208 | 462 | assert.equal(fetches.length, 0); | |
| 209 | 463 | assert.equal(status.textContent, 'this browser does not support passkeys'); | |
| 210 | 464 | }); | |
| 211 | - | ||
| 212 | - | test('the script registers one delegated click listener', () => { | |
| 213 | - | assert.equal(dom.listened, true); | |
| 214 | - | void settle; | |
| 215 | - | }); | |
| @@ -3,10 +3,12 @@ | |||||
| 3 | // node --test 'internal/adapters/web/jstest/*.test.mjs' | 3 | // node --test 'internal/adapters/web/jstest/*.test.mjs' | ||
| 4 | import assert from 'node:assert/strict'; | 4 | import assert from 'node:assert/strict'; | ||
| 5 | import { test, beforeEach } from 'node:test'; | 5 | import { test, beforeEach } from 'node:test'; | ||
| 6 | - | import { dom, el, installDom, settle } from './dom.mjs'; | 6 | + | import { dom, el, installDom } from './dom.mjs'; |
| 7 | 7 | ||||
| 8 | const doc = installDom({ parseHTML: () => [] }); | 8 | const doc = installDom({ parseHTML: () => [] }); | ||
| 9 | - | doc.addEventListener = () => { dom.listened = true; }; | 9 | + | // The script registers its listeners when it loads: record them. |
| 10 | + | const listeners = []; | |||
| 11 | + | doc.addEventListener = (type, fn) => { listeners.push([type, fn]); }; | |||
| 10 | // Node defines navigator as a getter: replace it, as a browser test would. | 12 | // Node defines navigator as a getter: replace it, as a browser test would. | ||
| 11 | const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true }); | 13 | const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true }); | ||
| 12 | setNavigator({ credentials: {} }); | 14 | setNavigator({ credentials: {} }); | ||
| @@ -18,33 +20,50 @@ const OPTIONS = { publicKey: { challenge: 'abc' } }; | |||||
| 18 | const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) }; | 20 | const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) }; | ||
| 19 | 21 | ||||
| 20 | let fetches; // {url, init} | 22 | let fetches; // {url, init} | ||
| 21 | - | let answers; // url -> {ok, status, body} | 23 | + | let answers; // url -> {ok, status, body} (body: object, or a function that throws, or undefined for no JSON) |
| 22 | let parsed; // [kind, publicKey] | 24 | let parsed; // [kind, publicKey] | ||
| 23 | let nav; // where the page was sent: 'assign:/' or 'reload' | 25 | let nav; // where the page was sent: 'assign:/' or 'reload' | ||
| 24 | let status; | 26 | let status; | ||
| 25 | let name; | 27 | let name; | ||
| 26 | 28 | ||||
| 29 | + | // page builds the DOM of the login page (a button) or the passkeys page (a form | |||
| 30 | + | // with the name field and a submit button). | |||
| 27 | function page(kind) { | 31 | function page(kind) { | ||
| 28 | dom.body = el('body'); | 32 | dom.body = el('body'); | ||
| 33 | + | dom.focused = []; | |||
| 29 | status = el('p', { attrs: { 'data-passkey-status': '' } }); | 34 | status = el('p', { attrs: { 'data-passkey-status': '' } }); | ||
| 30 | name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' }); | 35 | name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' }); | ||
| 31 | const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish' } }); | 36 | const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish' } }); | ||
| 32 | - | dom.body.append(status, name, btn); | 37 | + | if (kind === 'register') { |
| 38 | + | const form = el('form', { attrs: { 'data-passkey-form': '' } }, name, btn); | |||
| 39 | + | dom.body.append(status, form); | |||
| 40 | + | btn.form = form; | |||
| 41 | + | } else { | |||
| 42 | + | dom.body.append(status, btn); | |||
| 43 | + | } | |||
| 33 | return btn; | 44 | return btn; | ||
| 34 | } | 45 | } | ||
| 35 | 46 | ||||
| 36 | beforeEach(() => { | 47 | beforeEach(() => { | ||
| 48 | + | P.reset(); | |||
| 37 | fetches = []; | 49 | fetches = []; | ||
| 38 | parsed = []; | 50 | parsed = []; | ||
| 39 | nav = ''; | 51 | nav = ''; | ||
| 40 | answers = { | 52 | answers = { | ||
| 41 | '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } }, | 53 | '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } }, | ||
| 42 | - | '/finish': { ok: true, status: 204, body: null }, | 54 | + | '/finish': { ok: true, status: 204, body: undefined }, |
| 43 | }; | 55 | }; | ||
| 44 | globalThis.fetch = async (url, init) => { | 56 | globalThis.fetch = async (url, init) => { | ||
| 45 | fetches.push({ url, init }); | 57 | fetches.push({ url, init }); | ||
| 46 | const a = answers[url]; | 58 | const a = answers[url]; | ||
| 47 | - | return { ok: a.ok, status: a.status, json: async () => { if (a.body === null) throw new Error('no body'); return a.body; } }; | 59 | + | return { |
| 60 | + | ok: a.ok, | |||
| 61 | + | status: a.status, | |||
| 62 | + | json: async () => { | |||
| 63 | + | if (a.body === undefined) throw new SyntaxError('Unexpected end of JSON input'); | |||
| 64 | + | return a.body; | |||
| 65 | + | }, | |||
| 66 | + | }; | |||
| 48 | }; | 67 | }; | ||
| 49 | globalThis.PublicKeyCredential = { | 68 | globalThis.PublicKeyCredential = { | ||
| 50 | parseRequestOptionsFromJSON: (o) => { parsed.push(['get', o]); return { parsedGet: o }; }, | 69 | parseRequestOptionsFromJSON: (o) => { parsed.push(['get', o]); return { parsedGet: o }; }, | ||
| @@ -60,6 +79,22 @@ beforeEach(() => { | |||||
| 60 | }); | 79 | }); | ||
| 61 | 80 | ||||
| 62 | const clickOn = (target) => P.click({ target, preventDefault() {} }); | 81 | const clickOn = (target) => P.click({ target, preventDefault() {} }); | ||
| 82 | + | // submitOn sends a submit event from the form of btn (a click on the submit | |||
| 83 | + | // button, or Enter in the field); it answers whether the page's own navigation | |||
| 84 | + | // was kept (no preventDefault). | |||
| 85 | + | const submitOn = (target) => { | |||
| 86 | + | let prevented = false; | |||
| 87 | + | const p = P.submit({ target, preventDefault() { prevented = true; } }); | |||
| 88 | + | return { p, prevented: () => prevented }; | |||
| 89 | + | }; | |||
| 90 | + | const formOf = (btn) => btn.form; | |||
| 91 | + | const jsonBody = (i) => JSON.parse(fetches[i].init.body); | |||
| 92 | + | ||||
| 93 | + | test('the script registers exactly one click and one submit listener', () => { | |||
| 94 | + | assert.deepEqual(listeners.map(([t]) => t).sort(), ['click', 'submit']); | |||
| 95 | + | assert.equal(listeners.find(([t]) => t === 'click')[1], P.click); | |||
| 96 | + | assert.equal(listeners.find(([t]) => t === 'submit')[1], P.submit); | |||
| 97 | + | }); | |||
| 63 | 98 | ||||
| 64 | test('login: begin, get, finish with toJSON(), then home', async () => { | 99 | test('login: begin, get, finish with toJSON(), then home', async () => { | ||
| 65 | const btn = page('login'); | 100 | const btn = page('login'); | ||
| @@ -74,24 +109,55 @@ test('login: begin, get, finish with toJSON(), then home', async () => { | |||||
| 74 | 109 | ||||
| 75 | assert.equal(fetches[1].url, '/finish'); | 110 | assert.equal(fetches[1].url, '/finish'); | ||
| 76 | assert.equal(fetches[1].init.method, 'POST'); | 111 | assert.equal(fetches[1].init.method, 'POST'); | ||
| 112 | + | assert.equal(fetches[1].init.credentials, 'same-origin'); | |||
| 77 | assert.equal(fetches[1].init.headers['Content-Type'], 'application/json'); | 113 | assert.equal(fetches[1].init.headers['Content-Type'], 'application/json'); | ||
| 78 | - | assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: '', credential: { id: 'cid', type: 'public-key' } }); | 114 | + | assert.deepEqual(jsonBody(1), { ceremony: 'c1', name: '', credential: { id: 'cid', type: 'public-key' } }); |
| 79 | assert.equal(nav, 'assign:/'); | 115 | assert.equal(nav, 'assign:/'); | ||
| 80 | - | assert.equal(status.textContent, ''); | |||
| 81 | assert.equal(btn.disabled, true, 'stays disabled while the page navigates'); | 116 | assert.equal(btn.disabled, true, 'stays disabled while the page navigates'); | ||
| 82 | }); | 117 | }); | ||
| 83 | 118 | ||||
| 84 | - | test('register: create with the trimmed name, then reload', async () => { | 119 | + | test('register: the trimmed name goes to begin and to finish, then reload', async () => { |
| 85 | const btn = page('register'); | 120 | const btn = page('register'); | ||
| 86 | - | await clickOn(btn); | 121 | + | const { p, prevented } = submitOn(formOf(btn)); |
| 122 | + | await p; | |||
| 87 | 123 | ||||
| 124 | + | assert.equal(prevented(), true, 'the form never navigates by itself'); | |||
| 125 | + | assert.equal(fetches[0].url, '/begin'); | |||
| 126 | + | assert.equal(fetches[0].init.credentials, 'same-origin'); | |||
| 127 | + | assert.equal(fetches[0].init.headers['Content-Type'], 'application/json'); | |||
| 128 | + | assert.deepEqual(JSON.parse(fetches[0].init.body), { name: 'Pixel 9' }); | |||
| 88 | assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]); | 129 | assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]); | ||
| 89 | assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } }); | 130 | assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } }); | ||
| 90 | - | assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: 'Pixel 9', credential: { id: 'cid', type: 'public-key' } }); | 131 | + | assert.equal(fetches[1].init.credentials, 'same-origin'); |
| 132 | + | assert.deepEqual(jsonBody(1), { ceremony: 'c1', name: 'Pixel 9', credential: { id: 'cid', type: 'public-key' } }); | |||
| 91 | assert.equal(nav, 'reload'); | 133 | assert.equal(nav, 'reload'); | ||
| 92 | }); | 134 | }); | ||
| 93 | 135 | ||||
| 94 | - | test('a click inside the button reaches it, other clicks do nothing', async () => { | 136 | + | test('Enter in the name field submits: the event comes from the form or the field', async () => { |
| 137 | + | const btn = page('register'); | |||
| 138 | + | await submitOn(name).p; // an event whose target is inside the form | |||
| 139 | + | assert.equal(fetches.length, 2); | |||
| 140 | + | assert.equal(nav, 'reload'); | |||
| 141 | + | void btn; | |||
| 142 | + | }); | |||
| 143 | + | ||||
| 144 | + | test('the add button is not a click target: only the submit runs it', async () => { | |||
| 145 | + | const btn = page('register'); | |||
| 146 | + | await clickOn(btn); | |||
| 147 | + | assert.equal(fetches.length, 0, 'the click becomes a submit in a browser'); | |||
| 148 | + | }); | |||
| 149 | + | ||||
| 150 | + | test('a submit from another form is not ours', async () => { | |||
| 151 | + | page('register'); | |||
| 152 | + | const other = el('form', {}, el('input')); | |||
| 153 | + | dom.body.append(other); | |||
| 154 | + | const { p, prevented } = submitOn(other); | |||
| 155 | + | await p; | |||
| 156 | + | assert.equal(fetches.length, 0); | |||
| 157 | + | assert.equal(prevented(), false, 'other forms submit as they always did'); | |||
| 158 | + | }); | |||
| 159 | + | ||||
| 160 | + | test('a click inside the login button reaches it, other clicks do nothing', async () => { | |||
| 95 | const btn = page('login'); | 161 | const btn = page('login'); | ||
| 96 | const inner = el('span'); | 162 | const inner = el('span'); | ||
| 97 | btn.append(inner); | 163 | btn.append(inner); | ||
| @@ -103,33 +169,112 @@ test('a click inside the button reaches it, other clicks do nothing', async () = | |||||
| 103 | assert.equal(fetches.length, 0); | 169 | assert.equal(fetches.length, 0); | ||
| 104 | }); | 170 | }); | ||
| 105 | 171 | ||||
| 106 | - | test('begin refused: the server reason is shown, nothing else is called', async () => { | 172 | + | // While the ceremony waits for the authenticator the button is busy, the status |
| 173 | + | // says so, and a second click or Enter starts nothing. | |||
| 174 | + | test('busy: aria-busy, the waiting text, and a double click makes one begin', async () => { | |||
| 107 | const btn = page('login'); | 175 | const btn = page('login'); | ||
| 108 | - | answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } }; | 176 | + | let release; |
| 109 | - | await clickOn(btn); | 177 | + | navigator.credentials.get = () => new Promise((resolve) => { release = () => resolve(CRED); }); |
| 110 | - | assert.equal(status.textContent, 'too many attempts'); | 178 | + | |
| 111 | - | assert.equal(fetches.length, 1); | 179 | + | const first = clickOn(btn); |
| 112 | - | assert.equal(nav, ''); | 180 | + | await new Promise((r) => setTimeout(r, 0)); |
| 113 | - | assert.equal(btn.disabled, false, 'the button is usable again'); | 181 | + | assert.equal(btn.getAttribute('aria-busy'), 'true'); |
| 182 | + | assert.equal(btn.disabled, true); | |||
| 183 | + | assert.equal(status.textContent, 'waiting for your passkey…'); | |||
| 184 | + | ||||
| 185 | + | await clickOn(btn); // the double click | |||
| 186 | + | assert.equal(fetches.filter((f) => f.url === '/begin').length, 1, 'one begin only'); | |||
| 187 | + | ||||
| 188 | + | release(); | |||
| 189 | + | await first; | |||
| 190 | + | assert.equal(fetches.filter((f) => f.url === '/begin').length, 1); | |||
| 191 | + | assert.equal(nav, 'assign:/'); | |||
| 192 | + | }); | |||
| 193 | + | ||||
| 194 | + | test('a double submit makes one begin', async () => { | |||
| 195 | + | const btn = page('register'); | |||
| 196 | + | let release; | |||
| 197 | + | navigator.credentials.create = () => new Promise((resolve) => { release = () => resolve(CRED); }); | |||
| 198 | + | const a = submitOn(formOf(btn)).p; | |||
| 199 | + | await new Promise((r) => setTimeout(r, 0)); | |||
| 200 | + | await submitOn(formOf(btn)).p; | |||
| 201 | + | assert.equal(fetches.filter((f) => f.url === '/begin').length, 1); | |||
| 202 | + | release(); | |||
| 203 | + | await a; | |||
| 204 | + | assert.equal(nav, 'reload'); | |||
| 205 | + | }); | |||
| 206 | + | ||||
| 207 | + | test('after a failure the page is free for another try', async () => { | |||
| 208 | + | const btn = page('register'); | |||
| 209 | + | answers['/finish'] = { ok: false, status: 400, body: { error: 'nope' } }; | |||
| 210 | + | await submitOn(formOf(btn)).p; | |||
| 211 | + | assert.equal(status.textContent, 'nope'); | |||
| 212 | + | assert.equal(btn.disabled, false); | |||
| 213 | + | await submitOn(formOf(btn)).p; | |||
| 214 | + | assert.equal(fetches.filter((f) => f.url === '/begin').length, 2, 'the second try ran'); | |||
| 114 | }); | 215 | }); | ||
| 115 | 216 | ||||
| 116 | - | test('finish refused: the reason is shown and the page stays', async () => { | 217 | + | test('on failure the button is enabled again, not busy, and has the focus', async () => { |
| 117 | const btn = page('login'); | 218 | const btn = page('login'); | ||
| 118 | answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } }; | 219 | answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } }; | ||
| 119 | await clickOn(btn); | 220 | await clickOn(btn); | ||
| 120 | assert.equal(status.textContent, 'passkey not accepted'); | 221 | assert.equal(status.textContent, 'passkey not accepted'); | ||
| 222 | + | assert.equal(btn.disabled, false); | |||
| 223 | + | assert.equal(btn.hasAttribute('aria-busy'), false); | |||
| 224 | + | assert.equal(nav, ''); | |||
| 225 | + | assert.deepEqual(dom.focused, [btn], 'the focus is given back'); | |||
| 226 | + | }); | |||
| 227 | + | ||||
| 228 | + | test('on success the button stays busy and disabled, without stealing focus', async () => { | |||
| 229 | + | const btn = page('login'); | |||
| 230 | + | await clickOn(btn); | |||
| 231 | + | assert.equal(btn.disabled, true); | |||
| 232 | + | assert.equal(btn.getAttribute('aria-busy'), 'true'); | |||
| 233 | + | assert.deepEqual(dom.focused, []); | |||
| 234 | + | }); | |||
| 235 | + | ||||
| 236 | + | test('begin refused: the server reason is shown, nothing else is called', async () => { | |||
| 237 | + | const btn = page('login'); | |||
| 238 | + | answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } }; | |||
| 239 | + | await clickOn(btn); | |||
| 240 | + | assert.equal(status.textContent, 'too many attempts'); | |||
| 241 | + | assert.equal(fetches.length, 1); | |||
| 121 | assert.equal(nav, ''); | 242 | assert.equal(nav, ''); | ||
| 122 | assert.equal(btn.disabled, false); | 243 | assert.equal(btn.disabled, false); | ||
| 123 | }); | 244 | }); | ||
| 124 | 245 | ||||
| 125 | test('a refusal without a JSON body falls back to the status', async () => { | 246 | test('a refusal without a JSON body falls back to the status', async () => { | ||
| 126 | - | const btn = page('register'); | 247 | + | const btn = page('login'); |
| 127 | - | answers['/finish'] = { ok: false, status: 502, body: null }; | 248 | + | answers['/finish'] = { ok: false, status: 502, body: undefined }; |
| 128 | await clickOn(btn); | 249 | await clickOn(btn); | ||
| 129 | assert.equal(status.textContent, 'the server answered 502'); | 250 | assert.equal(status.textContent, 'the server answered 502'); | ||
| 130 | assert.equal(nav, ''); | 251 | assert.equal(nav, ''); | ||
| 131 | }); | 252 | }); | ||
| 132 | 253 | ||||
| 254 | + | test('a 403 without a reason says the session ended', async () => { | |||
| 255 | + | for (const body of [undefined, { error: 'forbidden' }, {}]) { | |||
| 256 | + | const btn = page('register'); | |||
| 257 | + | answers['/begin'] = { ok: false, status: 403, body }; | |||
| 258 | + | await submitOn(formOf(btn)).p; | |||
| 259 | + | assert.equal(status.textContent, 'your session ended or this page is out of date; reload and sign in again'); | |||
| 260 | + | assert.equal(fetches.length, 1); | |||
| 261 | + | fetches = []; | |||
| 262 | + | } | |||
| 263 | + | // At finish too. | |||
| 264 | + | const btn = page('login'); | |||
| 265 | + | answers['/begin'] = { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } }; | |||
| 266 | + | answers['/finish'] = { ok: false, status: 403, body: undefined }; | |||
| 267 | + | await clickOn(btn); | |||
| 268 | + | assert.match(status.textContent, /your session ended/); | |||
| 269 | + | }); | |||
| 270 | + | ||||
| 271 | + | test('a 403 with a reason of its own shows that reason', async () => { | |||
| 272 | + | const btn = page('register'); | |||
| 273 | + | answers['/begin'] = { ok: false, status: 403, body: { error: 'the owner must sign in first' } }; | |||
| 274 | + | await submitOn(formOf(btn)).p; | |||
| 275 | + | assert.equal(status.textContent, 'the owner must sign in first'); | |||
| 276 | + | }); | |||
| 277 | + | ||||
| 133 | test('the owner cancels the prompt', async () => { | 278 | test('the owner cancels the prompt', async () => { | ||
| 134 | const btn = page('login'); | 279 | const btn = page('login'); | ||
| 135 | navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); }; | 280 | navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); }; | ||
| @@ -137,12 +282,22 @@ test('the owner cancels the prompt', async () => { | |||||
| 137 | assert.match(status.textContent, /cancelled, or it took too long/); | 282 | assert.match(status.textContent, /cancelled, or it took too long/); | ||
| 138 | assert.equal(fetches.length, 1, 'finish is not called'); | 283 | assert.equal(fetches.length, 1, 'finish is not called'); | ||
| 139 | assert.equal(btn.disabled, false); | 284 | assert.equal(btn.disabled, false); | ||
| 285 | + | assert.deepEqual(dom.focused, [btn]); | |||
| 286 | + | }); | |||
| 287 | + | ||||
| 288 | + | test('AbortError says cancelled', async () => { | |||
| 289 | + | const btn = page('login'); | |||
| 290 | + | navigator.credentials.get = async () => { throw Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }); }; | |||
| 291 | + | await clickOn(btn); | |||
| 292 | + | assert.equal(status.textContent, 'cancelled'); | |||
| 293 | + | assert.equal(fetches.length, 1); | |||
| 294 | + | assert.equal(btn.disabled, false); | |||
| 140 | }); | 295 | }); | ||
| 141 | 296 | ||||
| 142 | test('an authenticator that already holds a passkey', async () => { | 297 | test('an authenticator that already holds a passkey', async () => { | ||
| 143 | const btn = page('register'); | 298 | const btn = page('register'); | ||
| 144 | navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); }; | 299 | navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); }; | ||
| 145 | - | await clickOn(btn); | 300 | + | await submitOn(formOf(btn)).p; |
| 146 | assert.match(status.textContent, /already holds a passkey/); | 301 | assert.match(status.textContent, /already holds a passkey/); | ||
| 147 | assert.equal(fetches.length, 1); | 302 | assert.equal(fetches.length, 1); | ||
| 148 | }); | 303 | }); | ||
| @@ -163,6 +318,105 @@ test('a null credential is not sent', async () => { | |||||
| 163 | assert.equal(fetches.length, 1); | 318 | assert.equal(fetches.length, 1); | ||
| 164 | }); | 319 | }); | ||
| 165 | 320 | ||||
| 321 | + | test('a credential without toJSON is reported, not sent', async () => { | |||
| 322 | + | for (const kind of ['login', 'register']) { | |||
| 323 | + | fetches = []; | |||
| 324 | + | const btn = page(kind); | |||
| 325 | + | const bare = { id: 'x' }; | |||
| 326 | + | navigator.credentials.get = async () => bare; | |||
| 327 | + | navigator.credentials.create = async () => bare; | |||
| 328 | + | if (kind === 'login') await clickOn(btn); else await submitOn(formOf(btn)).p; | |||
| 329 | + | assert.match(status.textContent, /cannot send; update the browser/); | |||
| 330 | + | assert.equal(fetches.length, 1, 'finish is not called'); | |||
| 331 | + | assert.equal(btn.disabled, false); | |||
| 332 | + | } | |||
| 333 | + | }); | |||
| 334 | + | ||||
| 335 | + | // The begin answer is checked before it reaches the browser API. | |||
| 336 | + | test('a begin answer that is not JSON', async () => { | |||
| 337 | + | const btn = page('login'); | |||
| 338 | + | answers['/begin'] = { ok: true, status: 200, body: undefined }; // a 200 whose body is not JSON | |||
| 339 | + | await clickOn(btn); | |||
| 340 | + | assert.equal(status.textContent, 'the server sent an unexpected answer'); | |||
| 341 | + | assert.equal(parsed.length, 0); | |||
| 342 | + | assert.equal(btn.disabled, false); | |||
| 343 | + | }); | |||
| 344 | + | ||||
| 345 | + | test('a begin answer with the wrong shape', async () => { | |||
| 346 | + | for (const body of [null, 'x', [], {}, { ceremony: 'c1' }, { ceremony: 'c1', options: {} }, { ceremony: 'c1', options: { publicKey: 'x' } }, | |||
| 347 | + | { ceremony: '', options: OPTIONS }, { ceremony: 5, options: OPTIONS }, { options: OPTIONS }, { ceremony: 'c1', options: null }]) { | |||
| 348 | + | fetches = []; | |||
| 349 | + | parsed = []; | |||
| 350 | + | const btn = page('login'); | |||
| 351 | + | answers['/begin'] = { ok: true, status: 200, body }; | |||
| 352 | + | await clickOn(btn); | |||
| 353 | + | assert.equal(status.textContent, 'the server sent an unexpected answer', JSON.stringify(body)); | |||
| 354 | + | assert.equal(parsed.length, 0, 'nothing reached the browser API'); | |||
| 355 | + | assert.equal(fetches.length, 1); | |||
| 356 | + | } | |||
| 357 | + | }); | |||
| 358 | + | ||||
| 359 | + | // A name the server would refuse is caught before the owner is prompted. | |||
| 360 | + | test('a name over 64 bytes (UTF-8) stops before begin', async () => { | |||
| 361 | + | const btn = page('register'); | |||
| 362 | + | name.value = 'é'.repeat(33); // 33 characters, 66 bytes | |||
| 363 | + | await submitOn(formOf(btn)).p; | |||
| 364 | + | assert.match(status.textContent, /longer than 64 bytes/); | |||
| 365 | + | assert.equal(fetches.length, 0, 'no request'); | |||
| 366 | + | assert.equal(navigator.created, undefined, 'create() is not called'); | |||
| 367 | + | assert.notEqual(btn.disabled, true, 'the button was never disabled'); | |||
| 368 | + | assert.deepEqual(dom.focused, [name], 'the field gets the focus'); | |||
| 369 | + | // The check is not a ceremony: the next try is not blocked. | |||
| 370 | + | name.value = 'ok'; | |||
| 371 | + | await submitOn(formOf(btn)).p; | |||
| 372 | + | assert.equal(fetches.length, 2); | |||
| 373 | + | }); | |||
| 374 | + | ||||
| 375 | + | test('64 bytes is fine, in ASCII or in two-byte characters', async () => { | |||
| 376 | + | for (const ok of ['a'.repeat(64), 'é'.repeat(32)]) { | |||
| 377 | + | fetches = []; | |||
| 378 | + | const btn = page('register'); // a success navigates away: each try is a new page | |||
| 379 | + | name.value = ok; | |||
| 380 | + | await submitOn(formOf(btn)).p; | |||
| 381 | + | assert.equal(fetches.length, 2, ok.length + ' characters'); | |||
| 382 | + | P.reset(); | |||
| 383 | + | } | |||
| 384 | + | }); | |||
| 385 | + | ||||
| 386 | + | test('control and direction characters stop before begin', async () => { | |||
| 387 | + | for (const bad of ['a\u0000b', 'a\tb', 'a\nb', 'a\x7fb', 'a\u0085b', 'evil \u202e txt', 'a\u2066b', 'a\u200eb', 'a\u061cb', 'a\u2028b', 'a\u2029b']) { | |||
| 388 | + | fetches = []; | |||
| 389 | + | const btn = page('register'); | |||
| 390 | + | name.value = bad; | |||
| 391 | + | await submitOn(formOf(btn)).p; | |||
| 392 | + | assert.match(status.textContent, /control or direction character/, JSON.stringify(bad)); | |||
| 393 | + | assert.equal(fetches.length, 0, JSON.stringify(bad)); | |||
| 394 | + | } | |||
| 395 | + | }); | |||
| 396 | + | ||||
| 397 | + | test('plain text with a zero width joiner, accents and emoji is fine', () => { | |||
| 398 | + | for (const ok of ['Pixel 9', 'clé de ma mère', 'a\u200db', '🔑 key', '']) { | |||
| 399 | + | assert.equal(P.nameProblem(ok), '', JSON.stringify(ok)); | |||
| 400 | + | } | |||
| 401 | + | }); | |||
| 402 | + | ||||
| 403 | + | test('an empty name is allowed: the server names the passkey', async () => { | |||
| 404 | + | const btn = page('register'); | |||
| 405 | + | name.value = ' '; | |||
| 406 | + | await submitOn(formOf(btn)).p; | |||
| 407 | + | assert.deepEqual(JSON.parse(fetches[0].init.body), { name: '' }); | |||
| 408 | + | assert.equal(fetches.length, 2); | |||
| 409 | + | }); | |||
| 410 | + | ||||
| 411 | + | test('the server can still refuse the name at begin: its reason is shown', async () => { | |||
| 412 | + | const btn = page('register'); | |||
| 413 | + | answers['/begin'] = { ok: false, status: 400, body: { error: 'passkey name: text contains the control character U+0009' } }; | |||
| 414 | + | await submitOn(formOf(btn)).p; | |||
| 415 | + | assert.match(status.textContent, /control character/); | |||
| 416 | + | assert.equal(navigator.created, undefined); | |||
| 417 | + | assert.equal(btn.disabled, false); | |||
| 418 | + | }); | |||
| 419 | + | ||||
| 166 | test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => { | 420 | test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => { | ||
| 167 | const login = page('login'); | 421 | const login = page('login'); | ||
| 168 | const register = el('button', { attrs: { 'data-passkey': 'register' } }); | 422 | const register = el('button', { attrs: { 'data-passkey': 'register' } }); | ||
| @@ -179,7 +433,7 @@ test('an old browser: the buttons are disabled with a reason, nothing is fetched | |||||
| 179 | assert.equal(status.textContent, 'this browser does not support passkeys'); | 433 | assert.equal(status.textContent, 'this browser does not support passkeys'); | ||
| 180 | }); | 434 | }); | ||
| 181 | 435 | ||||
| 182 | - | test('no PublicKeyCredential at all', async () => { | 436 | + | test('no PublicKeyCredential at all', () => { |
| 183 | const btn = page('login'); | 437 | const btn = page('login'); | ||
| 184 | globalThis.PublicKeyCredential = undefined; | 438 | globalThis.PublicKeyCredential = undefined; | ||
| 185 | P.init(); | 439 | P.init(); | ||
| @@ -201,15 +455,10 @@ test('a disabled button ignores clicks', async () => { | |||||
| 201 | assert.equal(fetches.length, 0); | 455 | assert.equal(fetches.length, 0); | ||
| 202 | }); | 456 | }); | ||
| 203 | 457 | ||||
| 204 | - | test('only login supports needs parseRequestOptionsFromJSON, register parseCreationOptionsFromJSON', async () => { | 458 | + | test('register needs parseCreationOptionsFromJSON', async () => { |
| 205 | const btn = page('register'); | 459 | const btn = page('register'); | ||
| 206 | delete PublicKeyCredential.parseCreationOptionsFromJSON; | 460 | delete PublicKeyCredential.parseCreationOptionsFromJSON; | ||
| 207 | - | await clickOn(btn); | 461 | + | await submitOn(formOf(btn)).p; |
| 208 | assert.equal(fetches.length, 0); | 462 | assert.equal(fetches.length, 0); | ||
| 209 | assert.equal(status.textContent, 'this browser does not support passkeys'); | 463 | assert.equal(status.textContent, 'this browser does not support passkeys'); | ||
| 210 | }); | 464 | }); | ||
| 211 | - | ||||
| 212 | - | test('the script registers one delegated click listener', () => { | |||
| 213 | - | assert.equal(dom.listened, true); | |||
| 214 | - | void settle; | |||
| 215 | - | }); | |||
M internal/adapters/web/pages.templ +4 -4
| @@ -259,11 +259,11 @@ templ Passkeys(l Layout, p PasskeysPage) { | |||
| 259 | 259 | if p.Full { | |
| 260 | 260 | <p class="dim">{ itoa(p.Max) } passkeys are registered, the most there can be. Remove one to add another.</p> | |
| 261 | 261 | } else { | |
| 262 | - | <div class="passkey-add"> | |
| 262 | + | <form class="passkey-add" data-js data-passkey-form> | |
| 263 | 263 | <label for="passkey-name" class="sr">Name of the new passkey</label> | |
| 264 | - | <input id="passkey-name" type="text" maxlength="64" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/> | |
| 265 | - | <button type="button" class="btn primary" data-js data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button> | |
| 266 | - | </div> | |
| 264 | + | <input id="passkey-name" type="text" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/> | |
| 265 | + | <button type="submit" class="btn primary" data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button> | |
| 266 | + | </form> | |
| 267 | 267 | } | |
| 268 | 268 | <p class="dim" role="status" aria-live="polite" data-passkey-status></p> | |
| 269 | 269 | } | |
| @@ -259,11 +259,11 @@ templ Passkeys(l Layout, p PasskeysPage) { | |||||
| 259 | if p.Full { | 259 | if p.Full { | ||
| 260 | <p class="dim">{ itoa(p.Max) } passkeys are registered, the most there can be. Remove one to add another.</p> | 260 | <p class="dim">{ itoa(p.Max) } passkeys are registered, the most there can be. Remove one to add another.</p> | ||
| 261 | } else { | 261 | } else { | ||
| 262 | - | <div class="passkey-add"> | 262 | + | <form class="passkey-add" data-js data-passkey-form> |
| 263 | <label for="passkey-name" class="sr">Name of the new passkey</label> | 263 | <label for="passkey-name" class="sr">Name of the new passkey</label> | ||
| 264 | - | <input id="passkey-name" type="text" maxlength="64" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/> | 264 | + | <input id="passkey-name" type="text" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/> |
| 265 | - | <button type="button" class="btn primary" data-js data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button> | 265 | + | <button type="submit" class="btn primary" data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button> |
| 266 | - | </div> | 266 | + | </form> |
| 267 | } | 267 | } | ||
| 268 | <p class="dim" role="status" aria-live="polite" data-passkey-status></p> | 268 | <p class="dim" role="status" aria-live="polite" data-passkey-status></p> | ||
| 269 | } | 269 | } | ||
M internal/adapters/web/pages_templ.go +4 -4 generated file: collapsed
M internal/adapters/web/passkeys_test.go +8 -0
| @@ -364,6 +364,14 @@ func TestPasskeysPage(t *testing.T) { | |||
| 364 | 364 | assert.Contains(t, body, "never") | |
| 365 | 365 | assert.Contains(t, body, `action="/-/passkeys/`+base64.RawURLEncoding.EncodeToString(list[0].ID)+`/remove"`) | |
| 366 | 366 | assert.Contains(t, body, `data-passkey="register"`) | |
| 367 | + | // TS-004: the name field and the add button are one form, so Enter submits. | |
| 368 | + | form := body[strings.Index(body, "<form class=\"passkey-add\""):] | |
| 369 | + | form = form[:strings.Index(form, "</form>")] | |
| 370 | + | assert.Contains(t, form, "data-passkey-form") | |
| 371 | + | assert.Contains(t, form, `data-passkey-name`) | |
| 372 | + | assert.Contains(t, form, `type="submit"`) | |
| 373 | + | assert.Contains(t, form, `data-passkey="register"`) | |
| 374 | + | assert.NotContains(t, form, "action=", "the script handles the submit; the form never posts by itself") | |
| 367 | 375 | assert.Contains(t, body, `data-begin="/-/passkeys/register/begin"`) | |
| 368 | 376 | assert.Contains(t, body, `data-finish="/-/passkeys/register/finish"`) | |
| 369 | 377 | assert.Contains(t, body, "data-passkey-status") | |
| @@ -364,6 +364,14 @@ func TestPasskeysPage(t *testing.T) { | |||||
| 364 | assert.Contains(t, body, "never") | 364 | assert.Contains(t, body, "never") | ||
| 365 | assert.Contains(t, body, `action="/-/passkeys/`+base64.RawURLEncoding.EncodeToString(list[0].ID)+`/remove"`) | 365 | assert.Contains(t, body, `action="/-/passkeys/`+base64.RawURLEncoding.EncodeToString(list[0].ID)+`/remove"`) | ||
| 366 | assert.Contains(t, body, `data-passkey="register"`) | 366 | assert.Contains(t, body, `data-passkey="register"`) | ||
| 367 | + | // TS-004: the name field and the add button are one form, so Enter submits. | |||
| 368 | + | form := body[strings.Index(body, "<form class=\"passkey-add\""):] | |||
| 369 | + | form = form[:strings.Index(form, "</form>")] | |||
| 370 | + | assert.Contains(t, form, "data-passkey-form") | |||
| 371 | + | assert.Contains(t, form, `data-passkey-name`) | |||
| 372 | + | assert.Contains(t, form, `type="submit"`) | |||
| 373 | + | assert.Contains(t, form, `data-passkey="register"`) | |||
| 374 | + | assert.NotContains(t, form, "action=", "the script handles the submit; the form never posts by itself") | |||
| 367 | assert.Contains(t, body, `data-begin="/-/passkeys/register/begin"`) | 375 | assert.Contains(t, body, `data-begin="/-/passkeys/register/begin"`) | ||
| 368 | assert.Contains(t, body, `data-finish="/-/passkeys/register/finish"`) | 376 | assert.Contains(t, body, `data-finish="/-/passkeys/register/finish"`) | ||
| 369 | assert.Contains(t, body, "data-passkey-status") | 377 | assert.Contains(t, body, "data-passkey-status") | ||
M internal/adapters/web/static/js/passkey.js +95 -17
| @@ -1,13 +1,19 @@ | |||
| 1 | 1 | // passkey.js: sign in with a passkey (/-/login) and add one (/-/passkeys). | |
| 2 | 2 | // | |
| 3 | - | // One delegated click handler on [data-passkey="login"|"register"]. The button | |
| 4 | - | // carries the two endpoints (data-begin, data-finish). The flow is the one of | |
| 5 | - | // the WebAuthn JSON API: POST begin, hand options.publicKey to the browser | |
| 6 | - | // (parse*OptionsFromJSON, then navigator.credentials.get|create), POST the | |
| 7 | - | // result (toJSON()) to finish. Nothing is inline: the CSP only allows scripts | |
| 8 | - | // from this origin. The core is tested in Node by jstest/passkey.test.mjs. | |
| 3 | + | // One delegated click handler (the login button) and one delegated submit | |
| 4 | + | // handler (the "add a passkey" form, so that Enter in the name field works). | |
| 5 | + | // The button carries the two endpoints (data-begin, data-finish). The flow is | |
| 6 | + | // the one of the WebAuthn JSON API: POST begin, hand options.publicKey to the | |
| 7 | + | // browser (parse*OptionsFromJSON, then navigator.credentials.get|create), POST | |
| 8 | + | // the result (toJSON()) to finish. Nothing is inline: the CSP only allows | |
| 9 | + | // scripts from this origin. The core is tested in Node by | |
| 10 | + | // jstest/passkey.test.mjs. | |
| 9 | 11 | window.ermitePasskey = (() => { | |
| 10 | 12 | const UNSUPPORTED = 'this browser does not support passkeys'; | |
| 13 | + | const UNEXPECTED = 'the server sent an unexpected answer'; | |
| 14 | + | const SESSION_ENDED = 'your session ended or this page is out of date; reload and sign in again'; | |
| 15 | + | const WAITING = 'waiting for your passkey…'; | |
| 16 | + | const MAX_NAME_BYTES = 64; | |
| 11 | 17 | ||
| 12 | 18 | // The parse function each ceremony needs; absent in a browser that predates | |
| 13 | 19 | // the WebAuthn JSON API. | |
| @@ -20,6 +26,28 @@ window.ermitePasskey = (() => { | |||
| 20 | 26 | const statusEl = () => document.querySelector('[data-passkey-status]'); | |
| 21 | 27 | const say = (text) => { const s = statusEl(); if (s) s.textContent = text; }; | |
| 22 | 28 | ||
| 29 | + | // A ceremony runs at a time: a second click or Enter while one waits for the | |
| 30 | + | // authenticator would start another prompt. | |
| 31 | + | let busy = false; | |
| 32 | + | ||
| 33 | + | // nameProblem says why a passkey name would be refused, or '' when it is fine. | |
| 34 | + | // It mirrors the server (domain.ParsePasskeyName): at most 64 bytes of UTF-8, | |
| 35 | + | // one line, no control character and no bidirectional mark, override or isolate. | |
| 36 | + | // The server stays the judge; this only saves the owner a prompt that would end | |
| 37 | + | // in a refusal. | |
| 38 | + | function nameProblem(name) { | |
| 39 | + | if (new TextEncoder().encode(name).length > MAX_NAME_BYTES) { | |
| 40 | + | return 'the name is longer than ' + MAX_NAME_BYTES + ' bytes'; | |
| 41 | + | } | |
| 42 | + | // C0 and C1 controls (this includes tab and line breaks), the line and | |
| 43 | + | // paragraph separators, and the bidirectional marks (U+200E, U+200F, U+061C, | |
| 44 | + | // U+202A to U+202E, U+2066 to U+2069). | |
| 45 | + | if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/.test(name)) { | |
| 46 | + | return 'the name has a control or direction character'; | |
| 47 | + | } | |
| 48 | + | return ''; | |
| 49 | + | } | |
| 50 | + | ||
| 23 | 51 | // messageOf turns what a step threw into one line for the owner. | |
| 24 | 52 | function messageOf(e) { | |
| 25 | 53 | if (e && e.name === 'NotAllowedError') return 'cancelled, or it took too long; try again'; | |
| @@ -29,35 +57,63 @@ window.ermitePasskey = (() => { | |||
| 29 | 57 | } | |
| 30 | 58 | ||
| 31 | 59 | // failure reads the error of a refused request: the server's one-line | |
| 32 | - | // reason when it sent one, the status otherwise. | |
| 60 | + | // reason when it sent one, the status otherwise. A 403 without a reason of | |
| 61 | + | // its own is a session that ended, or a page older than the server's state. | |
| 33 | 62 | async function failure(res) { | |
| 34 | 63 | let reason = ''; | |
| 35 | 64 | try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ } | |
| 65 | + | if (res.status === 403 && (!reason || reason === 'forbidden')) return new Error(SESSION_ENDED); | |
| 36 | 66 | return new Error(reason || 'the server answered ' + res.status); | |
| 37 | 67 | } | |
| 38 | 68 | ||
| 69 | + | // begin posts to the begin endpoint and checks the shape of the answer. | |
| 70 | + | async function begin(btn, kind, name) { | |
| 71 | + | const init = { method: 'POST', credentials: 'same-origin' }; | |
| 72 | + | if (kind === 'register') { | |
| 73 | + | init.headers = { 'Content-Type': 'application/json' }; | |
| 74 | + | init.body = JSON.stringify({ name }); | |
| 75 | + | } | |
| 76 | + | const res = await fetch(btn.dataset.begin, init); | |
| 77 | + | if (!res.ok) throw await failure(res); | |
| 78 | + | let answer; | |
| 79 | + | try { answer = await res.json(); } catch (_) { throw new Error(UNEXPECTED); } | |
| 80 | + | const ok = answer && typeof answer.ceremony === 'string' && answer.ceremony !== '' && | |
| 81 | + | answer.options && typeof answer.options === 'object' && | |
| 82 | + | answer.options.publicKey && typeof answer.options.publicKey === 'object'; | |
| 83 | + | if (!ok) throw new Error(UNEXPECTED); | |
| 84 | + | return answer; | |
| 85 | + | } | |
| 86 | + | ||
| 39 | 87 | async function run(btn) { | |
| 40 | 88 | const kind = btn.dataset.passkey; | |
| 41 | - | if (!parser[kind]) return; | |
| 89 | + | if (!parser[kind] || busy) return; | |
| 42 | 90 | if (!supported(kind)) { say(UNSUPPORTED); return; } | |
| 91 | + | ||
| 92 | + | const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null; | |
| 93 | + | const name = nameEl ? nameEl.value.trim() : ''; | |
| 94 | + | if (nameEl) { | |
| 95 | + | const problem = nameProblem(name); | |
| 96 | + | if (problem) { say(problem); nameEl.focus(); return; } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | busy = true; | |
| 43 | 100 | btn.disabled = true; | |
| 44 | - | say(''); | |
| 101 | + | btn.setAttribute('aria-busy', 'true'); | |
| 102 | + | say(WAITING); | |
| 45 | 103 | let done = false; | |
| 46 | 104 | try { | |
| 47 | - | const begun = await fetch(btn.dataset.begin, { method: 'POST', credentials: 'same-origin' }); | |
| 48 | - | if (!begun.ok) throw await failure(begun); | |
| 49 | - | const { ceremony, options } = await begun.json(); | |
| 105 | + | const { ceremony, options } = await begin(btn, kind, name); | |
| 50 | 106 | const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey); | |
| 51 | 107 | const cred = kind === 'login' | |
| 52 | 108 | ? await navigator.credentials.get({ publicKey }) | |
| 53 | 109 | : await navigator.credentials.create({ publicKey }); | |
| 54 | 110 | if (!cred) throw new Error('no passkey was returned'); | |
| 55 | - | const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null; | |
| 111 | + | if (typeof cred.toJSON !== 'function') throw new Error('this browser returned a passkey this page cannot send; update the browser'); | |
| 56 | 112 | const finished = await fetch(btn.dataset.finish, { | |
| 57 | 113 | method: 'POST', | |
| 58 | 114 | credentials: 'same-origin', | |
| 59 | 115 | headers: { 'Content-Type': 'application/json' }, | |
| 60 | - | body: JSON.stringify({ ceremony, name: nameEl ? nameEl.value.trim() : '', credential: cred.toJSON() }), | |
| 116 | + | body: JSON.stringify({ ceremony, name, credential: cred.toJSON() }), | |
| 61 | 117 | }); | |
| 62 | 118 | if (!finished.ok) throw await failure(finished); | |
| 63 | 119 | done = true; | |
| @@ -66,17 +122,35 @@ window.ermitePasskey = (() => { | |||
| 66 | 122 | } catch (e) { | |
| 67 | 123 | say(messageOf(e)); | |
| 68 | 124 | } finally { | |
| 69 | - | if (!done) btn.disabled = false; | |
| 125 | + | if (!done) { | |
| 126 | + | busy = false; | |
| 127 | + | btn.removeAttribute('aria-busy'); | |
| 128 | + | btn.disabled = false; | |
| 129 | + | // A disabled button cannot hold the focus: give it back once enabled. | |
| 130 | + | btn.focus(); | |
| 131 | + | } | |
| 70 | 132 | } | |
| 71 | 133 | } | |
| 72 | 134 | ||
| 135 | + | // click handles the login button. The add button is a submit button of a | |
| 136 | + | // form: its click is left to become a submit, which submit() handles. | |
| 73 | 137 | function click(ev) { | |
| 74 | 138 | const btn = ev.target && ev.target.closest && ev.target.closest('[data-passkey]'); | |
| 75 | - | if (!btn || btn.disabled) return; | |
| 139 | + | if (!btn || btn.disabled || btn.closest('[data-passkey-form]')) return; | |
| 76 | 140 | if (ev.preventDefault) ev.preventDefault(); | |
| 77 | 141 | return run(btn); | |
| 78 | 142 | } | |
| 79 | 143 | ||
| 144 | + | // submit handles the add form, by a click on its button or Enter in the field. | |
| 145 | + | function submit(ev) { | |
| 146 | + | const form = ev.target && ev.target.closest && ev.target.closest('[data-passkey-form]'); | |
| 147 | + | if (!form) return; | |
| 148 | + | if (ev.preventDefault) ev.preventDefault(); // never a real navigation | |
| 149 | + | const btn = form.querySelector('[data-passkey]'); | |
| 150 | + | if (!btn || btn.disabled) return; | |
| 151 | + | return run(btn); | |
| 152 | + | } | |
| 153 | + | ||
| 80 | 154 | // init disables the buttons of a browser that cannot do passkeys, and says why. | |
| 81 | 155 | function init() { | |
| 82 | 156 | let missing = false; | |
| @@ -91,7 +165,11 @@ window.ermitePasskey = (() => { | |||
| 91 | 165 | ||
| 92 | 166 | if (typeof document.addEventListener === 'function') { | |
| 93 | 167 | document.addEventListener('click', click); | |
| 168 | + | document.addEventListener('submit', submit); | |
| 94 | 169 | init(); | |
| 95 | 170 | } | |
| 96 | - | return { click, init, run }; | |
| 171 | + | // reset forgets a ceremony in progress. A page never needs it (a success | |
| 172 | + | // navigates away); the tests share one module between pages. | |
| 173 | + | const reset = () => { busy = false; }; | |
| 174 | + | return { click, submit, init, run, nameProblem, reset }; | |
| 97 | 175 | })(); | |
| @@ -1,13 +1,19 @@ | |||||
| 1 | // passkey.js: sign in with a passkey (/-/login) and add one (/-/passkeys). | 1 | // passkey.js: sign in with a passkey (/-/login) and add one (/-/passkeys). | ||
| 2 | // | 2 | // | ||
| 3 | - | // One delegated click handler on [data-passkey="login"|"register"]. The button | 3 | + | // One delegated click handler (the login button) and one delegated submit |
| 4 | - | // carries the two endpoints (data-begin, data-finish). The flow is the one of | 4 | + | // handler (the "add a passkey" form, so that Enter in the name field works). |
| 5 | - | // the WebAuthn JSON API: POST begin, hand options.publicKey to the browser | 5 | + | // The button carries the two endpoints (data-begin, data-finish). The flow is |
| 6 | - | // (parse*OptionsFromJSON, then navigator.credentials.get|create), POST the | 6 | + | // the one of the WebAuthn JSON API: POST begin, hand options.publicKey to the |
| 7 | - | // result (toJSON()) to finish. Nothing is inline: the CSP only allows scripts | 7 | + | // browser (parse*OptionsFromJSON, then navigator.credentials.get|create), POST |
| 8 | - | // from this origin. The core is tested in Node by jstest/passkey.test.mjs. | 8 | + | // the result (toJSON()) to finish. Nothing is inline: the CSP only allows |
| 9 | + | // scripts from this origin. The core is tested in Node by | |||
| 10 | + | // jstest/passkey.test.mjs. | |||
| 9 | window.ermitePasskey = (() => { | 11 | window.ermitePasskey = (() => { | ||
| 10 | const UNSUPPORTED = 'this browser does not support passkeys'; | 12 | const UNSUPPORTED = 'this browser does not support passkeys'; | ||
| 13 | + | const UNEXPECTED = 'the server sent an unexpected answer'; | |||
| 14 | + | const SESSION_ENDED = 'your session ended or this page is out of date; reload and sign in again'; | |||
| 15 | + | const WAITING = 'waiting for your passkey…'; | |||
| 16 | + | const MAX_NAME_BYTES = 64; | |||
| 11 | 17 | ||||
| 12 | // The parse function each ceremony needs; absent in a browser that predates | 18 | // The parse function each ceremony needs; absent in a browser that predates | ||
| 13 | // the WebAuthn JSON API. | 19 | // the WebAuthn JSON API. | ||
| @@ -20,6 +26,28 @@ window.ermitePasskey = (() => { | |||||
| 20 | const statusEl = () => document.querySelector('[data-passkey-status]'); | 26 | const statusEl = () => document.querySelector('[data-passkey-status]'); | ||
| 21 | const say = (text) => { const s = statusEl(); if (s) s.textContent = text; }; | 27 | const say = (text) => { const s = statusEl(); if (s) s.textContent = text; }; | ||
| 22 | 28 | ||||
| 29 | + | // A ceremony runs at a time: a second click or Enter while one waits for the | |||
| 30 | + | // authenticator would start another prompt. | |||
| 31 | + | let busy = false; | |||
| 32 | + | ||||
| 33 | + | // nameProblem says why a passkey name would be refused, or '' when it is fine. | |||
| 34 | + | // It mirrors the server (domain.ParsePasskeyName): at most 64 bytes of UTF-8, | |||
| 35 | + | // one line, no control character and no bidirectional mark, override or isolate. | |||
| 36 | + | // The server stays the judge; this only saves the owner a prompt that would end | |||
| 37 | + | // in a refusal. | |||
| 38 | + | function nameProblem(name) { | |||
| 39 | + | if (new TextEncoder().encode(name).length > MAX_NAME_BYTES) { | |||
| 40 | + | return 'the name is longer than ' + MAX_NAME_BYTES + ' bytes'; | |||
| 41 | + | } | |||
| 42 | + | // C0 and C1 controls (this includes tab and line breaks), the line and | |||
| 43 | + | // paragraph separators, and the bidirectional marks (U+200E, U+200F, U+061C, | |||
| 44 | + | // U+202A to U+202E, U+2066 to U+2069). | |||
| 45 | + | if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/.test(name)) { | |||
| 46 | + | return 'the name has a control or direction character'; | |||
| 47 | + | } | |||
| 48 | + | return ''; | |||
| 49 | + | } | |||
| 50 | + | ||||
| 23 | // messageOf turns what a step threw into one line for the owner. | 51 | // messageOf turns what a step threw into one line for the owner. | ||
| 24 | function messageOf(e) { | 52 | function messageOf(e) { | ||
| 25 | if (e && e.name === 'NotAllowedError') return 'cancelled, or it took too long; try again'; | 53 | if (e && e.name === 'NotAllowedError') return 'cancelled, or it took too long; try again'; | ||
| @@ -29,35 +57,63 @@ window.ermitePasskey = (() => { | |||||
| 29 | } | 57 | } | ||
| 30 | 58 | ||||
| 31 | // failure reads the error of a refused request: the server's one-line | 59 | // failure reads the error of a refused request: the server's one-line | ||
| 32 | - | // reason when it sent one, the status otherwise. | 60 | + | // reason when it sent one, the status otherwise. A 403 without a reason of |
| 61 | + | // its own is a session that ended, or a page older than the server's state. | |||
| 33 | async function failure(res) { | 62 | async function failure(res) { | ||
| 34 | let reason = ''; | 63 | let reason = ''; | ||
| 35 | try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ } | 64 | try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ } | ||
| 65 | + | if (res.status === 403 && (!reason || reason === 'forbidden')) return new Error(SESSION_ENDED); | |||
| 36 | return new Error(reason || 'the server answered ' + res.status); | 66 | return new Error(reason || 'the server answered ' + res.status); | ||
| 37 | } | 67 | } | ||
| 38 | 68 | ||||
| 69 | + | // begin posts to the begin endpoint and checks the shape of the answer. | |||
| 70 | + | async function begin(btn, kind, name) { | |||
| 71 | + | const init = { method: 'POST', credentials: 'same-origin' }; | |||
| 72 | + | if (kind === 'register') { | |||
| 73 | + | init.headers = { 'Content-Type': 'application/json' }; | |||
| 74 | + | init.body = JSON.stringify({ name }); | |||
| 75 | + | } | |||
| 76 | + | const res = await fetch(btn.dataset.begin, init); | |||
| 77 | + | if (!res.ok) throw await failure(res); | |||
| 78 | + | let answer; | |||
| 79 | + | try { answer = await res.json(); } catch (_) { throw new Error(UNEXPECTED); } | |||
| 80 | + | const ok = answer && typeof answer.ceremony === 'string' && answer.ceremony !== '' && | |||
| 81 | + | answer.options && typeof answer.options === 'object' && | |||
| 82 | + | answer.options.publicKey && typeof answer.options.publicKey === 'object'; | |||
| 83 | + | if (!ok) throw new Error(UNEXPECTED); | |||
| 84 | + | return answer; | |||
| 85 | + | } | |||
| 86 | + | ||||
| 39 | async function run(btn) { | 87 | async function run(btn) { | ||
| 40 | const kind = btn.dataset.passkey; | 88 | const kind = btn.dataset.passkey; | ||
| 41 | - | if (!parser[kind]) return; | 89 | + | if (!parser[kind] || busy) return; |
| 42 | if (!supported(kind)) { say(UNSUPPORTED); return; } | 90 | if (!supported(kind)) { say(UNSUPPORTED); return; } | ||
| 91 | + | ||||
| 92 | + | const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null; | |||
| 93 | + | const name = nameEl ? nameEl.value.trim() : ''; | |||
| 94 | + | if (nameEl) { | |||
| 95 | + | const problem = nameProblem(name); | |||
| 96 | + | if (problem) { say(problem); nameEl.focus(); return; } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | busy = true; | |||
| 43 | btn.disabled = true; | 100 | btn.disabled = true; | ||
| 44 | - | say(''); | 101 | + | btn.setAttribute('aria-busy', 'true'); |
| 102 | + | say(WAITING); | |||
| 45 | let done = false; | 103 | let done = false; | ||
| 46 | try { | 104 | try { | ||
| 47 | - | const begun = await fetch(btn.dataset.begin, { method: 'POST', credentials: 'same-origin' }); | 105 | + | const { ceremony, options } = await begin(btn, kind, name); |
| 48 | - | if (!begun.ok) throw await failure(begun); | |||
| 49 | - | const { ceremony, options } = await begun.json(); | |||
| 50 | const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey); | 106 | const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey); | ||
| 51 | const cred = kind === 'login' | 107 | const cred = kind === 'login' | ||
| 52 | ? await navigator.credentials.get({ publicKey }) | 108 | ? await navigator.credentials.get({ publicKey }) | ||
| 53 | : await navigator.credentials.create({ publicKey }); | 109 | : await navigator.credentials.create({ publicKey }); | ||
| 54 | if (!cred) throw new Error('no passkey was returned'); | 110 | if (!cred) throw new Error('no passkey was returned'); | ||
| 55 | - | const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null; | 111 | + | if (typeof cred.toJSON !== 'function') throw new Error('this browser returned a passkey this page cannot send; update the browser'); |
| 56 | const finished = await fetch(btn.dataset.finish, { | 112 | const finished = await fetch(btn.dataset.finish, { | ||
| 57 | method: 'POST', | 113 | method: 'POST', | ||
| 58 | credentials: 'same-origin', | 114 | credentials: 'same-origin', | ||
| 59 | headers: { 'Content-Type': 'application/json' }, | 115 | headers: { 'Content-Type': 'application/json' }, | ||
| 60 | - | body: JSON.stringify({ ceremony, name: nameEl ? nameEl.value.trim() : '', credential: cred.toJSON() }), | 116 | + | body: JSON.stringify({ ceremony, name, credential: cred.toJSON() }), |
| 61 | }); | 117 | }); | ||
| 62 | if (!finished.ok) throw await failure(finished); | 118 | if (!finished.ok) throw await failure(finished); | ||
| 63 | done = true; | 119 | done = true; | ||
| @@ -66,17 +122,35 @@ window.ermitePasskey = (() => { | |||||
| 66 | } catch (e) { | 122 | } catch (e) { | ||
| 67 | say(messageOf(e)); | 123 | say(messageOf(e)); | ||
| 68 | } finally { | 124 | } finally { | ||
| 69 | - | if (!done) btn.disabled = false; | 125 | + | if (!done) { |
| 126 | + | busy = false; | |||
| 127 | + | btn.removeAttribute('aria-busy'); | |||
| 128 | + | btn.disabled = false; | |||
| 129 | + | // A disabled button cannot hold the focus: give it back once enabled. | |||
| 130 | + | btn.focus(); | |||
| 131 | + | } | |||
| 70 | } | 132 | } | ||
| 71 | } | 133 | } | ||
| 72 | 134 | ||||
| 135 | + | // click handles the login button. The add button is a submit button of a | |||
| 136 | + | // form: its click is left to become a submit, which submit() handles. | |||
| 73 | function click(ev) { | 137 | function click(ev) { | ||
| 74 | const btn = ev.target && ev.target.closest && ev.target.closest('[data-passkey]'); | 138 | const btn = ev.target && ev.target.closest && ev.target.closest('[data-passkey]'); | ||
| 75 | - | if (!btn || btn.disabled) return; | 139 | + | if (!btn || btn.disabled || btn.closest('[data-passkey-form]')) return; |
| 76 | if (ev.preventDefault) ev.preventDefault(); | 140 | if (ev.preventDefault) ev.preventDefault(); | ||
| 77 | return run(btn); | 141 | return run(btn); | ||
| 78 | } | 142 | } | ||
| 79 | 143 | ||||
| 144 | + | // submit handles the add form, by a click on its button or Enter in the field. | |||
| 145 | + | function submit(ev) { | |||
| 146 | + | const form = ev.target && ev.target.closest && ev.target.closest('[data-passkey-form]'); | |||
| 147 | + | if (!form) return; | |||
| 148 | + | if (ev.preventDefault) ev.preventDefault(); // never a real navigation | |||
| 149 | + | const btn = form.querySelector('[data-passkey]'); | |||
| 150 | + | if (!btn || btn.disabled) return; | |||
| 151 | + | return run(btn); | |||
| 152 | + | } | |||
| 153 | + | ||||
| 80 | // init disables the buttons of a browser that cannot do passkeys, and says why. | 154 | // init disables the buttons of a browser that cannot do passkeys, and says why. | ||
| 81 | function init() { | 155 | function init() { | ||
| 82 | let missing = false; | 156 | let missing = false; | ||
| @@ -91,7 +165,11 @@ window.ermitePasskey = (() => { | |||||
| 91 | 165 | ||||
| 92 | if (typeof document.addEventListener === 'function') { | 166 | if (typeof document.addEventListener === 'function') { | ||
| 93 | document.addEventListener('click', click); | 167 | document.addEventListener('click', click); | ||
| 168 | + | document.addEventListener('submit', submit); | |||
| 94 | init(); | 169 | init(); | ||
| 95 | } | 170 | } | ||
| 96 | - | return { click, init, run }; | 171 | + | // reset forgets a ceremony in progress. A page never needs it (a success |
| 172 | + | // navigates away); the tests share one module between pages. | |||
| 173 | + | const reset = () => { busy = false; }; | |||
| 174 | + | return { click, submit, init, run, nameProblem, reset }; | |||
| 97 | })(); | 175 | })(); | ||