torii_core/services/
magic_link.rs

1use crate::{
2    Error, User,
3    repositories::{TokenRepository, UserRepository},
4    services::UserService,
5    storage::{SecureToken, TokenPurpose},
6};
7use chrono::Duration;
8use std::sync::Arc;
9
10/// Service for magic link authentication operations
11pub struct MagicLinkService<U: UserRepository, T: TokenRepository> {
12    user_service: Arc<UserService<U>>,
13    token_repository: Arc<T>,
14}
15
16impl<U: UserRepository, T: TokenRepository> MagicLinkService<U, T> {
17    /// Create a new MagicLinkService with the given repositories
18    pub fn new(user_repository: Arc<U>, token_repository: Arc<T>) -> Self {
19        let user_service = Arc::new(UserService::new(user_repository));
20        Self {
21            user_service,
22            token_repository,
23        }
24    }
25
26    /// Generate a magic token for a user
27    pub async fn generate_token(&self, email: &str) -> Result<SecureToken, Error> {
28        // Ensure user exists (or create them) - email validation happens in UserService
29        let user = self.user_service.get_or_create_user(email).await?;
30
31        // Generate the token with default expiration (15 minutes)
32        let expires_in = Duration::minutes(15);
33        self.token_repository
34            .create_token(&user.id, TokenPurpose::MagicLink, expires_in)
35            .await
36    }
37
38    /// Generate a magic token with custom expiration
39    pub async fn generate_token_with_expiration(
40        &self,
41        email: &str,
42        expires_in: Duration,
43    ) -> Result<SecureToken, Error> {
44        // Ensure user exists (or create them) - email validation happens in UserService
45        let user = self.user_service.get_or_create_user(email).await?;
46
47        self.token_repository
48            .create_token(&user.id, TokenPurpose::MagicLink, expires_in)
49            .await
50    }
51
52    /// Verify a magic token and return the associated user
53    pub async fn verify_token(&self, token: &str) -> Result<Option<User>, Error> {
54        // Verify and consume the token
55        let secure_token = self
56            .token_repository
57            .verify_token(token, TokenPurpose::MagicLink)
58            .await?;
59
60        if let Some(secure_token) = secure_token {
61            // Get the user by ID
62            let user = self.user_service.get_user(&secure_token.user_id).await?;
63            Ok(user)
64        } else {
65            Ok(None)
66        }
67    }
68
69    /// Clean up expired tokens
70    pub async fn cleanup_expired_tokens(&self) -> Result<(), Error> {
71        self.token_repository.cleanup_expired_tokens().await
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::repositories::{TokenRepository, UserRepository};
79    use crate::storage::{SecureToken, TokenPurpose};
80    use crate::{User, UserId};
81    use async_trait::async_trait;
82    use chrono::{DateTime, Utc};
83    use std::collections::HashMap;
84    use std::sync::Arc;
85    use tokio::sync::Mutex;
86
87    // Mock implementations for testing
88    #[derive(Debug, Clone)]
89    struct MockUser {
90        id: UserId,
91        email: String,
92        name: Option<String>,
93        created_at: DateTime<Utc>,
94        updated_at: DateTime<Utc>,
95    }
96
97    impl From<MockUser> for User {
98        fn from(user: MockUser) -> Self {
99            User {
100                id: user.id,
101                email: user.email,
102                name: user.name,
103                email_verified_at: None,
104                created_at: user.created_at,
105                updated_at: user.updated_at,
106            }
107        }
108    }
109
110    #[derive(Default)]
111    struct MockUserRepository {
112        users: Arc<Mutex<HashMap<UserId, MockUser>>>,
113        users_by_email: Arc<Mutex<HashMap<String, MockUser>>>,
114    }
115
116    #[async_trait]
117    impl UserRepository for MockUserRepository {
118        async fn create(&self, new_user: crate::storage::NewUser) -> Result<User, Error> {
119            let user = MockUser {
120                id: UserId::new_random(),
121                email: new_user.email.clone(),
122                name: new_user.name,
123                created_at: Utc::now(),
124                updated_at: Utc::now(),
125            };
126
127            self.users
128                .lock()
129                .await
130                .insert(user.id.clone(), user.clone());
131            self.users_by_email
132                .lock()
133                .await
134                .insert(new_user.email, user.clone());
135            Ok(user.into())
136        }
137
138        async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, Error> {
139            Ok(self.users.lock().await.get(id).cloned().map(Into::into))
140        }
141
142        async fn find_by_email(&self, email: &str) -> Result<Option<User>, Error> {
143            Ok(self
144                .users_by_email
145                .lock()
146                .await
147                .get(email)
148                .cloned()
149                .map(Into::into))
150        }
151
152        async fn find_or_create_by_email(&self, email: &str) -> Result<User, Error> {
153            if let Some(user) = self.find_by_email(email).await? {
154                Ok(user)
155            } else {
156                let new_user = crate::storage::NewUser::builder()
157                    .email(email.to_string())
158                    .build()
159                    .unwrap();
160                self.create(new_user).await
161            }
162        }
163
164        async fn update(&self, _user: &User) -> Result<User, Error> {
165            unimplemented!()
166        }
167
168        async fn delete(&self, _id: &UserId) -> Result<(), Error> {
169            unimplemented!()
170        }
171
172        async fn mark_email_verified(&self, _user_id: &UserId) -> Result<(), Error> {
173            Ok(())
174        }
175    }
176
177    #[derive(Default)]
178    struct MockTokenRepository {
179        tokens: Arc<Mutex<HashMap<String, SecureToken>>>,
180    }
181
182    #[async_trait]
183    impl TokenRepository for MockTokenRepository {
184        async fn create_token(
185            &self,
186            user_id: &UserId,
187            purpose: TokenPurpose,
188            expires_in: Duration,
189        ) -> Result<SecureToken, Error> {
190            let token_str = "test_token_123".to_string();
191            let expires_at = Utc::now() + expires_in;
192
193            let secure_token = SecureToken::new(
194                user_id.clone(),
195                token_str.clone(),
196                purpose,
197                None,
198                expires_at,
199                Utc::now(),
200                Utc::now(),
201            );
202
203            self.tokens
204                .lock()
205                .await
206                .insert(token_str, secure_token.clone());
207
208            Ok(secure_token)
209        }
210
211        async fn verify_token(
212            &self,
213            token: &str,
214            purpose: TokenPurpose,
215        ) -> Result<Option<SecureToken>, Error> {
216            let mut tokens = self.tokens.lock().await;
217            if let Some(secure_token) = tokens.get(token) {
218                if secure_token.purpose == purpose
219                    && secure_token.expires_at > Utc::now()
220                    && secure_token.used_at.is_none()
221                {
222                    let mut verified_token = secure_token.clone();
223                    verified_token.used_at = Some(Utc::now());
224                    verified_token.updated_at = Utc::now();
225                    tokens.insert(token.to_string(), verified_token.clone());
226                    Ok(Some(verified_token))
227                } else {
228                    Ok(None)
229                }
230            } else {
231                Ok(None)
232            }
233        }
234
235        async fn check_token(&self, token: &str, purpose: TokenPurpose) -> Result<bool, Error> {
236            let tokens = self.tokens.lock().await;
237            if let Some(secure_token) = tokens.get(token) {
238                Ok(secure_token.purpose == purpose
239                    && secure_token.expires_at > Utc::now()
240                    && secure_token.used_at.is_none())
241            } else {
242                Ok(false)
243            }
244        }
245
246        async fn cleanup_expired_tokens(&self) -> Result<(), Error> {
247            let mut tokens = self.tokens.lock().await;
248            let now = Utc::now();
249            tokens.retain(|_, token| token.expires_at > now);
250            Ok(())
251        }
252    }
253
254    #[tokio::test]
255    async fn test_generate_token() {
256        let user_repo = Arc::new(MockUserRepository::default());
257        let token_repo = Arc::new(MockTokenRepository::default());
258        let service = MagicLinkService::new(user_repo, token_repo);
259
260        let result = service.generate_token("test@example.com").await;
261        assert!(result.is_ok());
262
263        let token = result.unwrap();
264        assert_eq!(token.token, "test_token_123");
265        assert_eq!(token.purpose, TokenPurpose::MagicLink);
266    }
267
268    #[tokio::test]
269    async fn test_generate_token_with_expiration() {
270        let user_repo = Arc::new(MockUserRepository::default());
271        let token_repo = Arc::new(MockTokenRepository::default());
272        let service = MagicLinkService::new(user_repo, token_repo);
273
274        let expires_in = Duration::minutes(30);
275        let result = service
276            .generate_token_with_expiration("test@example.com", expires_in)
277            .await;
278        assert!(result.is_ok());
279
280        let token = result.unwrap();
281        assert_eq!(token.purpose, TokenPurpose::MagicLink);
282    }
283
284    #[tokio::test]
285    async fn test_verify_token_success() {
286        let user_repo = Arc::new(MockUserRepository::default());
287        let token_repo = Arc::new(MockTokenRepository::default());
288        let service = MagicLinkService::new(user_repo, token_repo);
289
290        // First generate a token
291        let token = service.generate_token("test@example.com").await.unwrap();
292
293        // Then verify it
294        let result = service.verify_token(&token.token).await;
295        assert!(result.is_ok());
296
297        let user = result.unwrap();
298        assert!(user.is_some());
299        assert_eq!(user.unwrap().email, "test@example.com");
300    }
301
302    #[tokio::test]
303    async fn test_verify_token_not_found() {
304        let user_repo = Arc::new(MockUserRepository::default());
305        let token_repo = Arc::new(MockTokenRepository::default());
306        let service = MagicLinkService::new(user_repo, token_repo);
307
308        let result = service.verify_token("invalid_token").await;
309        assert!(result.is_ok());
310        assert!(result.unwrap().is_none());
311    }
312
313    #[tokio::test]
314    async fn test_cleanup_expired_tokens() {
315        let user_repo = Arc::new(MockUserRepository::default());
316        let token_repo = Arc::new(MockTokenRepository::default());
317        let service = MagicLinkService::new(user_repo, token_repo);
318
319        let result = service.cleanup_expired_tokens().await;
320        assert!(result.is_ok());
321    }
322}