Skip to main content

fraiseql_auth/anonymous/
mod.rs

1//! Anonymous session creation — `POST /auth/v1/signup`.
2//!
3//! Issues a guest session with a `7`-day `TTL` without requiring any credentials.
4//! The resulting `user_id` carries an `anon_` prefix so application code can
5//! distinguish anonymous visitors from authenticated users.
6//!
7//! # Upgrade path
8//!
9//! When an anonymous user later completes a social or email auth flow, the
10//! application calls [`upgrade_anonymous_session`] to atomically swap the
11//! `anon_` prefix for a stable identity.
12//!
13//! # Security
14//!
15//! - Rate-limited to `ANON_RATE_MAX` signups per IP per `ANON_RATE_WINDOW_SECS`.
16//! - Each anonymous `user_id` is a `UUIDv4` with an `anon_` prefix (unpredictable).
17
18use std::{net::SocketAddr, sync::Arc};
19
20use axum::{
21    Json,
22    extract::{ConnectInfo, State},
23    http::StatusCode,
24    response::{IntoResponse, Response},
25};
26use dashmap::DashMap;
27use serde::Serialize;
28use uuid::Uuid;
29
30use crate::{
31    audit::logger::{AuditEventType, SecretType, get_audit_logger},
32    error::Result,
33    session::{SessionStore, unix_now},
34};
35
36// ─── Constants ────────────────────────────────────────────────────────────────
37
38/// Anonymous session `TTL` (7 days).
39const ANON_SESSION_TTL_SECS: u64 = 7 * 24 * 3600;
40
41/// Rate-limit window for anonymous signups (1 hour).
42const ANON_RATE_WINDOW_SECS: u64 = 3_600;
43
44/// Maximum anonymous signups per IP per rate-limit window.
45const ANON_RATE_MAX: u32 = 10;
46
47// ─── Rate-limit record ────────────────────────────────────────────────────────
48
49#[derive(Debug, Clone)]
50struct RateRecord {
51    /// Count of signups in the current window.
52    count:        u32,
53    /// Start of the current window (Unix seconds).
54    window_start: u64,
55}
56
57// ─── Route state ──────────────────────────────────────────────────────────────
58
59/// Axum route state for `POST /auth/v1/signup`.
60#[derive(Clone)]
61pub struct AnonSignupState {
62    /// Session store used to issue the anonymous session.
63    pub session_store: Arc<dyn SessionStore>,
64    /// Per-IP signup rate-limit counters.
65    rate_counters:     Arc<DashMap<String, RateRecord>>,
66}
67
68impl AnonSignupState {
69    /// Create a new signup state wrapping the given session store.
70    #[must_use]
71    pub fn new(session_store: Arc<dyn SessionStore>) -> Self {
72        Self {
73            session_store,
74            rate_counters: Arc::new(DashMap::new()),
75        }
76    }
77
78    /// Check whether `ip` is within the rate limit.
79    ///
80    /// Increments the counter if allowed; returns `false` if the limit is exceeded.
81    fn check_rate_limit(&self, ip: &str, now: u64) -> bool {
82        let mut record = self.rate_counters.entry(ip.to_string()).or_insert(RateRecord {
83            count:        0,
84            window_start: now,
85        });
86
87        // Reset window if it has elapsed.
88        if now.saturating_sub(record.window_start) >= ANON_RATE_WINDOW_SECS {
89            record.count = 0;
90            record.window_start = now;
91        }
92
93        if record.count >= ANON_RATE_MAX {
94            return false;
95        }
96        record.count += 1;
97        true
98    }
99}
100
101// ─── Response types ───────────────────────────────────────────────────────────
102
103/// Response for `POST /auth/v1/signup`.
104#[derive(Debug, Serialize)]
105pub struct AnonSignupResponse {
106    /// Anonymous user identifier (`anon_<uuid>`).
107    pub user_id:       String,
108    /// Short-lived access token.
109    pub access_token:  String,
110    /// Long-lived refresh token.
111    pub refresh_token: String,
112    /// Seconds until the access token expires.
113    pub expires_in:    u64,
114}
115
116// ─── Handler ──────────────────────────────────────────────────────────────────
117
118/// `POST /auth/v1/signup` — issue an anonymous session.
119///
120/// Returns `200 OK` with a [`AnonSignupResponse`] on success.
121/// Returns `429 Too Many Requests` if the IP rate limit is exceeded.
122///
123/// # Errors
124///
125/// Returns `500 Internal Server Error` if the session store fails.
126pub async fn anon_signup(
127    State(state): State<Arc<AnonSignupState>>,
128    ConnectInfo(addr): ConnectInfo<SocketAddr>,
129) -> Response {
130    let logger = get_audit_logger();
131    let ip = addr.ip().to_string();
132
133    let now = match unix_now() {
134        Ok(t) => t,
135        Err(e) => {
136            logger.log_failure(
137                AuditEventType::AuthFailure,
138                SecretType::SessionToken,
139                None,
140                "anon_signup:clock",
141                &e.to_string(),
142            );
143            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
144        },
145    };
146
147    // Rate-limit check.
148    if !state.check_rate_limit(&ip, now) {
149        logger.log_failure(
150            AuditEventType::AuthFailure,
151            SecretType::SessionToken,
152            None,
153            "anon_signup:rate_limited",
154            "too many anonymous signups from this IP",
155        );
156        return (
157            StatusCode::TOO_MANY_REQUESTS,
158            Json(serde_json::json!({"error": "rate_limited"})),
159        )
160            .into_response();
161    }
162
163    let user_id = format!("anon_{}", Uuid::new_v4().as_simple());
164    let expires_at = now + ANON_SESSION_TTL_SECS;
165
166    match state.session_store.create_session(&user_id, expires_at).await {
167        Ok(tokens) => {
168            logger.log_success(
169                AuditEventType::SessionTokenCreated,
170                SecretType::SessionToken,
171                Some(user_id.clone()),
172                "anon_signup",
173            );
174            (
175                StatusCode::OK,
176                Json(AnonSignupResponse {
177                    user_id,
178                    access_token: tokens.access_token,
179                    refresh_token: tokens.refresh_token,
180                    expires_in: tokens.expires_in,
181                }),
182            )
183                .into_response()
184        },
185        Err(e) => {
186            logger.log_failure(
187                AuditEventType::AuthFailure,
188                SecretType::SessionToken,
189                None,
190                "anon_signup:session_create",
191                &e.to_string(),
192            );
193            StatusCode::INTERNAL_SERVER_ERROR.into_response()
194        },
195    }
196}
197
198/// Upgrade an anonymous session to a real identity.
199///
200/// Revokes all sessions for the old `anon_` identity and creates a new session
201/// for `new_user_id`.  Returns the new token pair.
202///
203/// # Errors
204///
205/// Returns an error if revocation or session creation fails.
206pub async fn upgrade_anonymous_session(
207    session_store: &dyn SessionStore,
208    anon_user_id: &str,
209    new_user_id: &str,
210    expires_at: u64,
211) -> Result<crate::session::TokenPair> {
212    // Revoke all anonymous sessions first.
213    session_store.revoke_all_sessions(anon_user_id).await?;
214    // Issue a new session under the real identity.
215    session_store.create_session(new_user_id, expires_at).await
216}
217
218// ─── Tests ────────────────────────────────────────────────────────────────────
219
220#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
221#[cfg(test)]
222mod tests;