Skip to main content

authx_plugins/magic_link/
service.rs

1use std::time::Duration;
2
3use chrono::Utc;
4use tracing::instrument;
5
6use authx_core::{
7    crypto::sha256_hex,
8    error::{AuthError, Result},
9    events::{AuthEvent, EventBus},
10    models::{CreateSession, Session, User},
11};
12use authx_storage::ports::{SessionRepository, UserRepository};
13
14use crate::one_time_token::{OneTimeTokenStore, TokenKind};
15
16/// Returned when a magic link is successfully verified.
17#[derive(Debug)]
18pub struct MagicLinkVerifyResponse {
19    pub user: User,
20    pub session: Session,
21    /// Raw session token — send to client once; store SHA-256 hash server-side.
22    pub token: String,
23}
24
25/// Magic link authentication service.
26///
27/// # Flow
28/// 1. App calls `request_link(email)` → gets a raw token (send in email yourself).
29/// 2. User clicks link → app calls `verify(token, ip)` → session is created.
30///
31/// The magic link token is single-use and expires after `ttl` (default 15 min).
32pub struct MagicLinkService<S> {
33    storage: S,
34    events: EventBus,
35    token_store: OneTimeTokenStore,
36    session_ttl_secs: i64,
37}
38
39impl<S> MagicLinkService<S>
40where
41    S: UserRepository + SessionRepository + Clone + Send + Sync + 'static,
42{
43    pub fn new(storage: S, events: EventBus, session_ttl_secs: i64) -> Self {
44        Self {
45            storage,
46            events,
47            token_store: OneTimeTokenStore::new(Duration::from_secs(15 * 60)),
48            session_ttl_secs,
49        }
50    }
51
52    pub fn with_link_ttl(mut self, ttl: Duration) -> Self {
53        self.token_store = OneTimeTokenStore::new(ttl);
54        self
55    }
56
57    /// Issue a magic link token for the given email.
58    ///
59    /// Returns `None` for unknown emails (to avoid user enumeration).
60    #[instrument(skip(self), fields(email = %email))]
61    pub async fn request_link(&self, email: &str) -> Result<Option<String>> {
62        let user = match UserRepository::find_by_email(&self.storage, email).await? {
63            Some(u) => u,
64            None => {
65                tracing::debug!("magic link requested for unknown email");
66                return Ok(None);
67            }
68        };
69
70        let token = self.token_store.issue(user.id, TokenKind::MagicLink);
71        tracing::info!(user_id = %user.id, "magic link token issued");
72        Ok(Some(token))
73    }
74
75    /// Consume the token, create a session, and return auth credentials.
76    #[instrument(skip(self, raw_token), fields(ip = %ip))]
77    pub async fn verify(&self, raw_token: &str, ip: &str) -> Result<MagicLinkVerifyResponse> {
78        let user_id = self
79            .token_store
80            .consume(raw_token, TokenKind::MagicLink)
81            .ok_or(AuthError::InvalidToken)?;
82
83        let user = UserRepository::find_by_id(&self.storage, user_id)
84            .await?
85            .ok_or(AuthError::UserNotFound)?;
86
87        // Generate session token.
88        let raw_session_token: [u8; 32] = rand::thread_rng().gen();
89        let raw_session_str = hex::encode(raw_session_token);
90        let token_hash = sha256_hex(raw_session_str.as_bytes());
91
92        let session = SessionRepository::create(
93            &self.storage,
94            CreateSession {
95                user_id: user.id,
96                token_hash,
97                device_info: serde_json::Value::Null,
98                ip_address: ip.to_owned(),
99                org_id: None,
100                expires_at: Utc::now() + chrono::Duration::seconds(self.session_ttl_secs),
101            },
102        )
103        .await?;
104
105        self.events.emit(AuthEvent::SignIn {
106            user: user.clone(),
107            session: session.clone(),
108        });
109        tracing::info!(user_id = %user_id, session_id = %session.id, "magic link sign-in complete");
110
111        Ok(MagicLinkVerifyResponse {
112            user,
113            session,
114            token: raw_session_str,
115        })
116    }
117}
118
119// pull in rand/hex for the session token generation
120use rand::Rng;