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 /// Whether this browser can do WebAuthn at all. False hides every passkey
85 /// control rather than offering one that cannot work.
86 #[wasm_bindgen(js_name = passkeySupported)]
87 pub fn supported() -> bool;
88
89 #[wasm_bindgen(js_name = conditionalSupported)]
90 async fn js_conditional_supported() -> JsValue;
91
92 #[wasm_bindgen(js_name = passkeyCancel)]
93 pub fn cancel();
94
95 #[wasm_bindgen(js_name = passkeyCreate, catch)]
96 async fn js_create(options: &str) -> Result<JsValue, JsValue>;
97
98 #[wasm_bindgen(js_name = passkeyGet, catch)]
99 async fn js_get(options: &str, conditional: bool) -> Result<JsValue, JsValue>;
100}
101
102/// Whether this browser offers passkeys in the autofill dropdown.
103pub async fn conditional_supported() -> bool {
104 js_conditional_supported().await.as_bool().unwrap_or(false)
105}
106
107/// Register a new credential. `options` is the server's challenge JSON.
108pub async fn create(options: &str) -> Result<String, String> {
109 js_create(options)
110 .await
111 .map_err(error_text)?
112 .as_string()
113 .ok_or_else(|| "the browser returned nothing".to_string())
114}
115
116/// Ask for an assertion. `Ok(None)` means the request was cancelled to make
117/// room for another one, which is not something the user needs to hear about.
118pub async fn get(options: &str, conditional: bool) -> Result<Option<String>, String> {
119 Ok(js_get(options, conditional)
120 .await
121 .map_err(error_text)?
122 .as_string())
123}
124
125/// One neutral message for every WebAuthn failure.
126///
127/// The API deliberately returns the same `NotAllowedError` whether the user
128/// cancelled or nothing matched, so there is nothing more specific to say.
129/// Claiming "you have no passkey here" would often be wrong. Any other name
130/// is a deployment fault, not a user choice — `SecurityError` means the RP ID
131/// does not match the browser's origin — so it is worth showing.
132fn error_text(e: JsValue) -> String {
133 let text = crate::i18n::t(crate::i18n::k::PASSKEY_NOT_USED).to_string();
134 match js_sys::Reflect::get(&e, &JsValue::from_str("name"))
135 .ok()
136 .and_then(|v| v.as_string())
137 {
138 Some(name) if name != "NotAllowedError" => format!("{text} ({name})"),
139 _ => text,
140 }
141}
142