anvilsign in

collin/anvil

1//! Passkey sign-in: the two WebAuthn ceremonies, plus the browser glue.
2//!
3//! Registration (signed in) and authentication (signed out) each run as
4//! *begin* → *finish*. Begin mints a challenge, stashes the server half in
5//! [`anvil_core::passkeys::Ceremonies`], and returns the client half as JSON.
6//! Finish takes what `navigator.credentials` produced, verifies it against the
7//! stashed challenge, and either stores a credential or starts a session.
8//!
9//! Sign-in is usernameless: passkeys are discoverable credentials, so the
10//! authenticator hands back the credential id it used and we look the account
11//! up from that.
12
13use anvil_core::{
14 App,
15 User,
16 passkeys::{
17 self,
18 Ceremony,
19 },
20 sessions,
21 users,
22};
23use axum::{
24 Json,
25 Router,
26 extract::{
27 Path,
28 State,
29 },
30 http::{
31 HeaderMap,
32 StatusCode,
33 },
34 response::{
35 IntoResponse,
36 Redirect,
37 Response,
38 },
39 routing::post,
40};
41use base64::Engine;
42use maud::{
43 Markup,
44 PreEscaped,
45 html,
46};
47use serde::{
48 Deserialize,
49 Serialize,
50};
51use webauthn_rp::{
52 AuthenticatedCredential,
53 DiscoverableAuthentication64,
54 DiscoverableCredentialRequestOptions,
55 PublicKeyCredentialCreationOptions,
56 Registration,
57 bin::{
58 Decode,
59 Encode,
60 },
61 request::{
62 PublicKeyCredentialDescriptor,
63 auth::AuthenticationVerificationOptions,
64 register::{
65 Nickname,
66 PublicKeyCredentialUserEntity,
67 RegistrationVerificationOptions,
68 UserHandle64,
69 Username,
70 },
71 },
72 response::{
73 AuthTransports,
74 CredentialId,
75 register::{
76 CompressedPubKey,
77 DynamicState,
78 StaticState,
79 },
80 },
81};
82
83use crate::{
84 auth::{
85 Csrf,
86 CurrentUser,
87 verify_csrf,
88 },
89 ui::{
90 csrf_input,
91 fmt_relative,
92 },
93};
94
95/// The stored public key, in the shape `webauthn_rp` decodes into.
96type StoredKey = CompressedPubKey<[u8; 32], [u8; 32], [u8; 48], Vec<u8>>;
97
98pub fn routes(router: Router<App>) -> Router<App> {
99 router
100 .route("/-/settings/passkeys/begin", post(register_begin))
101 .route("/-/settings/passkeys/finish", post(register_finish))
102 .route("/-/settings/passkeys/{id}/delete", post(delete_passkey))
103 .route("/-/login/passkey/begin", post(login_begin))
104 .route("/-/login/passkey/finish", post(login_finish))
105}
106
107// --- registration ----------------------------------------------------------
108
109#[derive(Serialize)]
110struct BeginResponse {
111 ceremony: String,
112 options: serde_json::Value,
113}
114
115/// `POST /-/settings/passkeys/begin` — issue a registration challenge.
116async fn register_begin(
117 State(app): State<App>,
118 CurrentUser(user): CurrentUser,
119 csrf: Csrf,
120 headers: HeaderMap,
121) -> Response {
122 let Some(user) = user else {
123 return (StatusCode::UNAUTHORIZED, "sign in first").into_response();
124 };
125 if let Err(resp) = check_csrf(&csrf, &headers) {
126 return resp;
127 }
128 let rp = match passkeys::rp_id(&app.config.http.base_url) {
129 Ok(rp) => rp,
130 Err(e) => return server_error(e),
131 };
132
133 // Reuse this account's existing handle so the authenticator files a second
134 // passkey under the same user rather than inventing a parallel identity.
135 let existing = passkeys::list(&app.db, user.id).await.unwrap_or_default();
136 let handle = match existing.first() {
137 Some(key) => match decode_handle(&key.user_handle) {
138 Some(handle) => handle,
139 None => return server_error("stored passkey handle is malformed"),
140 },
141 None => passkeys::new_user_handle(),
142 };
143
144 // Excluding what is already registered is what makes a second attempt on
145 // the same authenticator say "already registered" instead of silently
146 // creating a duplicate.
147 let exclude = existing
148 .iter()
149 .filter_map(|key| {
150 let id = b64url().decode(&key.credential_id).ok()?;
151 Some(PublicKeyCredentialDescriptor {
152 id: CredentialId::decode(id).ok()?,
153 transports: decode_transports(key.transports),
154 })
155 })
156 .collect();
157
158 let username = match Username::try_from(user.username.as_str()) {
159 Ok(name) => name,
160 Err(_) => return server_error("username is not usable as a WebAuthn name"),
161 };
162 let display_name = Nickname::try_from(user.username.as_str()).ok();
163 let entity = PublicKeyCredentialUserEntity {
164 name: username,
165 id: &handle,
166 display_name,
167 };
168
169 let (server_state, client_state) =
170 match PublicKeyCredentialCreationOptions::passkey(&rp, entity, exclude).start_ceremony() {
171 Ok(pair) => pair,
172 Err(e) => return server_error(format!("building registration options: {e}")),
173 };
174 let options = match serde_json::to_value(&client_state) {
175 Ok(value) => value,
176 Err(e) => return server_error(e),
177 };
178 let ceremony = app.ceremonies.insert(Ceremony::Register {
179 state: Box::new(server_state),
180 user_id: user.id,
181 });
182 // The handle travels with the ceremony via the credential we are about to
183 // store; keep it here so finish() writes the same bytes the browser saw.
184 let handle_b64 = base64::engine::general_purpose::STANDARD.encode(handle.as_ref());
185 Json(serde_json::json!({
186 "ceremony": ceremony,
187 "options": options,
188 "handle": handle_b64,
189 }))
190 .into_response()
191}
192
193#[derive(Deserialize)]
194struct RegisterFinish {
195 ceremony: String,
196 #[serde(default)]
197 name: String,
198 handle: String,
199 credential: serde_json::Value,
200}
201
202/// `POST /-/settings/passkeys/finish` — verify and store the new credential.
203async fn register_finish(
204 State(app): State<App>,
205 CurrentUser(user): CurrentUser,
206 csrf: Csrf,
207 headers: HeaderMap,
208 Json(body): Json<RegisterFinish>,
209) -> Response {
210 let Some(user) = user else {
211 return (StatusCode::UNAUTHORIZED, "sign in first").into_response();
212 };
213 if let Err(resp) = check_csrf(&csrf, &headers) {
214 return resp;
215 }
216 let Some(Ceremony::Register { state, user_id }) = app.ceremonies.take(&body.ceremony) else {
217 return bad_request("that registration expired — try again");
218 };
219 if user_id != user.id {
220 return bad_request("that registration belongs to another session");
221 }
222 let rp = match passkeys::rp_id(&app.config.http.base_url) {
223 Ok(rp) => rp,
224 Err(e) => return server_error(e),
225 };
226 let registration: Registration = match serde_json::from_value(body.credential) {
227 Ok(reg) => reg,
228 Err(e) => return bad_request(format!("malformed credential: {e}")),
229 };
230
231 let origin = passkeys::origin(&app.config.http.base_url);
232 let options = RegistrationVerificationOptions::<&str, &str> {
233 allowed_origins: &[origin.as_str()],
234 ..Default::default()
235 };
236 let credential = match state.verify(&rp, &registration, &options) {
237 Ok(credential) => credential,
238 Err(e) => {
239 tracing::warn!("passkey registration rejected: {e}");
240 return bad_request(format!("passkey rejected: {e}"));
241 }
242 };
243
244 let (id, transports, _handle, static_state, dynamic_state, _metadata) = credential.into_parts();
245 let credential_id = b64url().encode(id.as_ref());
246 let static_encoded = match static_state.encode() {
247 Ok(bytes) => base64::engine::general_purpose::STANDARD.encode(bytes),
248 Err(_) => return server_error("encoding credential public key"),
249 };
250 let dynamic_encoded = match dynamic_state.encode() {
251 Ok(bytes) => base64::engine::general_purpose::STANDARD.encode(bytes),
252 Err(_) => return server_error("encoding credential state"),
253 };
254 let transports = encode_transports(transports);
255
256 match passkeys::add(
257 &app.db,
258 user.id,
259 &body.name,
260 &credential_id,
261 &body.handle,
262 &static_encoded,
263 &dynamic_encoded,
264 transports,
265 )
266 .await
267 {
268 Ok(_) => {
269 tracing::info!("passkey registered for {}", user.username);
270 StatusCode::NO_CONTENT.into_response()
271 }
272 Err(anvil_core::Error::AlreadyExists(_)) => {
273 bad_request("that passkey is already registered")
274 }
275 Err(e) => server_error(e),
276 }
277}
278
279/// `POST /-/settings/passkeys/{id}/delete` — remove one of your passkeys.
280async fn delete_passkey(
281 State(app): State<App>,
282 CurrentUser(user): CurrentUser,
283 csrf: Csrf,
284 Path(id): Path<i64>,
285 axum::Form(form): axum::Form<crate::auth::CsrfForm>,
286) -> Response {
287 let Some(user) = user else {
288 return (StatusCode::UNAUTHORIZED, "sign in first").into_response();
289 };
290 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
291 return resp;
292 }
293 if let Err(e) = passkeys::delete(&app.db, id, user.id).await {
294 return server_error(e);
295 }
296 Redirect::to("/-/settings").into_response()
297}
298
299// --- sign-in ---------------------------------------------------------------
300
301/// `POST /-/login/passkey/begin` — issue an authentication challenge.
302///
303/// Deliberately open to anyone: it reveals nothing (the challenge is random and
304/// no account is named), and requiring a session first would defeat the point.
305async fn login_begin(State(app): State<App>) -> Response {
306 let rp = match passkeys::rp_id(&app.config.http.base_url) {
307 Ok(rp) => rp,
308 Err(e) => return server_error(e),
309 };
310 let (server_state, client_state) =
311 match DiscoverableCredentialRequestOptions::passkey(&rp).start_ceremony() {
312 Ok(pair) => pair,
313 Err(e) => return server_error(format!("building authentication options: {e}")),
314 };
315 let options = match serde_json::to_value(&client_state) {
316 Ok(value) => value,
317 Err(e) => return server_error(e),
318 };
319 let ceremony = app.ceremonies.insert(Ceremony::Authenticate {
320 state: Box::new(server_state),
321 });
322 Json(BeginResponse { ceremony, options }).into_response()
323}
324
325#[derive(Deserialize)]
326struct LoginFinish {
327 ceremony: String,
328 credential: serde_json::Value,
329}
330
331/// `POST /-/login/passkey/finish` — verify an assertion and start a session.
332async fn login_finish(
333 State(app): State<App>,
334 jar: axum_extra::extract::CookieJar,
335 Json(body): Json<LoginFinish>,
336) -> Response {
337 let Some(Ceremony::Authenticate { state }) = app.ceremonies.take(&body.ceremony) else {
338 return bad_request("that sign-in expired — try again");
339 };
340 let rp = match passkeys::rp_id(&app.config.http.base_url) {
341 Ok(rp) => rp,
342 Err(e) => return server_error(e),
343 };
344 let authentication: DiscoverableAuthentication64 = match serde_json::from_value(body.credential)
345 {
346 Ok(auth) => auth,
347 Err(e) => return bad_request(format!("malformed assertion: {e}")),
348 };
349
350 let credential_id = b64url().encode(authentication.raw_id().as_ref());
351 let stored = match passkeys::find_by_credential_id(&app.db, &credential_id).await {
352 Ok(Some(stored)) => stored,
353 Ok(None) => return unauthorized(),
354 Err(e) => return server_error(e),
355 };
356 let Some(handle) = decode_handle(&stored.user_handle) else {
357 return server_error("stored passkey handle is malformed");
358 };
359 let (Some(static_state), Some(dynamic_state)) = (
360 decode_static_state(&stored.static_state),
361 decode_dynamic_state(&stored.dynamic_state),
362 ) else {
363 return server_error("stored passkey state is malformed");
364 };
365
366 let raw_id = authentication.raw_id().as_ref().to_vec();
367 let credential_ref = match CredentialId::decode(raw_id.as_slice()) {
368 Ok(id) => id,
369 Err(_) => return unauthorized(),
370 };
371 let mut credential =
372 match AuthenticatedCredential::new(credential_ref, &handle, static_state, dynamic_state) {
373 Ok(credential) => credential,
374 Err(e) => return server_error(format!("rebuilding credential: {e}")),
375 };
376
377 let origin = passkeys::origin(&app.config.http.base_url);
378 let options = AuthenticationVerificationOptions::<&str, &str> {
379 allowed_origins: &[origin.as_str()],
380 ..Default::default()
381 };
382 match state.verify(&rp, &authentication, &mut credential, &options) {
383 Ok(_updated) => {}
384 Err(e) => {
385 tracing::warn!("passkey sign-in rejected: {e}");
386 return unauthorized();
387 }
388 }
389
390 let Ok(user) = users::find_by_id(&app.db, stored.user_id).await else {
391 return server_error("looking up the passkey's account");
392 };
393 let Some(user) = user else {
394 return unauthorized();
395 };
396
397 // Persist the counter/flags the authenticator just reported, so a cloned
398 // credential replaying an older count is caught next time.
399 let Ok(bytes) = credential.dynamic_state().encode();
400 let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
401 if let Err(e) = passkeys::record_use(&app.db, stored, &encoded).await {
402 tracing::warn!("recording passkey use: {e}");
403 }
404
405 let session = match sessions::create(&app.db, user.id).await {
406 Ok(session) => session,
407 Err(e) => return server_error(e),
408 };
409 tracing::info!("passkey sign-in for {}", user.username);
410 let jar = jar.add(crate::auth::session_cookie(&app, session.token));
411 (jar, Json(serde_json::json!({ "redirect": "/" }))).into_response()
412}
413
414// --- settings UI -----------------------------------------------------------
415
416/// The passkeys section of account settings.
417pub fn settings_section(user: &User, keys: &[anvil_core::Passkey], csrf: &str) -> Markup {
418 html! {
419 h2 style="margin-top:28px" { "Passkeys" }
420 p.muted style="font-size:13px" {
421 "Sign in with Touch ID, Windows Hello, a phone, or a security key instead of "
422 (user.username) "'s password. The key itself never leaves the device — anvil only "
423 "stores its public half, and a passkey created here cannot be used on any other site."
424 }
425 @if keys.is_empty() {
426 p.muted { "No passkeys yet." }
427 } @else {
428 div.box {
429 @for key in keys {
430 div.row {
431 span { (key.name) }
432 span.muted style="margin-left:auto;font-size:13px" {
433 @if key.last_used_at == 0 {
434 "never used"
435 } @else {
436 "last used " (fmt_relative(key.last_used_at))
437 }
438 " · added " (fmt_relative(key.created_at))
439 }
440 form method="post" style="margin-left:12px"
441 action=(format!("/-/settings/passkeys/{}/delete", key.id)) {
442 (csrf_input(csrf))
443 button.btn.btn-secondary type="submit" { "Remove" }
444 }
445 }
446 }
447 }
448 }
449 div #passkey-add.stack data-csrf=(csrf) style="margin-top:16px" {
450 p {
451 label { "Name this device" br; input #passkey-name type="text" placeholder="MacBook Touch ID" autocomplete="off"; }
452 }
453 p {
454 button.btn #passkey-register type="button" { "Add passkey" }
455 span #passkey-status.muted style="margin-left:10px;font-size:13px" {}
456 }
457 }
458 script { (PreEscaped(REGISTER_JS)) }
459 }
460}
461
462/// The "sign in with a passkey" control for the login page.
463pub fn login_button() -> Markup {
464 html! {
465 div #passkey-login style="margin-top:16px" {
466 button.btn.btn-secondary #passkey-login-btn type="button" { "Sign in with a passkey" }
467 span #passkey-login-status.muted style="margin-left:10px;font-size:13px" {}
468 }
469 script { (PreEscaped(LOGIN_JS)) }
470 }
471}
472
473// --- helpers ---------------------------------------------------------------
474
475fn b64url() -> base64::engine::general_purpose::GeneralPurpose {
476 base64::engine::general_purpose::URL_SAFE_NO_PAD
477}
478
479fn check_csrf(csrf: &Csrf, headers: &HeaderMap) -> Result<(), Response> {
480 let submitted = headers
481 .get("x-csrf-token")
482 .and_then(|v| v.to_str().ok())
483 .unwrap_or_default();
484 verify_csrf(csrf, submitted)
485}
486
487fn decode_handle(encoded: &str) -> Option<UserHandle64> {
488 let bytes = base64::engine::general_purpose::STANDARD
489 .decode(encoded)
490 .ok()?;
491 let bytes: [u8; passkeys::USER_HANDLE_LEN] = bytes.try_into().ok()?;
492 UserHandle64::decode(bytes).ok()
493}
494
495fn decode_static_state(encoded: &str) -> Option<StaticState<StoredKey>> {
496 let bytes = base64::engine::general_purpose::STANDARD
497 .decode(encoded)
498 .ok()?;
499 StaticState::decode(bytes.as_slice()).ok()
500}
501
502fn decode_dynamic_state(encoded: &str) -> Option<DynamicState> {
503 let bytes = base64::engine::general_purpose::STANDARD
504 .decode(encoded)
505 .ok()?;
506 let bytes: [u8; 7] = bytes.try_into().ok()?;
507 DynamicState::decode(bytes).ok()
508}
509
510/// Transports are stored as the crate's own compact encoding, widened to the
511/// integer column SQLite gives us.
512fn encode_transports(transports: AuthTransports) -> i64 {
513 transports.encode().map(i64::from).unwrap_or_default()
514}
515
516fn decode_transports(stored: i64) -> AuthTransports {
517 // An unreadable value costs a transport *hint*, nothing more: the browser
518 // falls back to asking about every transport it supports.
519 u8::try_from(stored)
520 .ok()
521 .and_then(|byte| AuthTransports::decode(byte).ok())
522 .unwrap_or_else(|| {
523 AuthTransports::decode(0).unwrap_or_else(|_| unreachable!("0 is a valid transport set"))
524 })
525}
526
527fn unauthorized() -> Response {
528 // Deliberately uniform: never distinguish "no such credential" from "bad
529 // signature", or the endpoint becomes a credential-enumeration oracle.
530 (
531 StatusCode::UNAUTHORIZED,
532 "that passkey is not registered here",
533 )
534 .into_response()
535}
536
537fn server_error(e: impl std::fmt::Display) -> Response {
538 tracing::error!("passkeys: {e}");
539 (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
540}
541
542fn bad_request(e: impl std::fmt::Display) -> Response {
543 (StatusCode::BAD_REQUEST, e.to_string()).into_response()
544}
545
546/// Shared browser helpers: WebAuthn speaks ArrayBuffers, JSON speaks base64url.
547///
548/// `PublicKeyCredential.parseCreationOptionsFromJSON`/`toJSON` would do this,
549/// but they are recent enough that a hand-rolled conversion is the difference
550/// between working everywhere and working on new Chrome.
551const WEBAUTHN_JS: &str = r#"
552globalThis.anvilWebAuthn = (function () {
553 function decode(value) {
554 var pad = value.replace(/-/g, '+').replace(/_/g, '/');
555 var bin = atob(pad + '='.repeat((4 - pad.length % 4) % 4));
556 var out = new Uint8Array(bin.length);
557 for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
558 return out;
559 }
560 function encode(buffer) {
561 var bytes = new Uint8Array(buffer), s = '';
562 for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
563 return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
564 }
565 return {
566 decode: decode,
567 encode: encode,
568 // The server sends the same JSON shape browsers standardized on; the
569 // binary fields just have to become buffers again.
570 toCreationOptions: function (options) {
571 options.challenge = decode(options.challenge);
572 options.user.id = decode(options.user.id);
573 (options.excludeCredentials || []).forEach(function (c) { c.id = decode(c.id); });
574 return options;
575 },
576 toRequestOptions: function (options) {
577 options.challenge = decode(options.challenge);
578 (options.allowCredentials || []).forEach(function (c) { c.id = decode(c.id); });
579 return options;
580 },
581 registrationJson: function (credential) {
582 return {
583 id: credential.id,
584 rawId: encode(credential.rawId),
585 type: credential.type,
586 clientExtensionResults: credential.getClientExtensionResults(),
587 response: {
588 clientDataJSON: encode(credential.response.clientDataJSON),
589 attestationObject: encode(credential.response.attestationObject),
590 transports: credential.response.getTransports ? credential.response.getTransports() : [],
591 },
592 };
593 },
594 assertionJson: function (credential) {
595 return {
596 id: credential.id,
597 rawId: encode(credential.rawId),
598 type: credential.type,
599 clientExtensionResults: credential.getClientExtensionResults(),
600 response: {
601 clientDataJSON: encode(credential.response.clientDataJSON),
602 authenticatorData: encode(credential.response.authenticatorData),
603 signature: encode(credential.response.signature),
604 userHandle: credential.response.userHandle ? encode(credential.response.userHandle) : null,
605 },
606 };
607 },
608 };
609})();
610"#;
611
612/// Registration, driven from account settings.
613const REGISTER_JS: &str = r#"
614(function () {
615 var root = document.getElementById('passkey-add');
616 if (!root) return;
617 var button = document.getElementById('passkey-register');
618 var statusEl = document.getElementById('passkey-status');
619 var nameEl = document.getElementById('passkey-name');
620
621 function fail(message) {
622 statusEl.textContent = message;
623 statusEl.style.color = '#cf222e';
624 button.disabled = false;
625 }
626
627 if (!window.PublicKeyCredential) {
628 button.disabled = true;
629 statusEl.textContent = 'This browser does not support passkeys.';
630 return;
631 }
632
633 button.addEventListener('click', async function () {
634 button.disabled = true;
635 statusEl.style.color = '';
636 statusEl.textContent = 'Waiting for your authenticator…';
637 var headers = { 'Content-Type': 'application/json', 'X-CSRF-Token': root.dataset.csrf };
638 try {
639 var res = await fetch('/-/settings/passkeys/begin', { method: 'POST', headers: headers });
640 if (!res.ok) return fail(await res.text());
641 var begin = await res.json();
642 var credential = await navigator.credentials.create({
643 publicKey: anvilWebAuthn.toCreationOptions(begin.options.publicKey || begin.options),
644 });
645 if (!credential) return fail('No passkey was created.');
646 statusEl.textContent = 'Saving…';
647 var save = await fetch('/-/settings/passkeys/finish', {
648 method: 'POST',
649 headers: headers,
650 body: JSON.stringify({
651 ceremony: begin.ceremony,
652 handle: begin.handle,
653 name: nameEl.value,
654 credential: anvilWebAuthn.registrationJson(credential),
655 }),
656 });
657 if (!save.ok) return fail(await save.text());
658 location.reload();
659 } catch (e) {
660 // NotAllowedError is the user cancelling or letting the prompt time out.
661 fail(e.name === 'NotAllowedError' ? 'Cancelled.' : (e.message || String(e)));
662 }
663 });
664})();
665"#;
666
667/// Sign-in, driven from the login page.
668const LOGIN_JS: &str = r#"
669(function () {
670 var button = document.getElementById('passkey-login-btn');
671 if (!button) return;
672 var statusEl = document.getElementById('passkey-login-status');
673
674 function fail(message) {
675 statusEl.textContent = message;
676 statusEl.style.color = '#cf222e';
677 button.disabled = false;
678 }
679
680 if (!window.PublicKeyCredential) {
681 document.getElementById('passkey-login').style.display = 'none';
682 return;
683 }
684
685 button.addEventListener('click', async function () {
686 button.disabled = true;
687 statusEl.style.color = '';
688 statusEl.textContent = 'Waiting for your authenticator…';
689 try {
690 var res = await fetch('/-/login/passkey/begin', { method: 'POST' });
691 if (!res.ok) return fail(await res.text());
692 var begin = await res.json();
693 var credential = await navigator.credentials.get({
694 publicKey: anvilWebAuthn.toRequestOptions(begin.options.publicKey || begin.options),
695 });
696 if (!credential) return fail('No passkey was used.');
697 statusEl.textContent = 'Signing in…';
698 var done = await fetch('/-/login/passkey/finish', {
699 method: 'POST',
700 headers: { 'Content-Type': 'application/json' },
701 body: JSON.stringify({
702 ceremony: begin.ceremony,
703 credential: anvilWebAuthn.assertionJson(credential),
704 }),
705 });
706 if (!done.ok) return fail(await done.text());
707 var result = await done.json();
708 location.href = result.redirect || '/';
709 } catch (e) {
710 fail(e.name === 'NotAllowedError' ? 'Cancelled.' : (e.message || String(e)));
711 }
712 });
713})();
714"#;
715
716/// Emitted once per page that uses either ceremony.
717pub fn shared_script() -> Markup {
718 html! { script { (PreEscaped(WEBAUTHN_JS)) } }
719}