armature_auth/
passwordless.rs

1//! Passwordless Authentication
2//!
3//! Provides magic link and WebAuthn passwordless authentication.
4//!
5//! # Features
6//!
7//! - Magic link generation and verification
8//! - Email-based passwordless login
9//! - WebAuthn registration and authentication
10//! - Time-limited tokens
11//!
12//! # Usage
13//!
14//! ```no_run
15//! use armature_auth::passwordless::*;
16//!
17//! # async fn example() -> Result<(), PasswordlessError> {
18//! // Generate magic link
19//! let token = MagicLinkToken::generate("user@example.com", std::time::Duration::from_secs(3600))?;
20//! let link = format!("https://myapp.com/auth/verify?token={}", token.token);
21//!
22//! // Send link via email...
23//!
24//! // Later, verify the token
25//! if token.verify()? {
26//!     println!("Magic link valid!");
27//! }
28//! # Ok(())
29//! # }
30//! ```
31
32use 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/// Passwordless authentication errors
41#[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/// Magic Link Token
60///
61/// Time-limited token for passwordless authentication via email.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct MagicLinkToken {
64    /// The token string
65    pub token: String,
66
67    /// User identifier (email, user ID, etc.)
68    pub identifier: String,
69
70    /// Creation timestamp
71    pub created_at: DateTime<Utc>,
72
73    /// Expiration timestamp
74    pub expires_at: DateTime<Utc>,
75
76    /// Whether token has been used
77    pub used: bool,
78}
79
80impl MagicLinkToken {
81    /// Generate a new magic link token
82    ///
83    /// # Examples
84    ///
85    /// ```
86    /// use armature_auth::passwordless::*;
87    ///
88    /// # fn example() -> Result<(), PasswordlessError> {
89    /// let token = MagicLinkToken::generate(
90    ///     "user@example.com",
91    ///     std::time::Duration::from_secs(3600) // 1 hour
92    /// )?;
93    ///
94    /// println!("Token: {}", token.token);
95    /// # Ok(())
96    /// # }
97    /// ```
98    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    /// Verify the token is valid
120    ///
121    /// Checks:
122    /// - Not expired
123    /// - Not already used
124    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    /// Mark token as used
137    pub fn mark_used(&mut self) {
138        self.used = true;
139    }
140
141    /// Create verification URL
142    pub fn to_url(&self, base_url: &str) -> String {
143        format!("{}?token={}", base_url, self.token)
144    }
145}
146
147/// WebAuthn Configuration
148#[cfg(feature = "webauthn")]
149#[derive(Debug, Clone)]
150pub struct WebAuthnConfig {
151    /// Relying party ID (your domain)
152    pub rp_id: String,
153
154    /// Relying party name
155    pub rp_name: String,
156
157    /// Origin URL
158    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/// WebAuthn Manager
178#[cfg(feature = "webauthn")]
179pub struct WebAuthnManager {
180    webauthn: Webauthn,
181}
182
183#[cfg(feature = "webauthn")]
184impl WebAuthnManager {
185    /// Create new WebAuthn manager
186    ///
187    /// # Examples
188    ///
189    /// ```no_run
190    /// use armature_auth::passwordless::*;
191    ///
192    /// # #[cfg(feature = "webauthn")]
193    /// # fn example() -> Result<(), PasswordlessError> {
194    /// let config = WebAuthnConfig::new(
195    ///     "example.com",
196    ///     "Example App",
197    ///     "https://example.com"
198    /// )?;
199    ///
200    /// let manager = WebAuthnManager::new(config)?;
201    /// # Ok(())
202    /// # }
203    /// ```
204    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    /// Start registration (credential creation)
219    ///
220    /// Returns challenge that should be sent to client.
221    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    /// Finish registration
238    ///
239    /// Verifies client response and returns credential.
240    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    /// Start authentication
251    ///
252    /// Returns challenge that should be sent to client.
253    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    /// Finish authentication
263    ///
264    /// Verifies client response.
265    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), // Already expired
335        )
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}