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}