//! The browser half of WebAuthn. //! //! `navigator.credentials` deals in `ArrayBuffer`s, and the wire format is //! base64url. The platform converts between the two itself — //! `parseCreationOptionsFromJSON` on the way in, `toJSON()` on the way out — //! so this module is a thin shim rather than an encoder. `web-sys` also has //! WebAuthn bindings, but only behind `--cfg web_sys_unstable_apis`, which //! would infect the whole build. use wasm_bindgen::prelude::*; #[wasm_bindgen(inline_js = r#" // One outstanding navigator.credentials.get(), at most. A conditional // request sits in the autofill dropdown until the user touches the field, so // pressing the button has to cancel it before starting its own. let pending = null; export function passkeySupported() { return typeof window.PublicKeyCredential === "function" && typeof PublicKeyCredential.parseRequestOptionsFromJSON === "function" && typeof PublicKeyCredential.parseCreationOptionsFromJSON === "function"; } export async function conditionalSupported() { if (!passkeySupported()) return false; if (typeof PublicKeyCredential.isConditionalMediationAvailable !== "function") return false; try { return await PublicKeyCredential.isConditionalMediationAvailable(); } catch (e) { return false; } } export function passkeyCancel() { if (pending) { pending.abort(); pending = null; } } export async function passkeyCreate(optionsJson) { try { const opts = PublicKeyCredential.parseCreationOptionsFromJSON( JSON.parse(optionsJson).publicKey ); const cred = await navigator.credentials.create({ publicKey: opts }); if (!cred) throw new Error("no credential was created"); return JSON.stringify(cred.toJSON()); } catch (e) { console.error("passkey registration failed", e); throw e; } } // Resolves to the credential JSON, or to null when the request was cancelled // to make room for another one. A cancellation is not a failure and must not // reach the user. export async function passkeyGet(optionsJson, conditional) { passkeyCancel(); const opts = PublicKeyCredential.parseRequestOptionsFromJSON( JSON.parse(optionsJson).publicKey ); const ctl = new AbortController(); pending = ctl; try { const req = { publicKey: opts, signal: ctl.signal }; if (conditional) req.mediation = "conditional"; const cred = await navigator.credentials.get(req); if (!cred) throw new Error("no credential was returned"); const json = cred.toJSON(); // The server's parser wants the key present even when it is null, and // not every browser includes it for a non-discoverable credential. if (json.response && !("userHandle" in json.response)) { json.response.userHandle = null; } return JSON.stringify(json); } catch (e) { if (e && e.name === "AbortError") return null; console.error("passkey assertion failed", e); throw e; } finally { if (pending === ctl) pending = null; } } "#)] extern "C" { /// Whether this browser can do WebAuthn at all. False hides every passkey /// control rather than offering one that cannot work. #[wasm_bindgen(js_name = passkeySupported)] pub fn supported() -> bool; #[wasm_bindgen(js_name = conditionalSupported)] async fn js_conditional_supported() -> JsValue; #[wasm_bindgen(js_name = passkeyCancel)] pub fn cancel(); #[wasm_bindgen(js_name = passkeyCreate, catch)] async fn js_create(options: &str) -> Result; #[wasm_bindgen(js_name = passkeyGet, catch)] async fn js_get(options: &str, conditional: bool) -> Result; } /// Whether this browser offers passkeys in the autofill dropdown. pub async fn conditional_supported() -> bool { js_conditional_supported().await.as_bool().unwrap_or(false) } /// Register a new credential. `options` is the server's challenge JSON. pub async fn create(options: &str) -> Result { js_create(options) .await .map_err(error_text)? .as_string() .ok_or_else(|| "the browser returned nothing".to_string()) } /// Ask for an assertion. `Ok(None)` means the request was cancelled to make /// room for another one, which is not something the user needs to hear about. pub async fn get(options: &str, conditional: bool) -> Result, String> { Ok(js_get(options, conditional) .await .map_err(error_text)? .as_string()) } /// One neutral message for every WebAuthn failure. /// /// The API deliberately returns the same `NotAllowedError` whether the user /// cancelled or nothing matched, so there is nothing more specific to say. /// Claiming "you have no passkey here" would often be wrong. Any other name /// is a deployment fault, not a user choice — `SecurityError` means the RP ID /// does not match the browser's origin — so it is worth showing. fn error_text(e: JsValue) -> String { let text = crate::i18n::t(crate::i18n::k::PASSKEY_NOT_USED).to_string(); match js_sys::Reflect::get(&e, &JsValue::from_str("name")) .ok() .and_then(|v| v.as_string()) { Some(name) if name != "NotAllowedError" => format!("{text} ({name})"), _ => text, } }