1pub mod flow;
35
36use async_trait::async_trait;
37use base64::prelude::*;
38use std::future::Future;
39use std::pin::Pin;
40use std::sync::Arc;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum UserVerification {
45 Required,
46 Preferred,
47 Discouraged,
48}
49
50impl UserVerification {
51 fn parse(s: &str) -> Result<Self, PasskeyError> {
54 match s {
55 "required" => Ok(Self::Required),
56 "preferred" => Ok(Self::Preferred),
57 "discouraged" => Ok(Self::Discouraged),
58 other => Err(PasskeyError::InvalidOptions(format!(
59 "unsupported userVerification: {other}"
60 ))),
61 }
62 }
63}
64
65#[derive(Debug, Clone)]
69pub struct AssertionRequest {
70 pub challenge: Vec<u8>,
72 pub rp_id: Option<String>,
74 pub allow_credentials: Vec<Vec<u8>>,
76 pub user_verification: UserVerification,
77 pub timeout_ms: Option<u64>,
78 pub raw_options_json: String,
81}
82
83#[derive(Debug, Clone)]
85pub struct Assertion {
86 pub assertion_json: Vec<u8>,
89 pub credential_id: Vec<u8>,
91}
92
93#[derive(Debug, thiserror::Error)]
94#[non_exhaustive]
95pub enum PasskeyError {
96 #[error("no passkey registered for this account on the authenticator")]
97 NoCredential,
98 #[error("user cancelled or the ceremony timed out")]
99 Cancelled,
100 #[error("invalid request options: {0}")]
101 InvalidOptions(String),
102 #[error("authenticator backend error: {0}")]
103 Backend(String),
104 #[error("passkey linking flow error: {0}")]
105 Flow(String),
106}
107
108#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
116#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
117pub trait PasskeyAuthenticator: wacore::sync_marker::MaybeSendSync {
118 async fn get_assertion(&self, request: &AssertionRequest) -> Result<Assertion, PasskeyError>;
119}
120
121#[cfg(not(target_arch = "wasm32"))]
125type AssertionFuture = Pin<Box<dyn Future<Output = Result<Assertion, PasskeyError>> + Send>>;
126#[cfg(target_arch = "wasm32")]
127type AssertionFuture = Pin<Box<dyn Future<Output = Result<Assertion, PasskeyError>>>>;
128
129#[cfg(not(target_arch = "wasm32"))]
132type AssertionCallback = dyn Fn(AssertionRequest) -> AssertionFuture + Send + Sync;
133#[cfg(target_arch = "wasm32")]
134type AssertionCallback = dyn Fn(AssertionRequest) -> AssertionFuture;
135
136#[derive(Clone)]
142pub struct CallbackAuthenticator {
143 cb: Arc<AssertionCallback>,
144}
145
146impl CallbackAuthenticator {
147 pub fn new<F>(f: F) -> Self
148 where
149 F: Fn(AssertionRequest) -> AssertionFuture + wacore::sync_marker::MaybeSendSync + 'static,
150 {
151 Self { cb: Arc::new(f) }
152 }
153}
154
155#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
156#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
157impl PasskeyAuthenticator for CallbackAuthenticator {
158 async fn get_assertion(&self, request: &AssertionRequest) -> Result<Assertion, PasskeyError> {
159 (self.cb)(request.clone()).await
160 }
161}
162
163pub fn parse_request_options(json: &str) -> Result<AssertionRequest, PasskeyError> {
166 let v: serde_json::Value =
167 serde_json::from_str(json).map_err(|e| PasskeyError::InvalidOptions(e.to_string()))?;
168
169 let challenge_b64 = v
170 .get("challenge")
171 .and_then(|c| c.as_str())
172 .ok_or_else(|| PasskeyError::InvalidOptions("missing challenge".into()))?;
173 let challenge = BASE64_URL_SAFE_NO_PAD
174 .decode(challenge_b64.trim_end_matches('='))
175 .map_err(|e| PasskeyError::InvalidOptions(format!("challenge b64url: {e}")))?;
176 if challenge.is_empty() {
177 return Err(PasskeyError::InvalidOptions("empty challenge".into()));
178 }
179
180 let rp_id = match v.get("rpId") {
183 None => None,
184 Some(r) => Some(
185 r.as_str()
186 .ok_or_else(|| PasskeyError::InvalidOptions("rpId must be a string".into()))?
187 .to_string(),
188 ),
189 };
190
191 let mut allow_credentials = Vec::new();
195 if let Some(allow_credentials_value) = v.get("allowCredentials") {
196 let arr = allow_credentials_value.as_array().ok_or_else(|| {
197 PasskeyError::InvalidOptions("allowCredentials must be an array".into())
198 })?;
199 for cred in arr {
200 let id = cred.get("id").and_then(|i| i.as_str()).ok_or_else(|| {
201 PasskeyError::InvalidOptions("allowCredentials[].id must be a string".into())
202 })?;
203 let bytes = BASE64_URL_SAFE_NO_PAD
204 .decode(id.trim_end_matches('='))
205 .map_err(|e| PasskeyError::InvalidOptions(format!("credential id b64url: {e}")))?;
206 if bytes.is_empty() {
207 return Err(PasskeyError::InvalidOptions(
208 "allowCredentials[].id is empty".into(),
209 ));
210 }
211 allow_credentials.push(bytes);
212 }
213 }
214
215 let user_verification = match v.get("userVerification") {
216 None => UserVerification::Preferred,
217 Some(u) => UserVerification::parse(u.as_str().ok_or_else(|| {
218 PasskeyError::InvalidOptions("userVerification must be a string".into())
219 })?)?,
220 };
221
222 let timeout_ms = v.get("timeout").and_then(|t| t.as_u64());
223
224 Ok(AssertionRequest {
225 challenge,
226 rp_id,
227 allow_credentials,
228 user_verification,
229 timeout_ms,
230 raw_options_json: json.to_string(),
231 })
232}
233
234pub fn build_webauthn_assertion_json(
239 credential_id: &[u8],
240 client_data_json: &[u8],
241 authenticator_data: &[u8],
242 signature: &[u8],
243 user_handle: Option<&[u8]>,
244) -> Vec<u8> {
245 let id = BASE64_URL_SAFE_NO_PAD.encode(credential_id);
246 let assertion = serde_json::json!({
247 "id": id,
248 "rawId": id,
249 "type": "public-key",
250 "response": {
251 "clientDataJSON": BASE64_URL_SAFE_NO_PAD.encode(client_data_json),
252 "authenticatorData": BASE64_URL_SAFE_NO_PAD.encode(authenticator_data),
253 "signature": BASE64_URL_SAFE_NO_PAD.encode(signature),
254 "userHandle": user_handle.map(|u| BASE64_URL_SAFE_NO_PAD.encode(u)),
255 }
256 });
257 assertion.to_string().into_bytes()
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn parses_request_options() {
266 let challenge = b"the-challenge-bytes!";
267 let cred = b"credential-id-1";
268 let json = serde_json::json!({
269 "challenge": BASE64_URL_SAFE_NO_PAD.encode(challenge),
270 "rpId": "web.whatsapp.com",
271 "userVerification": "required",
272 "timeout": 60000u64,
273 "allowCredentials": [
274 {"type": "public-key", "id": BASE64_URL_SAFE_NO_PAD.encode(cred)}
275 ]
276 })
277 .to_string();
278
279 let req = parse_request_options(&json).unwrap();
280 assert_eq!(req.challenge, challenge);
281 assert_eq!(req.rp_id.as_deref(), Some("web.whatsapp.com"));
282 assert_eq!(req.user_verification, UserVerification::Required);
283 assert_eq!(req.timeout_ms, Some(60000));
284 assert_eq!(req.allow_credentials, vec![cred.to_vec()]);
285 assert_eq!(req.raw_options_json, json); }
287
288 #[test]
289 fn missing_challenge_is_error() {
290 assert!(parse_request_options("{\"rpId\":\"x\"}").is_err());
291 }
292
293 #[test]
294 fn unknown_user_verification_fails_closed() {
295 let json = serde_json::json!({
296 "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"),
297 "userVerification": "sometimes",
298 })
299 .to_string();
300 assert!(matches!(
301 parse_request_options(&json),
302 Err(PasskeyError::InvalidOptions(_))
303 ));
304 }
305
306 #[test]
307 fn absent_user_verification_defaults_to_preferred() {
308 let json =
309 serde_json::json!({ "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c") }).to_string();
310 let req = parse_request_options(&json).unwrap();
311 assert_eq!(req.user_verification, UserVerification::Preferred);
312 }
313
314 #[test]
315 fn malformed_allow_credentials_is_rejected() {
316 let json = serde_json::json!({
318 "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"),
319 "allowCredentials": "nope",
320 })
321 .to_string();
322 assert!(parse_request_options(&json).is_err());
323
324 let json = serde_json::json!({
326 "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"),
327 "allowCredentials": [{"type": "public-key"}],
328 })
329 .to_string();
330 assert!(parse_request_options(&json).is_err());
331
332 let json = serde_json::json!({
335 "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"),
336 "allowCredentials": [{"type": "public-key", "id": ""}],
337 })
338 .to_string();
339 assert!(parse_request_options(&json).is_err());
340 }
341
342 #[test]
343 fn empty_challenge_is_rejected() {
344 let json = serde_json::json!({ "challenge": "" }).to_string();
347 assert!(matches!(
348 parse_request_options(&json),
349 Err(PasskeyError::InvalidOptions(_))
350 ));
351 }
352
353 #[test]
354 fn non_string_rp_id_is_rejected() {
355 let json = serde_json::json!({
357 "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"),
358 "rpId": 123,
359 })
360 .to_string();
361 assert!(matches!(
362 parse_request_options(&json),
363 Err(PasskeyError::InvalidOptions(_))
364 ));
365 }
366
367 #[test]
368 fn builds_wa_assertion_json_shape() {
369 let bytes = build_webauthn_assertion_json(b"cid", b"cdj", b"authdata", b"sig", None);
370 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
371 assert_eq!(v["type"], "public-key");
372 assert_eq!(v["id"], BASE64_URL_SAFE_NO_PAD.encode(b"cid"));
373 assert_eq!(v["rawId"], BASE64_URL_SAFE_NO_PAD.encode(b"cid"));
374 assert_eq!(
375 v["response"]["clientDataJSON"],
376 BASE64_URL_SAFE_NO_PAD.encode(b"cdj")
377 );
378 assert_eq!(
379 v["response"]["signature"],
380 BASE64_URL_SAFE_NO_PAD.encode(b"sig")
381 );
382 assert!(v["response"]["userHandle"].is_null());
383 }
384
385 #[tokio::test]
386 async fn callback_authenticator_invokes_closure() {
387 let auth = CallbackAuthenticator::new(|req: AssertionRequest| {
388 Box::pin(async move {
389 Ok(Assertion {
390 assertion_json: req.raw_options_json.into_bytes(),
391 credential_id: req.challenge,
392 })
393 })
394 });
395 let req = AssertionRequest {
396 challenge: vec![1, 2, 3],
397 rp_id: Some("web.whatsapp.com".into()),
398 allow_credentials: vec![],
399 user_verification: UserVerification::Preferred,
400 timeout_ms: None,
401 raw_options_json: "{}".into(),
402 };
403 let a = auth.get_assertion(&req).await.unwrap();
404 assert_eq!(a.credential_id, vec![1, 2, 3]);
405 assert_eq!(a.assertion_json, b"{}".to_vec());
406 }
407}