pub mod flow;
use async_trait::async_trait;
use base64::prelude::*;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UserVerification {
Required,
Preferred,
Discouraged,
}
impl UserVerification {
fn parse(s: &str) -> Result<Self, PasskeyError> {
match s {
"required" => Ok(Self::Required),
"preferred" => Ok(Self::Preferred),
"discouraged" => Ok(Self::Discouraged),
other => Err(PasskeyError::InvalidOptions(format!(
"unsupported userVerification: {other}"
))),
}
}
}
#[derive(Debug, Clone)]
pub struct AssertionRequest {
pub challenge: Vec<u8>,
pub rp_id: Option<String>,
pub allow_credentials: Vec<Vec<u8>>,
pub user_verification: UserVerification,
pub timeout_ms: Option<u64>,
pub raw_options_json: String,
}
#[derive(Debug, Clone)]
pub struct Assertion {
pub assertion_json: Vec<u8>,
pub credential_id: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PasskeyError {
#[error("no passkey registered for this account on the authenticator")]
NoCredential,
#[error("user cancelled or the ceremony timed out")]
Cancelled,
#[error("invalid request options: {0}")]
InvalidOptions(String),
#[error("authenticator backend error: {0}")]
Backend(String),
#[error("passkey linking flow error: {0}")]
Flow(String),
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait PasskeyAuthenticator: wacore::sync_marker::MaybeSendSync {
async fn get_assertion(&self, request: &AssertionRequest) -> Result<Assertion, PasskeyError>;
}
#[cfg(not(target_arch = "wasm32"))]
type AssertionFuture = Pin<Box<dyn Future<Output = Result<Assertion, PasskeyError>> + Send>>;
#[cfg(target_arch = "wasm32")]
type AssertionFuture = Pin<Box<dyn Future<Output = Result<Assertion, PasskeyError>>>>;
#[cfg(not(target_arch = "wasm32"))]
type AssertionCallback = dyn Fn(AssertionRequest) -> AssertionFuture + Send + Sync;
#[cfg(target_arch = "wasm32")]
type AssertionCallback = dyn Fn(AssertionRequest) -> AssertionFuture;
#[derive(Clone)]
pub struct CallbackAuthenticator {
cb: Arc<AssertionCallback>,
}
impl CallbackAuthenticator {
pub fn new<F>(f: F) -> Self
where
F: Fn(AssertionRequest) -> AssertionFuture + wacore::sync_marker::MaybeSendSync + 'static,
{
Self { cb: Arc::new(f) }
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl PasskeyAuthenticator for CallbackAuthenticator {
async fn get_assertion(&self, request: &AssertionRequest) -> Result<Assertion, PasskeyError> {
(self.cb)(request.clone()).await
}
}
pub fn parse_request_options(json: &str) -> Result<AssertionRequest, PasskeyError> {
let v: serde_json::Value =
serde_json::from_str(json).map_err(|e| PasskeyError::InvalidOptions(e.to_string()))?;
let challenge_b64 = v
.get("challenge")
.and_then(|c| c.as_str())
.ok_or_else(|| PasskeyError::InvalidOptions("missing challenge".into()))?;
let challenge = BASE64_URL_SAFE_NO_PAD
.decode(challenge_b64.trim_end_matches('='))
.map_err(|e| PasskeyError::InvalidOptions(format!("challenge b64url: {e}")))?;
if challenge.is_empty() {
return Err(PasskeyError::InvalidOptions("empty challenge".into()));
}
let rp_id = match v.get("rpId") {
None => None,
Some(r) => Some(
r.as_str()
.ok_or_else(|| PasskeyError::InvalidOptions("rpId must be a string".into()))?
.to_string(),
),
};
let mut allow_credentials = Vec::new();
if let Some(allow_credentials_value) = v.get("allowCredentials") {
let arr = allow_credentials_value.as_array().ok_or_else(|| {
PasskeyError::InvalidOptions("allowCredentials must be an array".into())
})?;
for cred in arr {
let id = cred.get("id").and_then(|i| i.as_str()).ok_or_else(|| {
PasskeyError::InvalidOptions("allowCredentials[].id must be a string".into())
})?;
let bytes = BASE64_URL_SAFE_NO_PAD
.decode(id.trim_end_matches('='))
.map_err(|e| PasskeyError::InvalidOptions(format!("credential id b64url: {e}")))?;
if bytes.is_empty() {
return Err(PasskeyError::InvalidOptions(
"allowCredentials[].id is empty".into(),
));
}
allow_credentials.push(bytes);
}
}
let user_verification = match v.get("userVerification") {
None => UserVerification::Preferred,
Some(u) => UserVerification::parse(u.as_str().ok_or_else(|| {
PasskeyError::InvalidOptions("userVerification must be a string".into())
})?)?,
};
let timeout_ms = v.get("timeout").and_then(|t| t.as_u64());
Ok(AssertionRequest {
challenge,
rp_id,
allow_credentials,
user_verification,
timeout_ms,
raw_options_json: json.to_string(),
})
}
pub fn build_webauthn_assertion_json(
credential_id: &[u8],
client_data_json: &[u8],
authenticator_data: &[u8],
signature: &[u8],
user_handle: Option<&[u8]>,
) -> Vec<u8> {
let id = BASE64_URL_SAFE_NO_PAD.encode(credential_id);
let assertion = serde_json::json!({
"id": id,
"rawId": id,
"type": "public-key",
"response": {
"clientDataJSON": BASE64_URL_SAFE_NO_PAD.encode(client_data_json),
"authenticatorData": BASE64_URL_SAFE_NO_PAD.encode(authenticator_data),
"signature": BASE64_URL_SAFE_NO_PAD.encode(signature),
"userHandle": user_handle.map(|u| BASE64_URL_SAFE_NO_PAD.encode(u)),
}
});
assertion.to_string().into_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_request_options() {
let challenge = b"the-challenge-bytes!";
let cred = b"credential-id-1";
let json = serde_json::json!({
"challenge": BASE64_URL_SAFE_NO_PAD.encode(challenge),
"rpId": "web.whatsapp.com",
"userVerification": "required",
"timeout": 60000u64,
"allowCredentials": [
{"type": "public-key", "id": BASE64_URL_SAFE_NO_PAD.encode(cred)}
]
})
.to_string();
let req = parse_request_options(&json).unwrap();
assert_eq!(req.challenge, challenge);
assert_eq!(req.rp_id.as_deref(), Some("web.whatsapp.com"));
assert_eq!(req.user_verification, UserVerification::Required);
assert_eq!(req.timeout_ms, Some(60000));
assert_eq!(req.allow_credentials, vec![cred.to_vec()]);
assert_eq!(req.raw_options_json, json); }
#[test]
fn missing_challenge_is_error() {
assert!(parse_request_options("{\"rpId\":\"x\"}").is_err());
}
#[test]
fn unknown_user_verification_fails_closed() {
let json = serde_json::json!({
"challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"),
"userVerification": "sometimes",
})
.to_string();
assert!(matches!(
parse_request_options(&json),
Err(PasskeyError::InvalidOptions(_))
));
}
#[test]
fn absent_user_verification_defaults_to_preferred() {
let json =
serde_json::json!({ "challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c") }).to_string();
let req = parse_request_options(&json).unwrap();
assert_eq!(req.user_verification, UserVerification::Preferred);
}
#[test]
fn malformed_allow_credentials_is_rejected() {
let json = serde_json::json!({
"challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"),
"allowCredentials": "nope",
})
.to_string();
assert!(parse_request_options(&json).is_err());
let json = serde_json::json!({
"challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"),
"allowCredentials": [{"type": "public-key"}],
})
.to_string();
assert!(parse_request_options(&json).is_err());
let json = serde_json::json!({
"challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"),
"allowCredentials": [{"type": "public-key", "id": ""}],
})
.to_string();
assert!(parse_request_options(&json).is_err());
}
#[test]
fn empty_challenge_is_rejected() {
let json = serde_json::json!({ "challenge": "" }).to_string();
assert!(matches!(
parse_request_options(&json),
Err(PasskeyError::InvalidOptions(_))
));
}
#[test]
fn non_string_rp_id_is_rejected() {
let json = serde_json::json!({
"challenge": BASE64_URL_SAFE_NO_PAD.encode(b"c"),
"rpId": 123,
})
.to_string();
assert!(matches!(
parse_request_options(&json),
Err(PasskeyError::InvalidOptions(_))
));
}
#[test]
fn builds_wa_assertion_json_shape() {
let bytes = build_webauthn_assertion_json(b"cid", b"cdj", b"authdata", b"sig", None);
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["type"], "public-key");
assert_eq!(v["id"], BASE64_URL_SAFE_NO_PAD.encode(b"cid"));
assert_eq!(v["rawId"], BASE64_URL_SAFE_NO_PAD.encode(b"cid"));
assert_eq!(
v["response"]["clientDataJSON"],
BASE64_URL_SAFE_NO_PAD.encode(b"cdj")
);
assert_eq!(
v["response"]["signature"],
BASE64_URL_SAFE_NO_PAD.encode(b"sig")
);
assert!(v["response"]["userHandle"].is_null());
}
#[tokio::test]
async fn callback_authenticator_invokes_closure() {
let auth = CallbackAuthenticator::new(|req: AssertionRequest| {
Box::pin(async move {
Ok(Assertion {
assertion_json: req.raw_options_json.into_bytes(),
credential_id: req.challenge,
})
})
});
let req = AssertionRequest {
challenge: vec![1, 2, 3],
rp_id: Some("web.whatsapp.com".into()),
allow_credentials: vec![],
user_verification: UserVerification::Preferred,
timeout_ms: None,
raw_options_json: "{}".into(),
};
let a = auth.get_assertion(&req).await.unwrap();
assert_eq!(a.credential_id, vec![1, 2, 3]);
assert_eq!(a.assertion_json, b"{}".to_vec());
}
}