Skip to main content

arcly_http_identity/
passwordless.rs

1//! Passwordless authentication built on [`OneTimeTokenService`] + [`Notifier`]:
2//! magic-links (email) and one-time codes (email or SMS).
3
4use std::sync::Arc;
5
6use arcly_http_core::resilience::distributed_rate_limit::{RateDecision, RateLimitBackend};
7
8use crate::error::{IdentityError, Result};
9use crate::identity::IdentityService;
10use crate::model::TokenResponse;
11use crate::notify::{Channel, Notification, NotificationKind, Notifier};
12use crate::ott::{OneTimeTokenService, OttPurpose};
13use crate::store::UserStore;
14
15/// Per-destination send throttle (H1) — caps how often a magic link / OTP can be
16/// requested for one address, defeating email/SMS bombing and the cost abuse it
17/// causes. Fail-closed: if the backend is down, sends are refused.
18struct SendLimit {
19    backend: Arc<dyn RateLimitBackend>,
20    max: u32,
21    window_secs: u32,
22}
23
24impl SendLimit {
25    async fn check(&self, destination: &str) -> Result<()> {
26        let key = format!("pwless_send::{}", destination.to_ascii_lowercase());
27        match self.backend.hit(&key, self.max, self.window_secs).await {
28            RateDecision::Allow { .. } => Ok(()),
29            RateDecision::Deny { .. } => Err(IdentityError::RateLimited),
30            RateDecision::Unavailable => Err(IdentityError::Unavailable),
31        }
32    }
33}
34
35/// Orchestrates the passwordless flows. `link_base` is the front-end URL the
36/// magic-link token is appended to (e.g. `https://app.example.com/auth/magic`).
37pub struct PasswordlessService {
38    users: Arc<dyn UserStore>,
39    ott: Arc<OneTimeTokenService>,
40    notifier: Arc<dyn Notifier>,
41    identity: IdentityService,
42    link_base: String,
43    magic_ttl_secs: u64,
44    otp_ttl_secs: u64,
45    otp_digits: u32,
46    limit: Option<SendLimit>,
47}
48
49impl PasswordlessService {
50    pub fn new(
51        users: Arc<dyn UserStore>,
52        ott: Arc<OneTimeTokenService>,
53        notifier: Arc<dyn Notifier>,
54        identity: IdentityService,
55        link_base: impl Into<String>,
56    ) -> Self {
57        Self {
58            users,
59            ott,
60            notifier,
61            identity,
62            link_base: link_base.into(),
63            magic_ttl_secs: 900,
64            otp_ttl_secs: 300,
65            otp_digits: 6,
66            limit: None,
67        }
68    }
69
70    /// Enable per-destination send throttling: at most `max` sends per
71    /// `window_secs` for any one address (fail-closed). Reuses the framework's
72    /// distributed rate-limit backend.
73    pub fn with_send_limit(
74        mut self,
75        backend: Arc<dyn RateLimitBackend>,
76        max: u32,
77        window_secs: u32,
78    ) -> Self {
79        self.limit = Some(SendLimit {
80            backend,
81            max,
82            window_secs,
83        });
84        self
85    }
86
87    /// Start a magic-link login: issue a token and email it as a link. Always
88    /// returns `Ok(())` even when the email is unknown, so the response cannot
89    /// enumerate accounts.
90    pub async fn start_magic_link(&self, tenant: Option<&str>, email: &str) -> Result<()> {
91        // Throttle *before* the user lookup so an unknown address is limited too
92        // (blocks enumeration-by-timing and bombing of arbitrary inboxes).
93        if let Some(limit) = &self.limit {
94            limit.check(email).await?;
95        }
96        if let Some(user) = self.users.find_by_email(tenant, email).await? {
97            let token = self
98                .ott
99                .issue(&user.id, OttPurpose::MagicLink, self.magic_ttl_secs)
100                .await?;
101            let link = format!("{}?token={}", self.link_base, token);
102            self.notifier
103                .send(Notification {
104                    to: email.to_owned(),
105                    channel: Channel::Email,
106                    kind: NotificationKind::MagicLink,
107                    payload: link,
108                })
109                .await?;
110        }
111        Ok(())
112    }
113
114    /// Redeem a magic-link token → issue tokens.
115    pub async fn complete_magic_link(&self, token: &str) -> Result<TokenResponse> {
116        let user_id = self.ott.consume(token, OttPurpose::MagicLink).await?;
117        let user = self
118            .users
119            .find_by_id(&user_id)
120            .await?
121            .ok_or(IdentityError::NotFound)?;
122        self.identity.mint(&user).await
123    }
124
125    /// Start OTP login: send a numeric code over `channel`. Enumeration-safe.
126    pub async fn start_otp(
127        &self,
128        tenant: Option<&str>,
129        destination: &str,
130        channel: Channel,
131    ) -> Result<()> {
132        if let Some(limit) = &self.limit {
133            limit.check(destination).await?;
134        }
135        // For SMS the destination is a phone; for email it's the address. We
136        // look up by email here; a phone-based lookup is a store concern.
137        if let Some(user) = self.users.find_by_email(tenant, destination).await? {
138            let code = self
139                .ott
140                .issue_numeric(
141                    &user.id,
142                    OttPurpose::LoginOtp,
143                    self.otp_digits,
144                    self.otp_ttl_secs,
145                )
146                .await?;
147            self.notifier
148                .send(Notification {
149                    to: destination.to_owned(),
150                    channel,
151                    kind: NotificationKind::LoginOtp,
152                    payload: code,
153                })
154                .await?;
155        }
156        Ok(())
157    }
158
159    /// Complete OTP login → issue tokens.
160    pub async fn complete_otp(&self, code: &str) -> Result<TokenResponse> {
161        let user_id = self.ott.consume(code, OttPurpose::LoginOtp).await?;
162        let user = self
163            .users
164            .find_by_id(&user_id)
165            .await?
166            .ok_or(IdentityError::NotFound)?;
167        self.identity.mint(&user).await
168    }
169}