Skip to main content

uptrakit_web_api_types/
auth.rs

1use crate::validation::{Validate, ValidationError};
2use serde::{Deserialize, Serialize};
3use uptrakit_shared_types::SecretString;
4use uuid::Uuid;
5
6#[derive(Serialize, Deserialize)]
7#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
8#[cfg_attr(feature = "openapi", schema(example = json!({
9    "email": "admin@example.com",
10    "first_name": "Admin",
11    "last_name": "User",
12    "password": "SecurePass123"
13})))]
14pub struct RegisterRequest {
15    #[cfg_attr(feature = "openapi", schema(example = "admin@example.com"))]
16    pub email: String,
17    #[cfg_attr(feature = "openapi", schema(example = "Admin"))]
18    pub first_name: String,
19    #[cfg_attr(feature = "openapi", schema(example = "User"))]
20    pub last_name: String,
21    #[cfg_attr(feature = "openapi", schema(example = "SecurePass123", min_length = 8))]
22    pub password: SecretString,
23    /// Required when registration mode is `invite`.
24    pub registration_token: Option<SecretString>,
25}
26
27#[derive(Serialize, Deserialize)]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29#[cfg_attr(feature = "openapi", schema(example = json!({
30    "email": "admin@example.com",
31    "password": "SecurePass123"
32})))]
33pub struct LoginRequest {
34    #[cfg_attr(feature = "openapi", schema(example = "admin@example.com"))]
35    pub email: String,
36    #[cfg_attr(feature = "openapi", schema(example = "SecurePass123"))]
37    pub password: SecretString,
38}
39
40#[derive(Serialize, Deserialize)]
41#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
42pub struct LogoutRequest {
43    /// The refresh token to revoke. Optional when the token is provided
44    /// via the `refresh_token` `HttpOnly` cookie.
45    #[serde(default)]
46    pub refresh_token: Option<SecretString>,
47}
48
49#[derive(Serialize, Deserialize)]
50#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
51pub struct RefreshRequest {
52    /// The refresh token. Optional when the token is provided via the
53    /// `refresh_token` `HttpOnly` cookie.
54    #[serde(default)]
55    pub refresh_token: Option<SecretString>,
56}
57
58#[derive(Debug, Serialize, Deserialize, Clone)]
59#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
60pub struct AuthResponse {
61    pub access_token: SecretString,
62    pub refresh_token: SecretString,
63    pub expires_in: i64,
64    pub token_type: String,
65    pub user: UserResponse,
66}
67
68#[derive(Serialize, Deserialize)]
69#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
70pub struct RefreshResponse {
71    pub access_token: SecretString,
72    /// Rotated refresh token. The previous refresh token is now invalid.
73    pub refresh_token: SecretString,
74    pub expires_in: i64,
75    pub token_type: String,
76}
77
78/// Whether the access engine resolved this principal's authority.
79///
80/// Deliberately a closed two-variant enum (no `#[non_exhaustive]`, no
81/// `Other`): the set is definitionally complete — the engine either
82/// resolved grants or it did not — matching the closed-verdict-set
83/// precedent rather than the wire-safe open-enum rule, which targets
84/// vocabularies that can grow (spec §3).
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
87#[serde(rename_all = "lowercase")]
88pub enum AuthorityStatus {
89    Ok,
90    Unavailable,
91}
92
93#[derive(Debug, Serialize, Deserialize, Clone)]
94#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
95pub struct UserResponse {
96    pub id: Uuid,
97    pub email: String,
98    pub first_name: String,
99    pub last_name: String,
100    /// Expanded effective action list: wildcards expanded against the
101    /// catalog, dynamic actions included per live registries. Token-scope
102    /// intersection applies once scoped credentials exist (M3) — pre-M3
103    /// session credentials carry no scope. Empty when `authority` is
104    /// `unavailable`.
105    ///
106    /// Deliberately `Vec<String>`, not `Vec<Action>`: `Action`'s
107    /// deserializer rejects any resource/verb the COMPILED catalog lacks,
108    /// so a typed field would make a newer controller's response
109    /// unparseable to an older client (CLI could not even log in) the
110    /// moment the catalog grows. The action set is open — clients treat
111    /// entries as opaque strings.
112    pub actions: Vec<String>,
113    /// Whether the access engine resolved this principal's authority for
114    /// this response. `unavailable` ⇒ `actions` is empty and the client
115    /// should degrade, not log out.
116    pub authority: AuthorityStatus,
117    pub has_pending_email_change: bool,
118}
119
120impl Validate for RegisterRequest {
121    fn validate(&self) -> Result<(), ValidationError> {
122        if self.email.len() > 254 {
123            return Err(ValidationError {
124                field: "email",
125                message: "email must not exceed 254 characters".to_string(),
126            });
127        }
128        if !self.email.contains('@') {
129            return Err(ValidationError {
130                field: "email",
131                message: "email must contain '@'".to_string(),
132            });
133        }
134        if self.first_name.is_empty() {
135            return Err(ValidationError {
136                field: "first_name",
137                message: "first_name must not be empty".to_string(),
138            });
139        }
140        let password_len = self.password.expose_secret().len();
141        if password_len < 8 {
142            return Err(ValidationError {
143                field: "password",
144                message: "password must be at least 8 characters".to_string(),
145            });
146        }
147        if password_len > 1024 {
148            return Err(ValidationError {
149                field: "password",
150                message: "password must not exceed 1024 characters".to_string(),
151            });
152        }
153        Ok(())
154    }
155}
156
157impl Validate for LoginRequest {
158    fn validate(&self) -> Result<(), ValidationError> {
159        if self.email.len() > 254 {
160            return Err(ValidationError {
161                field: "email",
162                message: "email must not exceed 254 characters".to_string(),
163            });
164        }
165        if !self.email.contains('@') {
166            return Err(ValidationError {
167                field: "email",
168                message: "email must contain '@'".to_string(),
169            });
170        }
171        if self.password.expose_secret().is_empty() {
172            return Err(ValidationError {
173                field: "password",
174                message: "password must not be empty".to_string(),
175            });
176        }
177        Ok(())
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    #![expect(
184        clippy::assertions_on_result_states,
185        reason = "test assertions — is_ok/is_err provides readable failure messages"
186    )]
187    use super::*;
188
189    // ── RegisterRequest ──────────────────────────────────────────────────────
190
191    fn valid_register() -> RegisterRequest {
192        RegisterRequest {
193            email: "user@example.com".to_string(),
194            first_name: "Alice".to_string(),
195            last_name: "Smith".to_string(),
196            password: SecretString::new("password123"),
197            registration_token: None,
198        }
199    }
200
201    #[test]
202    fn register_valid() {
203        assert!(valid_register().validate().is_ok());
204    }
205
206    #[test]
207    fn register_email_too_long() {
208        let mut req = valid_register();
209        req.email = format!("{}@x.com", "a".repeat(250));
210        let err = req.validate().unwrap_err();
211        assert_eq!(err.field, "email");
212    }
213
214    #[test]
215    fn register_email_no_at_sign() {
216        let mut req = valid_register();
217        req.email = "notanemail".to_string();
218        let err = req.validate().unwrap_err();
219        assert_eq!(err.field, "email");
220    }
221
222    #[test]
223    fn register_first_name_empty() {
224        let mut req = valid_register();
225        req.first_name = String::new();
226        let err = req.validate().unwrap_err();
227        assert_eq!(err.field, "first_name");
228    }
229
230    #[test]
231    fn register_password_too_short() {
232        let mut req = valid_register();
233        req.password = SecretString::new("short");
234        let err = req.validate().unwrap_err();
235        assert_eq!(err.field, "password");
236    }
237
238    #[test]
239    fn register_password_too_long() {
240        let mut req = valid_register();
241        req.password = SecretString::new("a".repeat(1025));
242        let err = req.validate().unwrap_err();
243        assert_eq!(err.field, "password");
244    }
245
246    // ── LoginRequest ─────────────────────────────────────────────────────────
247
248    fn valid_login() -> LoginRequest {
249        LoginRequest {
250            email: "user@example.com".to_string(),
251            password: SecretString::new("password123"),
252        }
253    }
254
255    #[test]
256    fn login_valid() {
257        assert!(valid_login().validate().is_ok());
258    }
259
260    #[test]
261    fn login_email_too_long() {
262        let mut req = valid_login();
263        req.email = format!("{}@x.com", "a".repeat(250));
264        let err = req.validate().unwrap_err();
265        assert_eq!(err.field, "email");
266    }
267
268    #[test]
269    fn login_email_no_at_sign() {
270        let mut req = valid_login();
271        req.email = "notanemail".to_string();
272        let err = req.validate().unwrap_err();
273        assert_eq!(err.field, "email");
274    }
275
276    #[test]
277    fn login_password_empty() {
278        let mut req = valid_login();
279        req.password = SecretString::new(String::new());
280        let err = req.validate().unwrap_err();
281        assert_eq!(err.field, "password");
282    }
283}