detached 59d5a0b

passkey.js RawHistoryFolder

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})();

Keyboard shortcuts

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

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

Branches 4

Tags 45

↑↓ moveenter openesc close