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 const opts = PublicKeyCredential.parseCreationOptionsFromJSON(
40 JSON.parse(optionsJson).publicKey
41 );
42 const cred = await navigator.credentials.create({ publicKey: opts });
43 if (!cred) throw new Error("no credential was created");
44 return JSON.stringify(cred.toJSON());
45}
46
47// Resolves to the credential JSON, or to null when the request was cancelled
48// to make room for another one. A cancellation is not a failure and must not
49// reach the user.
50export async function passkeyGet(optionsJson, conditional) {
51 passkeyCancel();
52 const opts = PublicKeyCredential.parseRequestOptionsFromJSON(
53 JSON.parse(optionsJson).publicKey
54 );
55 const ctl = new AbortController();
56 pending = ctl;
57 try {
58 const req = { publicKey: opts, signal: ctl.signal };
59 if (conditional) req.mediation = "conditional";
60 const cred = await navigator.credentials.get(req);
61 if (!cred) throw new Error("no credential was returned");
62 const json = cred.toJSON();
63 // The server's parser wants the key present even when it is null, and
64 // not every browser includes it for a non-discoverable credential.
65 if (json.response && !("userHandle" in json.response)) {
66 json.response.userHandle = null;
67 }
68 return JSON.stringify(json);
69 } catch (e) {
70 if (e && e.name === "AbortError") return null;
71 throw e;
72 } finally {
73 if (pending === ctl) pending = null;
74 }
75}
76"#)]
77extern "C" {
78 #[wasm_bindgen(js_name = passkeySupported)]
79 fn js_supported() -> bool;
80
81 #[wasm_bindgen(js_name = conditionalSupported)]
82 async fn js_conditional_supported() -> JsValue;
83
84 #[wasm_bindgen(js_name = passkeyCancel)]
85 pub fn cancel();
86
87 #[wasm_bindgen(js_name = passkeyCreate, catch)]
88 async fn js_create(options: &str) -> Result<JsValue, JsValue>;
89
90 #[wasm_bindgen(js_name = passkeyGet, catch)]
91 async fn js_get(options: &str, conditional: bool) -> Result<JsValue, JsValue>;
92}
93
94/// Whether this browser can do WebAuthn at all. False hides every passkey
95/// control rather than offering one that cannot work.
96pub fn supported() -> bool {
97 js_supported()
98}
99
100/// Whether this browser offers passkeys in the autofill dropdown.
101pub async fn conditional_supported() -> bool {
102 js_conditional_supported().await.as_bool().unwrap_or(false)
103}
104
105/// Register a new credential. `options` is the server's challenge JSON.
106pub async fn create(options: &str) -> Result<String, String> {
107 js_create(options)
108 .await
109 .map_err(error_text)?
110 .as_string()
111 .ok_or_else(|| "the browser returned nothing".to_string())
112}
113
114/// Ask for an assertion. `Ok(None)` means the request was cancelled to make
115/// room for another one, which is not something the user needs to hear about.
116pub async fn get(options: &str, conditional: bool) -> Result<Option<String>, String> {
117 Ok(js_get(options, conditional)
118 .await
119 .map_err(error_text)?
120 .as_string())
121}
122
123/// One neutral message for every WebAuthn failure.
124///
125/// The API deliberately returns the same `NotAllowedError` whether the user
126/// cancelled or nothing matched, so there is nothing more specific to say.
127/// Claiming "you have no passkey here" would often be wrong.
128fn error_text(_e: JsValue) -> String {
129 crate::i18n::t(crate::i18n::k::PASSKEY_NOT_USED).to_string()
130}
131