crates/monetize-ui-web/src/passkey.rs
//! The browser half of the passkey ceremony, through `js_sys::Reflect` — no
//! hand-written JavaScript. WebAuthn Level 3 gives the two conversions that
//! make this small: `PublicKeyCredential.parseCreationOptionsFromJSON` /
//! `parseRequestOptionsFromJSON` turn the server's base64url JSON into the
//! options object, and `credential.toJSON()` turns the answer back into the
//! base64url JSON the server verifies.
use js_sys::{Function, Object, Promise, Reflect};
use serde_json::Value;
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture;
/// `"<DOMException name>: <message>"` — the NAME is what tells a key that is already enrolled
/// (`InvalidStateError`) from a cancelled touch (`NotAllowedError`); `fmt::Fail::passkey_not_created`
/// reads it. A thrown value with no name keeps only its message.
fn err(e: JsValue) -> String {
let get = |k: &str| {
Reflect::get(&e, &k.into())
.ok()
.and_then(|m| m.as_string())
.filter(|s| !s.is_empty())
};
let message = get("message").unwrap_or_else(|| format!("{e:?}"));
match get("name") {
Some(name) => format!("{name}: {message}"),
None => message,
}
}
fn method(target: &JsValue, name: &str) -> Result<Function, String> {
Reflect::get(target, &name.into())
.map_err(err)?
.dyn_into::<Function>()
.map_err(|_| format!("this browser has no {name}; passkeys need a current browser"))
}
/// `navigator.credentials.<verb>({ publicKey: <parsed options> }).toJSON()`.
async fn ceremony(verb: &str, parser: &str, public_key: &Value) -> Result<Value, String> {
let window = web_sys::window().ok_or("no window")?;
let pkc = Reflect::get(&window, &"PublicKeyCredential".into()).map_err(err)?;
let parsed = method(&pkc, parser)?
.call1(
&pkc,
&js_sys::JSON::parse(&public_key.to_string()).map_err(err)?,
)
.map_err(err)?;
let options = Object::new();
Reflect::set(&options, &"publicKey".into(), &parsed).map_err(err)?;
let credentials = Reflect::get(&window.navigator(), &"credentials".into()).map_err(err)?;
let promise: Promise = method(&credentials, verb)?
.call1(&credentials, &options)
.map_err(err)?
.dyn_into()
.map_err(|_| "credentials call returned no promise".to_owned())?;
let credential = JsFuture::from(promise).await.map_err(err)?;
let json = method(&credential, "toJSON")?
.call0(&credential)
.map_err(err)?;
let text = js_sys::JSON::stringify(&json)
.map_err(err)?
.as_string()
.ok_or("toJSON produced no string")?;
serde_json::from_str(&text).map_err(|e| e.to_string())
}
/// Register: `options` is the server's `register/start` answer (`ceremony_id` + `publicKey`).
pub async fn create(options: &Value) -> Result<Value, String> {
ceremony(
"create",
"parseCreationOptionsFromJSON",
&options["publicKey"],
)
.await
}
/// Log in: `options` is the server's `login/start` answer.
pub async fn get(options: &Value) -> Result<Value, String> {
ceremony("get", "parseRequestOptionsFromJSON", &options["publicKey"]).await
}