torii_auth_magic_link/
lib.rs

1//! Magic Link authentication plugin for Torii
2//!
3//! This plugin provides magic link authentication functionality, allowing users to sign in by clicking
4//! a secure link sent to their email address rather than using a password.
5//!
6//! # Features
7//!
8//! - Generate secure one-time use magic links
9//! - Verify magic link tokens
10//! - Automatic user creation if not exists
11//! - Configurable token expiration
12//!
13//! # Example
14//!
15//! ```rust,no_run
16//! use torii_auth_magic_link::MagicLinkPlugin;
17//! use torii_core::{DefaultUserManager, plugin::PluginManager};
18//! use std::sync::Arc;
19//!
20//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
21//! # let user_storage = Arc::new(torii_storage_sqlite::SqliteStorage::connect("sqlite::memory:").await.unwrap());
22//! # let session_storage = user_storage.clone();
23//! # user_storage.migrate().await.unwrap();
24//!
25//! // Create user manager
26//! let user_manager = Arc::new(DefaultUserManager::new(user_storage.clone()));
27//!
28//! // Register the plugin with your PluginManager
29//! let mut plugin_manager = PluginManager::new(user_storage.clone(), session_storage.clone());
30//! plugin_manager.register_plugin(MagicLinkPlugin::new(user_manager, user_storage.clone()));
31//!
32//! // Generate a magic link token
33//! let plugin = plugin_manager.get_plugin::<MagicLinkPlugin<DefaultUserManager<_>, _>>("magic_link").unwrap();
34//! let token = plugin.generate_magic_token("user@example.com").await?;
35//!
36//! // Verify the token when user clicks the link
37//! let user = plugin.verify_magic_token(&token.token).await?;
38//! # Ok(())
39//! # }
40//! ```
41use std::sync::Arc;
42
43use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD};
44use chrono::Utc;
45use rand::TryRngCore;
46use torii_core::{
47    Plugin, User, UserManager,
48    storage::{MagicLinkStorage, MagicToken},
49};
50
51#[derive(Debug, thiserror::Error)]
52pub enum MagicLinkError {
53    #[error("User not found")]
54    UserNotFound,
55
56    #[error("Token expired")]
57    TokenExpired,
58
59    #[error("Token already used")]
60    TokenAlreadyUsed,
61
62    #[error("Storage error: {0}")]
63    StorageError(String),
64}
65
66/// Magic Link authentication plugin
67///
68/// This plugin provides magic link authentication functionality, allowing users to sign in by clicking
69/// a secure link sent to their email address rather than using a password.
70///
71/// # Features
72///
73/// - Generate secure one-time use magic links
74/// - Verify magic link tokens
75/// - Automatic user creation if not exists
76///
77/// # Example
78///
79/// ```rust,no_run
80/// use torii_auth_magic_link::MagicLinkPlugin;
81/// use torii_core::{DefaultUserManager, plugin::PluginManager};
82/// use std::sync::Arc;
83///
84/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
85/// # let user_storage = Arc::new(torii_storage_sqlite::SqliteStorage::connect("sqlite::memory:").await.unwrap());
86/// # let session_storage = user_storage.clone();
87/// # user_storage.migrate().await.unwrap();
88///
89/// // Create user manager
90/// let user_manager = Arc::new(DefaultUserManager::new(user_storage.clone()));
91///
92/// // Register the plugin with your PluginManager
93/// let mut plugin_manager = PluginManager::new(user_storage.clone(), session_storage.clone());
94/// plugin_manager.register_plugin(MagicLinkPlugin::new(user_manager, user_storage.clone()));
95///
96/// // Generate a magic link token
97/// let plugin = plugin_manager.get_plugin::<MagicLinkPlugin<DefaultUserManager<_>, _>>("magic_link").unwrap();
98/// let token = plugin.generate_magic_token("user@example.com").await?;
99///
100/// // Verify the token when user clicks the link
101/// let user = plugin.verify_magic_token(&token.token).await?;
102/// # Ok(())
103/// # }
104/// ```
105pub struct MagicLinkPlugin<M, S>
106where
107    M: UserManager,
108    S: MagicLinkStorage,
109{
110    user_manager: Arc<M>,
111    magic_link_storage: Arc<S>,
112}
113
114impl<M, S> Plugin for MagicLinkPlugin<M, S>
115where
116    M: UserManager,
117    S: MagicLinkStorage,
118{
119    fn name(&self) -> String {
120        "magic_link".to_string()
121    }
122}
123
124impl<M, S> MagicLinkPlugin<M, S>
125where
126    M: UserManager,
127    S: MagicLinkStorage,
128{
129    pub fn new(user_manager: Arc<M>, magic_link_storage: Arc<S>) -> Self {
130        Self {
131            user_manager,
132            magic_link_storage,
133        }
134    }
135
136    pub async fn generate_magic_token(&self, email: &str) -> Result<MagicToken, MagicLinkError> {
137        let user = self
138            .user_manager
139            .get_or_create_user_by_email(email)
140            .await
141            .map_err(|e| MagicLinkError::StorageError(e.to_string()))?;
142
143        let now = Utc::now();
144        let token = MagicToken::new(
145            user.id,
146            generate_secure_token(),
147            None,
148            now + chrono::Duration::minutes(10),
149            now,
150            now,
151        );
152
153        self.magic_link_storage
154            .save_magic_token(&token)
155            .await
156            .map_err(|e| MagicLinkError::StorageError(e.to_string()))?;
157
158        Ok(token)
159    }
160
161    pub async fn verify_magic_token(&self, token: &str) -> Result<User, MagicLinkError> {
162        let magic_token = self
163            .magic_link_storage
164            .get_magic_token(token)
165            .await
166            .map_err(|e| MagicLinkError::StorageError(e.to_string()))?
167            .ok_or(MagicLinkError::UserNotFound)?;
168
169        if magic_token.expires_at < Utc::now() {
170            return Err(MagicLinkError::TokenExpired);
171        }
172
173        if magic_token.used() {
174            return Err(MagicLinkError::TokenAlreadyUsed);
175        }
176
177        self.magic_link_storage
178            .set_magic_token_used(&magic_token.token)
179            .await
180            .map_err(|e| MagicLinkError::StorageError(e.to_string()))?;
181
182        // Get the user
183        let user = self
184            .user_manager
185            .get_user(&magic_token.user_id)
186            .await
187            .map_err(|e| MagicLinkError::StorageError(e.to_string()))?
188            .ok_or(MagicLinkError::UserNotFound)?;
189
190        Ok(user)
191    }
192}
193
194/// Generate a secure token
195///
196/// This function generates a secure token using the `rand` crate.
197/// The token is encoded using the `BASE64_URL_SAFE_NO_PAD` engine.
198///
199/// # Returns
200/// A secure token as a string
201///
202/// # Example
203///
204/// ```rust,no_run
205/// let token = generate_secure_token();
206/// ```
207fn generate_secure_token() -> String {
208    let mut token = [0u8; 32];
209    rand::rngs::OsRng
210        .try_fill_bytes(&mut token)
211        .expect("Failed to generate secure bytes");
212    encode_token(&token)
213}
214
215fn encode_token(token: &[u8]) -> String {
216    BASE64_URL_SAFE_NO_PAD.encode(token)
217}
218
219#[cfg(test)]
220mod tests {
221    use torii_core::DefaultUserManager;
222    use torii_storage_sqlite::SqliteStorage;
223
224    use super::*;
225
226    async fn setup_sqlite_storage() -> Arc<SqliteStorage> {
227        let storage = SqliteStorage::connect("sqlite::memory:").await.unwrap();
228        storage.migrate().await.unwrap();
229        Arc::new(storage)
230    }
231
232    #[tokio::test]
233    async fn test_magic_link_plugin() {
234        let storage = setup_sqlite_storage().await;
235        let user_manager = Arc::new(DefaultUserManager::new(storage.clone()));
236        let plugin = MagicLinkPlugin::new(user_manager, storage);
237        assert_eq!(plugin.name(), "magic_link");
238    }
239
240    #[tokio::test]
241    async fn test_generate_magic_token() {
242        let storage = setup_sqlite_storage().await;
243        let user_manager = Arc::new(DefaultUserManager::new(storage.clone()));
244        let plugin = MagicLinkPlugin::new(user_manager, storage);
245        let token = plugin
246            .generate_magic_token("test@example.com")
247            .await
248            .unwrap();
249        assert_ne!(token.token, "");
250    }
251
252    #[tokio::test]
253    async fn test_verify_magic_token() {
254        let storage = setup_sqlite_storage().await;
255        let user_manager = Arc::new(DefaultUserManager::new(storage.clone()));
256        let plugin = MagicLinkPlugin::new(user_manager, storage);
257        let token = plugin
258            .generate_magic_token("test@example.com")
259            .await
260            .unwrap();
261        let user = plugin.verify_magic_token(&token.token).await.unwrap();
262        assert_eq!(user.id, token.user_id);
263    }
264}