1// passkey.js: sign in with a passkey (/-/login) and add one (/-/passkeys).
2//
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
10// browser (parse*OptionsFromJSON, then navigator.credentials.get|create), POST
11// the result (toJSON()) to finish. Nothing is inline: the CSP only allows
12// scripts from this origin. The core is tested in Node by
13// jstest/passkey.test.mjs.
14window.ermitePasskey = (() => {
15 const UNSUPPORTED = 'this browser does not support passkeys';
16 const UNEXPECTED = 'the server sent an unexpected answer';
17 const SESSION_ENDED = 'your session ended or this page is out of date; reload and sign in again';
18 const WAITING = 'waiting for your passkey…';
19 const MAX_NAME_BYTES = 64;
20
21 // The parse function each ceremony needs; absent in a browser that predates
22 // the WebAuthn JSON API.
23 const parser = { login: 'parseRequestOptionsFromJSON', register: 'parseCreationOptionsFromJSON' };
24 const supported = (kind) => {
25 const pkc = window.PublicKeyCredential;
26 return !!pkc && typeof pkc[parser[kind]] === 'function' && !!navigator.credentials;
27 };
28
29 const statusEl = () => document.querySelector('[data-passkey-status]');
30 const say = (text) => { const s = statusEl(); if (s) s.textContent = text; };
31
32 // nameProblem says why a passkey name would be refused, or '' when it is fine.
33 // It mirrors the server (domain.ParsePasskeyName): at most 64 bytes of UTF-8,
34 // one line, no control character and no bidirectional mark, override or isolate.
35 // The server stays the judge; this only saves the owner a prompt that would end
36 // in a refusal.
37 function nameProblem(name) {
38 if (new TextEncoder().encode(name).length > MAX_NAME_BYTES) {
39 return 'the name is longer than ' + MAX_NAME_BYTES + ' bytes';
40 }
41 // C0 and C1 controls (this includes tab and line breaks), the line and
42 // paragraph separators, and the bidirectional marks (U+200E, U+200F, U+061C,
43 // U+202A to U+202E, U+2066 to U+2069).
44 if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/.test(name)) {
45 return 'the name has a control or direction character';
46 }
47 return '';
48 }
49
50 // messageOf turns what a step threw into one line for the owner.
51 function messageOf(e) {
52 if (e && e.name === 'NotAllowedError') return 'cancelled, or it took too long; try again';
53 if (e && e.name === 'InvalidStateError') return 'this authenticator already holds a passkey for this site';
54 if (e && e.name === 'AbortError') return 'cancelled';
55 return (e && e.message) || 'something went wrong; try again';
56 }
57
58 // failure reads the error of a refused request: the server's one-line
59 // reason when it sent one, the status otherwise. A 403 without a reason of
60 // its own is a session that ended, or a page older than the server's state.
61 async function failure(res) {
62 let reason = '';
63 try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ }
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;
68 }
69
70 // begin posts to the begin endpoint and checks the shape of the answer.
71 async function begin(btn, kind, name) {
72 const init = { method: 'POST', credentials: 'same-origin' };
73 if (kind === 'register') {
74 init.headers = { 'Content-Type': 'application/json' };
75 init.body = JSON.stringify({ name });
76 }
77 const res = await fetch(btn.dataset.begin, init);
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 }
83 let answer;
84 try { answer = await res.json(); } catch (_) { throw new Error(UNEXPECTED); }
85 const ok = answer && typeof answer.ceremony === 'string' && answer.ceremony !== '' &&
86 answer.options && typeof answer.options === 'object' &&
87 answer.options.publicKey && typeof answer.options.publicKey === 'object';
88 if (!ok) throw new Error(UNEXPECTED);
89 return answer;
90 }
91
92 async function run(btn) {
93 const kind = btn.dataset.passkey;
94 if (!parser[kind]) return;
95 if (!supported(kind)) { say(UNSUPPORTED); return; }
96
97 const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null;
98 const name = nameEl ? nameEl.value.trim() : '';
99 if (nameEl) {
100 const problem = nameProblem(name);
101 if (problem) { say(problem); nameEl.focus(); return; }
102 }
103
104 btn.disabled = true;
105 btn.setAttribute('aria-busy', 'true');
106 say(WAITING);
107 let done = false;
108 let failed = null;
109 try {
110 const { ceremony, options } = await begin(btn, kind, name);
111 const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey);
112 const cred = kind === 'login'
113 ? await navigator.credentials.get({ publicKey })
114 : await navigator.credentials.create({ publicKey });
115 if (!cred) throw new Error('no passkey was returned');
116 if (typeof cred.toJSON !== 'function') throw new Error('this browser returned a passkey this page cannot send; update the browser');
117 const finished = await fetch(btn.dataset.finish, {
118 method: 'POST',
119 credentials: 'same-origin',
120 headers: { 'Content-Type': 'application/json' },
121 body: JSON.stringify({ ceremony, credential: cred.toJSON() }),
122 });
123 if (!finished.ok) throw await failure(finished);
124 done = true;
125 if (kind === 'login') location.assign('/');
126 else location.reload();
127 } catch (e) {
128 say(messageOf(e));
129 failed = e;
130 } finally {
131 if (!done) {
132 btn.removeAttribute('aria-busy');
133 btn.disabled = false;
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();
138 }
139 }
140 }
141
142 // submit handles both forms. The event comes from the form itself.
143 function submit(ev) {
144 const form = ev.target;
145 if (!form || typeof form.matches !== 'function' || !form.matches('[data-passkey-form]')) return;
146 if (ev.preventDefault) ev.preventDefault(); // never a real navigation
147 const btn = form.querySelector('[data-passkey]');
148 if (!btn || btn.disabled) return;
149 return run(btn);
150 }
151
152 // init disables the buttons of a browser that cannot do passkeys, and says why.
153 function init() {
154 let missing = false;
155 document.querySelectorAll('[data-passkey]').forEach((btn) => {
156 if (parser[btn.dataset.passkey] && !supported(btn.dataset.passkey)) {
157 btn.disabled = true;
158 missing = true;
159 }
160 });
161 if (missing) say(UNSUPPORTED);
162 }
163
164 if (typeof document.addEventListener === 'function') {
165 document.addEventListener('submit', submit);
166 init();
167 }
168 return { submit, init, nameProblem };
169})();