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