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;