1// Passkeys on /login/ and /private/, with @simplewebauthn/browser (the2// webauthn asset, which defines SimpleWebAuthnBrowser). Signs in, adds a3// passkey from the link `ssh <host> passkey` prints (/login/#enroll=<code>),4// takes up an invite (/login/#invite=<code>, from forge user invite) with5// an SSH key and then adds a passkey, and, once signed in, tells the browser or password manager which of the6// user's passkeys the forge still accepts (the Signal API), so one removed7// with `ssh <host> passkeys rm` stops being offered. The server's side is8// lib/login.ts.9interface Signal {10 rpID: string;11 userID: string;12 allAcceptedCredentialIDs: string[];13}14interface WebAuthn {15 browserSupportsWebAuthn(): boolean;16 startAuthentication(o: { optionsJSON: unknown }): Promise<unknown>;17 startRegistration(o: { optionsJSON: unknown }): Promise<unknown>;18 sendSignal(o: ({ signalName: 'allAcceptedCredentials' } & Signal) | { signalName: 'unknownCredential'; rpID: string; credentialID: string }): Promise<void>;19}2021(() => {22 const box = document.getElementById('login');23 const wa = (window as unknown as { SimpleWebAuthnBrowser?: WebAuthn }).SimpleWebAuthnBrowser;24 if (!box || !wa) return;2526 // Best effort: browsers without the Signal API just don't hear it.27 const signal = (s: Signal | undefined): Promise<void> =>28 s ? wa.sendSignal({ signalName: 'allAcceptedCredentials', ...s }).catch(() => {}) : Promise.resolve();29 if (box.dataset.signal) void signal(JSON.parse(box.dataset.signal) as Signal);3031 const status = document.getElementById('login-status');32 const say = (text: string, bad = false): void => {33 if (!status) return;34 status.textContent = text;35 status.classList.toggle('bad', bad);36 };3738 class Failed extends Error {39 data: Record<string, unknown>;40 constructor(message: string, data: Record<string, unknown>) {41 super(message);42 this.data = data;43 }44 }45 const post = async (path: string, body: unknown): Promise<Record<string, unknown>> => {46 const r = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });47 const data = (await r.json().catch(() => ({}))) as Record<string, unknown>;48 if (!r.ok) throw new Failed(String(data.error ?? `HTTP ${r.status}`), data);49 return data;50 };51 // The WebAuthn prompt was dismissed or timed out: nothing to report.52 const dismissed = (e: unknown): boolean => (e as Error)?.name === 'NotAllowedError' || (e as Error)?.name === 'AbortError';53 const next = (): string => box.dataset.next || '/private/';5455 const signin = document.getElementById('signin') as HTMLButtonElement | null;56 if (signin && !wa.browserSupportsWebAuthn()) {57 signin.disabled = true;58 say("This browser can't use passkeys.", true);59 }60 signin?.addEventListener('click', async () => {61 signin.disabled = true;62 say('');63 try {64 const options = await post('/login/options', {});65 const response = await wa.startAuthentication({ optionsJSON: options });66 say('Signing in…');67 const done = await post('/login/verify', response);68 await signal(done.signal as Signal);69 location.assign(next());70 } catch (e) {71 if (e instanceof Failed && e.data.unknown) {72 const u = e.data.unknown as { rpID: string; credentialID: string };73 void wa.sendSignal({ signalName: 'unknownCredential', rpID: u.rpID, credentialID: u.credentialID }).catch(() => {});74 }75 if (!dismissed(e)) say((e as Error).message, true);76 signin.disabled = false;77 }78 });7980 // Add a passkey with an enrollment code: from a link ssh passkey printed,81 // or from an invite just taken up.82 const enroll = document.getElementById('enroll') as HTMLButtonElement | null;83 const startEnroll = (code: string): void => {84 if (!enroll) return;85 document.getElementById('signin-part')!.hidden = true;86 document.getElementById('enroll-part')!.hidden = false;87 let options: Record<string, unknown> | null = null;88 enroll.disabled = true;89 post('/login/enroll/options', { code }).then((o) => {90 options = o;91 document.getElementById('enroll-user')!.textContent = (o.user as { name: string }).name;92 enroll.disabled = false;93 }, (e: Error) => say(e.message, true));94 enroll.addEventListener('click', async () => {95 if (!options) return;96 enroll.disabled = true;97 say('');98 try {99 const response = await wa.startRegistration({ optionsJSON: options });100 say('Adding it…');101 const done = await post('/login/enroll/verify', { code, response });102 history.replaceState(null, '', '/login/');103 await signal(done.signal as Signal);104 location.assign(next());105 } catch (e) {106 if (!dismissed(e)) say((e as Error).message, true);107 if (e instanceof Failed && e.data.expired) return;108 // A challenge works once: another try needs new options.109 options = await post('/login/enroll/options', { code }).catch((x: Error) => (say(x.message, true), null));110 enroll.disabled = !options;111 }112 });113 };114115 // An invite: an SSH public key makes the invitee a user (forge user116 // invite printed the link), and then they may add a passkey right here.117 // Its code is in the fragment too.118 const invite = /^#invite=([A-Za-z0-9_-]+)$/.exec(location.hash)?.[1];119 const join = document.getElementById('invite-join') as HTMLButtonElement | null;120 if (invite && join) {121 const key = document.getElementById('invite-key') as HTMLTextAreaElement;122 document.getElementById('signin-part')!.hidden = true;123 document.getElementById('login-help')!.hidden = true;124 document.getElementById('invite-part')!.hidden = false;125 join.disabled = true;126 post('/login/invite/options', { code: invite }).then((o) => {127 document.getElementById('invite-user')!.textContent = String(o.user);128 if (o.repo) document.getElementById('invite-grant')!.textContent = `, a ${String(o.role)} of ${String(o.repo)}`;129 join.disabled = false;130 key.focus();131 }, (e: Error) => {132 // A dead link: nothing to fill in, and the refusal in its panel.133 const form = document.getElementById('invite-form')!;134 if (status) form.replaceChildren(status);135 say(e.message, true);136 });137 join.addEventListener('click', async () => {138 join.disabled = true;139 say('Adding your key…');140 try {141 const done = await post('/login/invite/accept', { code: invite, key: key.value });142 history.replaceState(null, '', '/login/');143 say('');144 document.getElementById('invite-part')!.hidden = true;145 document.getElementById('enroll-user')!.textContent = String(done.user);146 document.getElementById('joined')!.hidden = false;147 if (wa.browserSupportsWebAuthn()) startEnroll(String(done.enroll));148 else {149 document.getElementById('enroll-part')!.hidden = false;150 enroll!.disabled = true;151 say("This browser can't use passkeys; ssh passkey prints a link for another one.", true);152 }153 } catch (e) {154 say((e as Error).message, true);155 join.disabled = !!(e instanceof Failed && e.data.expired);156 }157 });158 return;159 }160161 // An enrollment link: the code is in the fragment, which never leaves the browser.162 const code = /^#enroll=([A-Za-z0-9_-]+)$/.exec(location.hash)?.[1];163 if (code) startEnroll(code);164})();