armature_auth/
passwordless.rs1use chrono::{DateTime, Duration, Utc};
33use rand::Rng;
34use serde::{Deserialize, Serialize};
35use thiserror::Error;
36
37#[cfg(feature = "webauthn")]
38use webauthn_rs::prelude::*;
39
40#[derive(Debug, Error)]
42pub enum PasswordlessError {
43 #[error("Invalid token")]
44 InvalidToken,
45
46 #[error("Token expired")]
47 TokenExpired,
48
49 #[error("Token already used")]
50 TokenUsed,
51
52 #[error("WebAuthn error: {0}")]
53 WebAuthn(String),
54
55 #[error("Feature not enabled: {0}")]
56 FeatureNotEnabled(&'static str),
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct MagicLinkToken {
64 pub token: String,
66
67 pub identifier: String,
69
70 pub created_at: DateTime<Utc>,
72
73 pub expires_at: DateTime<Utc>,
75
76 pub used: bool,
78}
79
80impl MagicLinkToken {
81 pub fn generate(
99 identifier: impl Into<String>,
100 ttl: std::time::Duration,
101 ) -> Result<Self, PasswordlessError> {
102 let mut rng = rand::rng();
103 let bytes: Vec<u8> = (0..32).map(|_| rng.random()).collect();
104 let token = hex::encode(bytes);
105
106 let now = Utc::now();
107 let expires_at =
108 now + Duration::from_std(ttl).map_err(|_| PasswordlessError::InvalidToken)?;
109
110 Ok(Self {
111 token,
112 identifier: identifier.into(),
113 created_at: now,
114 expires_at,
115 used: false,
116 })
117 }
118
119 pub fn verify(&self) -> Result<bool, PasswordlessError> {
125 if self.used {
126 return Err(PasswordlessError::TokenUsed);
127 }
128
129 if Utc::now() > self.expires_at {
130 return Err(PasswordlessError::TokenExpired);
131 }
132
133 Ok(true)
134 }
135
136 pub fn mark_used(&mut self) {
138 self.used = true;
139 }
140
141 pub fn to_url(&self, base_url: &str) -> String {
143 format!("{}?token={}", base_url, self.token)
144 }
145}
146
147#[cfg(feature = "webauthn")]
149#[derive(Debug, Clone)]
150pub struct WebAuthnConfig {
151 pub rp_id: String,
153
154 pub rp_name: String,
156
157 pub origin: Url,
159}
160
161#[cfg(feature = "webauthn")]
162impl WebAuthnConfig {
163 pub fn new(
164 rp_id: impl Into<String>,
165 rp_name: impl Into<String>,
166 origin: impl Into<String>,
167 ) -> Result<Self, PasswordlessError> {
168 Ok(Self {
169 rp_id: rp_id.into(),
170 rp_name: rp_name.into(),
171 origin: Url::parse(&origin.into())
172 .map_err(|e| PasswordlessError::WebAuthn(e.to_string()))?,
173 })
174 }
175}
176
177#[cfg(feature = "webauthn")]
179pub struct WebAuthnManager {
180 webauthn: Webauthn,
181}
182
183#[cfg(feature = "webauthn")]
184impl WebAuthnManager {
185 pub fn new(config: WebAuthnConfig) -> Result<Self, PasswordlessError> {
205 let rp_origin = config.origin.clone();
206 let builder = WebauthnBuilder::new(&config.rp_id, &rp_origin)
207 .map_err(|e| PasswordlessError::WebAuthn(e.to_string()))?;
208
209 let builder = builder.rp_name(&config.rp_name);
210
211 let webauthn = builder
212 .build()
213 .map_err(|e| PasswordlessError::WebAuthn(e.to_string()))?;
214
215 Ok(Self { webauthn })
216 }
217
218 pub fn start_registration(
222 &self,
223 user_id: &[u8],
224 username: &str,
225 display_name: &str,
226 ) -> Result<(CreationChallengeResponse, PasskeyRegistration), PasswordlessError> {
227 let user_unique_id = UserId::from(user_id);
228
229 let (ccr, reg_state) = self
230 .webauthn
231 .start_passkey_registration(user_unique_id, username, display_name, None)
232 .map_err(|e| PasswordlessError::WebAuthn(e.to_string()))?;
233
234 Ok((ccr, reg_state))
235 }
236
237 pub fn finish_registration(
241 &self,
242 reg: &RegisterPublicKeyCredential,
243 state: &PasskeyRegistration,
244 ) -> Result<Passkey, PasswordlessError> {
245 self.webauthn
246 .finish_passkey_registration(reg, state)
247 .map_err(|e| PasswordlessError::WebAuthn(e.to_string()))
248 }
249
250 pub fn start_authentication(
254 &self,
255 passkeys: &[Passkey],
256 ) -> Result<(RequestChallengeResponse, PasskeyAuthentication), PasswordlessError> {
257 self.webauthn
258 .start_passkey_authentication(passkeys)
259 .map_err(|e| PasswordlessError::WebAuthn(e.to_string()))
260 }
261
262 pub fn finish_authentication(
266 &self,
267 auth: &PublicKeyCredential,
268 state: &PasskeyAuthentication,
269 ) -> Result<AuthenticationResult, PasswordlessError> {
270 self.webauthn
271 .finish_passkey_authentication(auth, state)
272 .map_err(|e| PasswordlessError::WebAuthn(e.to_string()))
273 }
274}
275
276#[cfg(not(feature = "webauthn"))]
277pub struct WebAuthnManager;
278
279#[cfg(not(feature = "webauthn"))]
280impl WebAuthnManager {
281 pub fn new(_config: ()) -> Result<Self, PasswordlessError> {
282 Err(PasswordlessError::FeatureNotEnabled("webauthn"))
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn test_generate_magic_link() {
292 let token =
293 MagicLinkToken::generate("user@example.com", std::time::Duration::from_secs(3600))
294 .unwrap();
295
296 assert!(!token.token.is_empty());
297 assert_eq!(token.identifier, "user@example.com");
298 assert!(!token.used);
299 }
300
301 #[test]
302 fn test_verify_magic_link() {
303 let token =
304 MagicLinkToken::generate("user@example.com", std::time::Duration::from_secs(3600))
305 .unwrap();
306
307 assert!(token.verify().is_ok());
308 }
309
310 #[test]
311 fn test_magic_link_url() {
312 let token =
313 MagicLinkToken::generate("user@example.com", std::time::Duration::from_secs(3600))
314 .unwrap();
315
316 let url = token.to_url("https://example.com/auth/verify");
317 assert!(url.starts_with("https://example.com/auth/verify?token="));
318 }
319
320 #[test]
321 fn test_magic_link_used() {
322 let mut token =
323 MagicLinkToken::generate("user@example.com", std::time::Duration::from_secs(3600))
324 .unwrap();
325
326 token.mark_used();
327 assert!(matches!(token.verify(), Err(PasswordlessError::TokenUsed)));
328 }
329
330 #[test]
331 fn test_expired_magic_link() {
332 let token = MagicLinkToken::generate(
333 "user@example.com",
334 std::time::Duration::from_secs(0), )
336 .unwrap();
337
338 std::thread::sleep(std::time::Duration::from_millis(10));
339 assert!(matches!(
340 token.verify(),
341 Err(PasswordlessError::TokenExpired)
342 ));
343 }
344}