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 DiscoverableCredentialRequestOptions,
54 PublicKeyCredentialCreationOptions,
55 bin::{
56 Decode,
57 Encode,
58 },
59 request::{
60 PublicKeyCredentialDescriptor,
61 auth::AuthenticationVerificationOptions,
62 register::{
63 Nickname,
64 PublicKeyCredentialUserEntity,
65 RegistrationVerificationOptions,
66 UserHandle64,
67 Username,
68 },
69 },
70 response::{
71 AuthTransports,
72 CredentialId,
73 auth::ser_relaxed::AuthenticationRelaxed,
74 register::{
75 CompressedPubKey,
76 DynamicState,
77 StaticState,
78 ser_relaxed::RegistrationRelaxed,
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 // The *relaxed* deserializer on purpose: the strict one additionally
227 // requires `authenticatorData`, `publicKey` and `publicKeyAlgorithm`, which
228 // only browsers implementing the newer WebAuthn-JSON serialization emit.
229 // Nothing security-relevant rides on them — they are conveniences derived
230 // from the attestation object, which is verified either way.
231 let registration = match serde_json::from_value::<RegistrationRelaxed>(body.credential) {
232 Ok(reg) => reg.0,
233 Err(e) => return bad_request(format!("malformed credential: {e}")),
234 };
235
236 let origin = passkeys::origin(&app.config.http.base_url);
237 let options = RegistrationVerificationOptions::<&str, &str> {
238 allowed_origins: &[origin.as_str()],
239 ..Default::default()
240 };
241 let credential = match state.verify(&rp, &registration, &options) {
242 Ok(credential) => credential,
243 Err(e) => {
244 tracing::warn!("passkey registration rejected: {e}");
245 return bad_request(format!("passkey rejected: {e}"));
246 }
247 };
248
249 let (id, transports, _handle, static_state, dynamic_state, _metadata) = credential.into_parts();
250 let credential_id = b64url().encode(id.as_ref());
251 let static_encoded = match static_state.encode() {
252 Ok(bytes) => base64::engine::general_purpose::STANDARD.encode(bytes),
253 Err(_) => return server_error("encoding credential public key"),
254 };
255 let dynamic_encoded = match dynamic_state.encode() {
256 Ok(bytes) => base64::engine::general_purpose::STANDARD.encode(bytes),
257 Err(_) => return server_error("encoding credential state"),
258 };
259 let transports = encode_transports(transports);
260
261 match passkeys::add(
262 &app.db,
263 user.id,
264 &body.name,
265 &credential_id,
266 &body.handle,
267 &static_encoded,
268 &dynamic_encoded,
269 transports,
270 )
271 .await
272 {
273 Ok(_) => {
274 tracing::info!("passkey registered for {}", user.username);
275 StatusCode::NO_CONTENT.into_response()
276 }
277 Err(anvil_core::Error::AlreadyExists(_)) => {
278 bad_request("that passkey is already registered")
279 }
280 Err(e) => server_error(e),
281 }
282}
283
284/// `POST /-/settings/passkeys/{id}/delete` — remove one of your passkeys.
285async fn delete_passkey(
286 State(app): State<App>,
287 CurrentUser(user): CurrentUser,
288 csrf: Csrf,
289 Path(id): Path<i64>,
290 axum::Form(form): axum::Form<crate::auth::CsrfForm>,
291) -> Response {
292 let Some(user) = user else {
293 return (StatusCode::UNAUTHORIZED, "sign in first").into_response();
294 };
295 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
296 return resp;
297 }
298 if let Err(e) = passkeys::delete(&app.db, id, user.id).await {
299 return server_error(e);
300 }
301 Redirect::to("/-/settings").into_response()
302}
303
304// --- sign-in ---------------------------------------------------------------
305
306/// `POST /-/login/passkey/begin` — issue an authentication challenge.
307///
308/// Deliberately open to anyone: it reveals nothing (the challenge is random and
309/// no account is named), and requiring a session first would defeat the point.
310async fn login_begin(State(app): State<App>) -> Response {
311 let rp = match passkeys::rp_id(&app.config.http.base_url) {
312 Ok(rp) => rp,
313 Err(e) => return server_error(e),
314 };
315 let (server_state, client_state) =
316 match DiscoverableCredentialRequestOptions::passkey(&rp).start_ceremony() {
317 Ok(pair) => pair,
318 Err(e) => return server_error(format!("building authentication options: {e}")),
319 };
320 let options = match serde_json::to_value(&client_state) {
321 Ok(value) => value,
322 Err(e) => return server_error(e),
323 };
324 let ceremony = app.ceremonies.insert(Ceremony::Authenticate {
325 state: Box::new(server_state),
326 });
327 Json(BeginResponse { ceremony, options }).into_response()
328}
329
330#[derive(Deserialize)]
331struct LoginFinish {
332 ceremony: String,
333 credential: serde_json::Value,
334}
335
336/// `POST /-/login/passkey/finish` — verify an assertion and start a session.
337async fn login_finish(
338 State(app): State<App>,
339 jar: axum_extra::extract::CookieJar,
340 Json(body): Json<LoginFinish>,
341) -> Response {
342 let Some(Ceremony::Authenticate { state }) = app.ceremonies.take(&body.ceremony) else {
343 return bad_request("that sign-in expired — try again");
344 };
345 let rp = match passkeys::rp_id(&app.config.http.base_url) {
346 Ok(rp) => rp,
347 Err(e) => return server_error(e),
348 };
349 let authentication =
350 match serde_json::from_value::<AuthenticationRelaxed<64, true>>(body.credential) {
351 Ok(auth) => auth.0,
352 Err(e) => return bad_request(format!("malformed assertion: {e}")),
353 };
354
355 let credential_id = b64url().encode(authentication.raw_id().as_ref());
356 let stored = match passkeys::find_by_credential_id(&app.db, &credential_id).await {
357 Ok(Some(stored)) => stored,
358 Ok(None) => return unauthorized(),
359 Err(e) => return server_error(e),
360 };
361 let Some(handle) = decode_handle(&stored.user_handle) else {
362 return server_error("stored passkey handle is malformed");
363 };
364 let (Some(static_state), Some(dynamic_state)) = (
365 decode_static_state(&stored.static_state),
366 decode_dynamic_state(&stored.dynamic_state),
367 ) else {
368 return server_error("stored passkey state is malformed");
369 };
370
371 let raw_id = authentication.raw_id().as_ref().to_vec();
372 let credential_ref = match CredentialId::decode(raw_id.as_slice()) {
373 Ok(id) => id,
374 Err(_) => return unauthorized(),
375 };
376 let mut credential =
377 match AuthenticatedCredential::new(credential_ref, &handle, static_state, dynamic_state) {
378 Ok(credential) => credential,
379 Err(e) => return server_error(format!("rebuilding credential: {e}")),
380 };
381
382 let origin = passkeys::origin(&app.config.http.base_url);
383 let options = AuthenticationVerificationOptions::<&str, &str> {
384 allowed_origins: &[origin.as_str()],
385 ..Default::default()
386 };
387 match state.verify(&rp, &authentication, &mut credential, &options) {
388 Ok(_updated) => {}
389 Err(e) => {
390 tracing::warn!("passkey sign-in rejected: {e}");
391 return unauthorized();
392 }
393 }
394
395 let Ok(user) = users::find_by_id(&app.db, stored.user_id).await else {
396 return server_error("looking up the passkey's account");
397 };
398 let Some(user) = user else {
399 return unauthorized();
400 };
401
402 // Persist the counter/flags the authenticator just reported, so a cloned
403 // credential replaying an older count is caught next time.
404 let Ok(bytes) = credential.dynamic_state().encode();
405 let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
406 if let Err(e) = passkeys::record_use(&app.db, stored, &encoded).await {
407 tracing::warn!("recording passkey use: {e}");
408 }
409
410 let session = match sessions::create(&app.db, user.id).await {
411 Ok(session) => session,
412 Err(e) => return server_error(e),
413 };
414 tracing::info!("passkey sign-in for {}", user.username);
415 let jar = jar.add(crate::auth::session_cookie(&app, session.token));
416 (jar, Json(serde_json::json!({ "redirect": "/" }))).into_response()
417}
418
419// --- settings UI -----------------------------------------------------------
420
421/// The passkeys section of account settings.
422pub fn settings_section(user: &User, keys: &[anvil_core::Passkey], csrf: &str) -> Markup {
423 html! {
424 h2 style="margin-top:28px" { "Passkeys" }
425 p.muted style="font-size:13px" {
426 "Sign in with Touch ID, Windows Hello, a phone, or a security key instead of "
427 (user.username) "'s password. The key itself never leaves the device — anvil only "
428 "stores its public half, and a passkey created here cannot be used on any other site."
429 }
430 @if keys.is_empty() {
431 p.muted { "No passkeys yet." }
432 } @else {
433 div.box {
434 @for key in keys {
435 div.row {
436 span { (key.name) }
437 span.muted style="margin-left:auto;font-size:13px" {
438 @if key.last_used_at == 0 {
439 "never used"
440 } @else {
441 "last used " (fmt_relative(key.last_used_at))
442 }
443 " · added " (fmt_relative(key.created_at))
444 }
445 form method="post" style="margin-left:12px"
446 action=(format!("/-/settings/passkeys/{}/delete", key.id)) {
447 (csrf_input(csrf))
448 button.btn.btn-secondary type="submit" { "Remove" }
449 }
450 }
451 }
452 }
453 }
454 div #passkey-add.stack data-csrf=(csrf) style="margin-top:16px" {
455 p {
456 label { "Name this device" br; input #passkey-name type="text" placeholder="MacBook Touch ID" autocomplete="off"; }
457 }
458 p {
459 button.btn #passkey-register type="button" { "Add passkey" }
460 span #passkey-status.muted style="margin-left:10px;font-size:13px" {}
461 }
462 }
463 script { (PreEscaped(REGISTER_JS)) }
464 }
465}
466
467/// The "sign in with a passkey" control for the login page.
468pub fn login_button() -> Markup {
469 html! {
470 div #passkey-login style="margin-top:16px" {
471 button.btn.btn-secondary #passkey-login-btn type="button" { "Sign in with a passkey" }
472 span #passkey-login-status.muted style="margin-left:10px;font-size:13px" {}
473 }
474 script { (PreEscaped(LOGIN_JS)) }
475 }
476}
477
478// --- helpers ---------------------------------------------------------------
479
480fn b64url() -> base64::engine::general_purpose::GeneralPurpose {
481 base64::engine::general_purpose::URL_SAFE_NO_PAD
482}
483
484fn check_csrf(csrf: &Csrf, headers: &HeaderMap) -> Result<(), Response> {
485 let submitted = headers
486 .get("x-csrf-token")
487 .and_then(|v| v.to_str().ok())
488 .unwrap_or_default();
489 verify_csrf(csrf, submitted)
490}
491
492fn decode_handle(encoded: &str) -> Option<UserHandle64> {
493 let bytes = base64::engine::general_purpose::STANDARD
494 .decode(encoded)
495 .ok()?;
496 let bytes: [u8; passkeys::USER_HANDLE_LEN] = bytes.try_into().ok()?;
497 UserHandle64::decode(bytes).ok()
498}
499
500fn decode_static_state(encoded: &str) -> Option<StaticState<StoredKey>> {
501 let bytes = base64::engine::general_purpose::STANDARD
502 .decode(encoded)
503 .ok()?;
504 StaticState::decode(bytes.as_slice()).ok()
505}
506
507fn decode_dynamic_state(encoded: &str) -> Option<DynamicState> {
508 let bytes = base64::engine::general_purpose::STANDARD
509 .decode(encoded)
510 .ok()?;
511 let bytes: [u8; 7] = bytes.try_into().ok()?;
512 DynamicState::decode(bytes).ok()
513}
514
515/// Transports are stored as the crate's own compact encoding, widened to the
516/// integer column SQLite gives us.
517fn encode_transports(transports: AuthTransports) -> i64 {
518 transports.encode().map(i64::from).unwrap_or_default()
519}
520
521fn decode_transports(stored: i64) -> AuthTransports {
522 // An unreadable value costs a transport *hint*, nothing more: the browser
523 // falls back to asking about every transport it supports.
524 u8::try_from(stored)
525 .ok()
526 .and_then(|byte| AuthTransports::decode(byte).ok())
527 .unwrap_or_else(|| {
528 AuthTransports::decode(0).unwrap_or_else(|_| unreachable!("0 is a valid transport set"))
529 })
530}
531
532fn unauthorized() -> Response {
533 // Deliberately uniform: never distinguish "no such credential" from "bad
534 // signature", or the endpoint becomes a credential-enumeration oracle.
535 (
536 StatusCode::UNAUTHORIZED,
537 "that passkey is not registered here",
538 )
539 .into_response()
540}
541
542fn server_error(e: impl std::fmt::Display) -> Response {
543 tracing::error!("passkeys: {e}");
544 (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
545}
546
547fn bad_request(e: impl std::fmt::Display) -> Response {
548 (StatusCode::BAD_REQUEST, e.to_string()).into_response()
549}
550
551/// Shared browser helpers: WebAuthn speaks ArrayBuffers, JSON speaks base64url.
552///
553/// `PublicKeyCredential.parseCreationOptionsFromJSON`/`toJSON` would do this,
554/// but they are recent enough that a hand-rolled conversion is the difference
555/// between working everywhere and working on new Chrome.
556const WEBAUTHN_JS: &str = r#"
557globalThis.anvilWebAuthn = (function () {
558 function decode(value) {
559 var pad = value.replace(/-/g, '+').replace(/_/g, '/');
560 var bin = atob(pad + '='.repeat((4 - pad.length % 4) % 4));
561 var out = new Uint8Array(bin.length);
562 for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
563 return out;
564 }
565 function encode(buffer) {
566 var bytes = new Uint8Array(buffer), s = '';
567 for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
568 return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
569 }
570 return {
571 decode: decode,
572 encode: encode,
573 // The server sends the same JSON shape browsers standardized on; the
574 // binary fields just have to become buffers again.
575 toCreationOptions: function (options) {
576 options.challenge = decode(options.challenge);
577 options.user.id = decode(options.user.id);
578 (options.excludeCredentials || []).forEach(function (c) { c.id = decode(c.id); });
579 return options;
580 },
581 toRequestOptions: function (options) {
582 options.challenge = decode(options.challenge);
583 (options.allowCredentials || []).forEach(function (c) { c.id = decode(c.id); });
584 return options;
585 },
586 registrationJson: function (credential) {
587 return {
588 id: credential.id,
589 rawId: encode(credential.rawId),
590 type: credential.type,
591 clientExtensionResults: credential.getClientExtensionResults(),
592 response: {
593 clientDataJSON: encode(credential.response.clientDataJSON),
594 attestationObject: encode(credential.response.attestationObject),
595 transports: credential.response.getTransports ? credential.response.getTransports() : [],
596 // Derived views of the attestation object. The server verifies the
597 // object itself, so these are optional — sent when the browser can.
598 authenticatorData: credential.response.getAuthenticatorData
599 ? encode(credential.response.getAuthenticatorData()) : undefined,
600 publicKey: credential.response.getPublicKey && credential.response.getPublicKey()
601 ? encode(credential.response.getPublicKey()) : undefined,
602 publicKeyAlgorithm: credential.response.getPublicKeyAlgorithm
603 ? credential.response.getPublicKeyAlgorithm() : undefined,
604 },
605 };
606 },
607 assertionJson: function (credential) {
608 return {
609 id: credential.id,
610 rawId: encode(credential.rawId),
611 type: credential.type,
612 clientExtensionResults: credential.getClientExtensionResults(),
613 response: {
614 clientDataJSON: encode(credential.response.clientDataJSON),
615 authenticatorData: encode(credential.response.authenticatorData),
616 signature: encode(credential.response.signature),
617 userHandle: credential.response.userHandle ? encode(credential.response.userHandle) : null,
618 },
619 };
620 },
621 };
622})();
623"#;
624
625/// Registration, driven from account settings.
626const REGISTER_JS: &str = r#"
627(function () {
628 var root = document.getElementById('passkey-add');
629 if (!root) return;
630 var button = document.getElementById('passkey-register');
631 var statusEl = document.getElementById('passkey-status');
632 var nameEl = document.getElementById('passkey-name');
633
634 function fail(message) {
635 statusEl.textContent = message;
636 statusEl.style.color = '#cf222e';
637 button.disabled = false;
638 }
639
640 if (!window.PublicKeyCredential) {
641 button.disabled = true;
642 statusEl.textContent = 'This browser does not support passkeys.';
643 return;
644 }
645
646 button.addEventListener('click', async function () {
647 button.disabled = true;
648 statusEl.style.color = '';
649 statusEl.textContent = 'Waiting for your authenticator…';
650 var headers = { 'Content-Type': 'application/json', 'X-CSRF-Token': root.dataset.csrf };
651 try {
652 var res = await fetch('/-/settings/passkeys/begin', { method: 'POST', headers: headers });
653 if (!res.ok) return fail(await res.text());
654 var begin = await res.json();
655 var credential = await navigator.credentials.create({
656 publicKey: anvilWebAuthn.toCreationOptions(begin.options.publicKey || begin.options),
657 });
658 if (!credential) return fail('No passkey was created.');
659 statusEl.textContent = 'Saving…';
660 var save = await fetch('/-/settings/passkeys/finish', {
661 method: 'POST',
662 headers: headers,
663 body: JSON.stringify({
664 ceremony: begin.ceremony,
665 handle: begin.handle,
666 name: nameEl.value,
667 credential: anvilWebAuthn.registrationJson(credential),
668 }),
669 });
670 if (!save.ok) return fail(await save.text());
671 location.reload();
672 } catch (e) {
673 // NotAllowedError is the user cancelling or letting the prompt time out.
674 fail(e.name === 'NotAllowedError' ? 'Cancelled.' : (e.message || String(e)));
675 }
676 });
677})();
678"#;
679
680/// Sign-in, driven from the login page.
681const LOGIN_JS: &str = r#"
682(function () {
683 var button = document.getElementById('passkey-login-btn');
684 if (!button) return;
685 var statusEl = document.getElementById('passkey-login-status');
686
687 function fail(message) {
688 statusEl.textContent = message;
689 statusEl.style.color = '#cf222e';
690 button.disabled = false;
691 }
692
693 if (!window.PublicKeyCredential) {
694 document.getElementById('passkey-login').style.display = 'none';
695 return;
696 }
697
698 button.addEventListener('click', async function () {
699 button.disabled = true;
700 statusEl.style.color = '';
701 statusEl.textContent = 'Waiting for your authenticator…';
702 try {
703 var res = await fetch('/-/login/passkey/begin', { method: 'POST' });
704 if (!res.ok) return fail(await res.text());
705 var begin = await res.json();
706 var credential = await navigator.credentials.get({
707 publicKey: anvilWebAuthn.toRequestOptions(begin.options.publicKey || begin.options),
708 });
709 if (!credential) return fail('No passkey was used.');
710 statusEl.textContent = 'Signing in…';
711 var done = await fetch('/-/login/passkey/finish', {
712 method: 'POST',
713 headers: { 'Content-Type': 'application/json' },
714 body: JSON.stringify({
715 ceremony: begin.ceremony,
716 credential: anvilWebAuthn.assertionJson(credential),
717 }),
718 });
719 if (!done.ok) return fail(await done.text());
720 var result = await done.json();
721 location.href = result.redirect || '/';
722 } catch (e) {
723 fail(e.name === 'NotAllowedError' ? 'Cancelled.' : (e.message || String(e)));
724 }
725 });
726})();
727"#;
728
729/// Emitted once per page that uses either ceremony.
730pub fn shared_script() -> Markup {
731 html! { script { (PreEscaped(WEBAUTHN_JS)) } }
732}