passkey.rs
⎇
Raw
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
10use 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.
16let pending = null;
17
18export function passkeySupported() {
19 return typeof window.PublicKeyCredential === "function"
20 && typeof PublicKeyCredential.parseRequestOptionsFromJSON === "function"
21 && typeof PublicKeyCredential.parseCreationOptionsFromJSON === "function";
22}
23
24export 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
34export function passkeyCancel() {
35 if (pending) { pending.abort(); pending = null; }
36}
37
38export 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.
55export 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"#)]
83extern "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.
102pub fn supported() -> bool {
103 js_supported()
104}
105
106/// Whether this browser offers passkeys in the autofill dropdown.
107pub 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.
112pub 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.
122pub 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.
136fn 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