passkey.rs
| 1 | //! The browser half of WebAuthn. |
| 2 | //! |
| 3 | //! `navigator.credentials` deals in `ArrayBuffer`s, and the wire format is |
| 4 | //! base64url. The platform converts between the two itself — |
| 5 | //! `parseCreationOptionsFromJSON` on the way in, `toJSON()` on the way out — |
| 6 | //! so this module is a thin shim rather than an encoder. `web-sys` also has |
| 7 | //! WebAuthn bindings, but only behind `--cfg web_sys_unstable_apis`, which |
| 8 | //! would infect the whole build. |
| 9 | |
| 10 | use wasm_bindgen::prelude::*; |
| 11 | |
| 12 | #[wasm_bindgen(inline_js = r#" |
| 13 | // One outstanding navigator.credentials.get(), at most. A conditional |
| 14 | // request sits in the autofill dropdown until the user touches the field, so |
| 15 | // pressing the button has to cancel it before starting its own. |
| 16 | let pending = null; |
| 17 | |
| 18 | export function passkeySupported() { |
| 19 | return typeof window.PublicKeyCredential === "function" |
| 20 | && typeof PublicKeyCredential.parseRequestOptionsFromJSON === "function" |
| 21 | && typeof PublicKeyCredential.parseCreationOptionsFromJSON === "function"; |
| 22 | } |
| 23 | |
| 24 | export async function conditionalSupported() { |
| 25 | if (!passkeySupported()) return false; |
| 26 | if (typeof PublicKeyCredential.isConditionalMediationAvailable !== "function") return false; |
| 27 | try { |
| 28 | return await PublicKeyCredential.isConditionalMediationAvailable(); |
| 29 | } catch (e) { |
| 30 | return false; |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | export function passkeyCancel() { |
| 35 | if (pending) { pending.abort(); pending = null; } |
| 36 | } |
| 37 | |
| 38 | export async function passkeyCreate(optionsJson) { |
| 39 | try { |
| 40 | const opts = PublicKeyCredential.parseCreationOptionsFromJSON( |
| 41 | JSON.parse(optionsJson).publicKey |
| 42 | ); |
| 43 | const cred = await navigator.credentials.create({ publicKey: opts }); |
| 44 | if (!cred) throw new Error("no credential was created"); |
| 45 | return JSON.stringify(cred.toJSON()); |
| 46 | } catch (e) { |
| 47 | console.error("passkey registration failed", e); |
| 48 | throw e; |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | // Resolves to the credential JSON, or to null when the request was cancelled |
| 53 | // to make room for another one. A cancellation is not a failure and must not |
| 54 | // reach the user. |
| 55 | export async function passkeyGet(optionsJson, conditional) { |
| 56 | passkeyCancel(); |
| 57 | const opts = PublicKeyCredential.parseRequestOptionsFromJSON( |
| 58 | JSON.parse(optionsJson).publicKey |
| 59 | ); |
| 60 | const ctl = new AbortController(); |
| 61 | pending = ctl; |
| 62 | try { |
| 63 | const req = { publicKey: opts, signal: ctl.signal }; |
| 64 | if (conditional) req.mediation = "conditional"; |
| 65 | const cred = await navigator.credentials.get(req); |
| 66 | if (!cred) throw new Error("no credential was returned"); |
| 67 | const json = cred.toJSON(); |
| 68 | // The server's parser wants the key present even when it is null, and |
| 69 | // not every browser includes it for a non-discoverable credential. |
| 70 | if (json.response && !("userHandle" in json.response)) { |
| 71 | json.response.userHandle = null; |
| 72 | } |
| 73 | return JSON.stringify(json); |
| 74 | } catch (e) { |
| 75 | if (e && e.name === "AbortError") return null; |
| 76 | console.error("passkey assertion failed", e); |
| 77 | throw e; |
| 78 | } finally { |
| 79 | if (pending === ctl) pending = null; |
| 80 | } |
| 81 | } |
| 82 | "#)] |
| 83 | extern "C" { |
| 84 | #[wasm_bindgen(js_name = passkeySupported)] |
| 85 | fn js_supported() -> bool; |
| 86 | |
| 87 | #[wasm_bindgen(js_name = conditionalSupported)] |
| 88 | async fn js_conditional_supported() -> JsValue; |
| 89 | |
| 90 | #[wasm_bindgen(js_name = passkeyCancel)] |
| 91 | pub fn cancel(); |
| 92 | |
| 93 | #[wasm_bindgen(js_name = passkeyCreate, catch)] |
| 94 | async fn js_create(options: &str) -> Result<JsValue, JsValue>; |
| 95 | |
| 96 | #[wasm_bindgen(js_name = passkeyGet, catch)] |
| 97 | async fn js_get(options: &str, conditional: bool) -> Result<JsValue, JsValue>; |
| 98 | } |
| 99 | |
| 100 | /// Whether this browser can do WebAuthn at all. False hides every passkey |
| 101 | /// control rather than offering one that cannot work. |
| 102 | pub fn supported() -> bool { |
| 103 | js_supported() |
| 104 | } |
| 105 | |
| 106 | /// Whether this browser offers passkeys in the autofill dropdown. |
| 107 | pub async fn conditional_supported() -> bool { |
| 108 | js_conditional_supported().await.as_bool().unwrap_or(false) |
| 109 | } |
| 110 | |
| 111 | /// Register a new credential. `options` is the server's challenge JSON. |
| 112 | pub async fn create(options: &str) -> Result<String, String> { |
| 113 | js_create(options) |
| 114 | .await |
| 115 | .map_err(error_text)? |
| 116 | .as_string() |
| 117 | .ok_or_else(|| "the browser returned nothing".to_string()) |
| 118 | } |
| 119 | |
| 120 | /// Ask for an assertion. `Ok(None)` means the request was cancelled to make |
| 121 | /// room for another one, which is not something the user needs to hear about. |
| 122 | pub async fn get(options: &str, conditional: bool) -> Result<Option<String>, String> { |
| 123 | Ok(js_get(options, conditional) |
| 124 | .await |
| 125 | .map_err(error_text)? |
| 126 | .as_string()) |
| 127 | } |
| 128 | |
| 129 | /// One neutral message for every WebAuthn failure. |
| 130 | /// |
| 131 | /// The API deliberately returns the same `NotAllowedError` whether the user |
| 132 | /// cancelled or nothing matched, so there is nothing more specific to say. |
| 133 | /// Claiming "you have no passkey here" would often be wrong. Any other name |
| 134 | /// is a deployment fault, not a user choice — `SecurityError` means the RP ID |
| 135 | /// does not match the browser's origin — so it is worth showing. |
| 136 | fn error_text(e: JsValue) -> String { |
| 137 | let text = crate::i18n::t(crate::i18n::k::PASSKEY_NOT_USED).to_string(); |
| 138 | match js_sys::Reflect::get(&e, &JsValue::from_str("name")) |
| 139 | .ok() |
| 140 | .and_then(|v| v.as_string()) |
| 141 | { |
| 142 | Some(name) if name != "NotAllowedError" => format!("{text} ({name})"), |
| 143 | _ => text, |
| 144 | } |
| 145 | } |
| 146 |