authx_plugins/magic_link/
service.rs1use 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#[derive(Debug)]
18pub struct MagicLinkVerifyResponse {
19 pub user: User,
20 pub session: Session,
21 pub token: String,
23}
24
25pub 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 #[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 #[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 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
119use rand::Rng;