Skip to main content

lighty_auth/
azuriom.rs

1// Copyright (c) 2025 Hamadi
2// Licensed under the MIT License
3
4//! Azuriom CMS authentication (email/password, 2FA, token verification, logout).
5
6use crate::auth::route_token;
7use crate::{Authenticator, AuthError, AuthProvider, AuthResult, UserProfile, UserRole};
8use lighty_core::hosts::HTTP_CLIENT as CLIENT;
9use serde::Deserialize;
10
11#[cfg(feature = "events")]
12use lighty_event::{EventBus, Event, AuthEvent};
13
14/// Authenticates users via an Azuriom CMS instance.
15pub struct AzuriomAuth {
16    base_url: String,
17    email: String,
18    password: String,
19    two_factor_code: Option<String>,
20    #[cfg(feature = "keyring")]
21    keyring_service: Option<String>,
22}
23
24impl AzuriomAuth {
25    /// Create a new Azuriom authenticator.
26    pub fn new(base_url: impl Into<String>, email: impl Into<String>, password: impl Into<String>) -> Self {
27        Self {
28            base_url: base_url.into().trim_end_matches('/').to_string(),
29            email: email.into(),
30            password: password.into(),
31            two_factor_code: None,
32            #[cfg(feature = "keyring")]
33            keyring_service: None,
34        }
35    }
36
37    /// Set the 2FA code (call when authentication returned `TwoFactorRequired`).
38    pub fn set_two_factor_code(&mut self, code: impl Into<String>) {
39        self.two_factor_code = Some(code.into());
40    }
41
42    /// Clear the 2FA code.
43    pub fn clear_two_factor_code(&mut self) {
44        self.two_factor_code = None;
45    }
46
47    /// Route subsequent `access_token`s into the OS keychain under
48    /// `service` (and `username = format!("azuriom:{uuid}")`). The
49    /// returned `UserProfile` carries a [`TokenHandle`] instead of the
50    /// raw token, so the secret never lives long-term in process memory.
51    #[cfg(feature = "keyring")]
52    pub fn with_keyring(mut self, service: impl Into<String>) -> Self {
53        self.keyring_service = Some(service.into());
54        self
55    }
56
57    fn keyring_service(&self) -> Option<&str> {
58        #[cfg(feature = "keyring")]
59        {
60            self.keyring_service.as_deref()
61        }
62        #[cfg(not(feature = "keyring"))]
63        {
64            None
65        }
66    }
67}
68
69
70/// Azuriom API response for successful authentication.
71#[derive(Debug, Deserialize)]
72struct AzuriomAuthResponse {
73    id: u64,
74    username: String,
75    uuid: String,
76    access_token: String,
77    email_verified: Option<bool>,
78    money: Option<f64>,
79    role: Option<AzuriomRole>,
80    banned: Option<bool>,
81}
82
83/// Azuriom role information.
84#[derive(Debug, Deserialize)]
85struct AzuriomRole {
86    name: String,
87    color: Option<String>,
88}
89
90/// Azuriom API error response.
91#[derive(Debug, Deserialize)]
92struct AzuriomErrorResponse {
93    status: String,
94    reason: String,
95    message: String,
96}
97impl Authenticator for AzuriomAuth {
98    async fn authenticate(
99        &mut self,
100        #[cfg(feature = "events")] event_bus: Option<&EventBus>,
101    ) -> AuthResult<UserProfile> {
102        let url = format!("{}/api/auth/authenticate", self.base_url);
103        lighty_core::trace_debug!(url = %url, email = %self.email, "Authenticating with Azuriom");
104
105        #[cfg(feature = "events")]
106        if let Some(bus) = event_bus {
107            bus.emit(Event::Auth(AuthEvent::AuthenticationStarted {
108                provider: "Azuriom".to_string(),
109            }));
110        }
111
112        let mut body = serde_json::json!({
113            "email": self.email,
114            "password": self.password,
115        });
116
117        if let Some(code) = &self.two_factor_code {
118            body["code"] = serde_json::json!(code);
119        }
120
121        let response = CLIENT
122            .post(&url)
123            .json(&body)
124            .send()
125            .await?;
126
127        let status = response.status();
128        let response_text = response.text().await?;
129
130        if status.is_success() {
131            let azuriom_response: AzuriomAuthResponse = serde_json::from_str(&response_text)
132                .map_err(|e| AuthError::InvalidResponse(format!("Failed to parse response: {}", e)))?;
133
134            if azuriom_response.banned.unwrap_or(false) {
135                lighty_core::trace_error!(username = %azuriom_response.username, "Account is banned");
136                #[cfg(feature = "events")]
137                if let Some(bus) = event_bus {
138                    bus.emit(Event::Auth(AuthEvent::AuthenticationFailed {
139                        provider: "Azuriom".to_string(),
140                        error: "Account is banned".to_string(),
141                    }));
142                }
143                return Err(AuthError::AccountBanned(
144                    azuriom_response.username.clone()
145                ));
146            }
147
148            lighty_core::trace_info!(username = %azuriom_response.username, uuid = %azuriom_response.uuid, "Successfully authenticated");
149
150            #[cfg(feature = "events")]
151            if let Some(bus) = event_bus {
152                bus.emit(Event::Auth(AuthEvent::AuthenticationSuccess {
153                    provider: "Azuriom".to_string(),
154                    username: azuriom_response.username.clone(),
155                    uuid: azuriom_response.uuid.clone(),
156                }));
157            }
158
159            let routed = route_token(
160                azuriom_response.access_token,
161                self.keyring_service(),
162                &format!("azuriom:{}", azuriom_response.uuid),
163            )?;
164            Ok(UserProfile {
165                id: Some(azuriom_response.id),
166                username: azuriom_response.username,
167                uuid: azuriom_response.uuid,
168                access_token: routed.access_token,
169                #[cfg(feature = "keyring")]
170                token_handle: routed.token_handle,
171                xuid: None,
172                email: Some(self.email.clone()),
173                email_verified: azuriom_response.email_verified.unwrap_or(true),
174                money: azuriom_response.money,
175                role: azuriom_response.role.map(|r| UserRole {
176                    name: r.name,
177                    color: r.color,
178                }),
179                banned: azuriom_response.banned.unwrap_or(false),
180                provider: AuthProvider::Azuriom { base_url: self.base_url.clone() },
181            })
182        } else {
183            let error_response: AzuriomErrorResponse = serde_json::from_str(&response_text)
184                .map_err(|_| AuthError::InvalidResponse(format!("HTTP {}: {}", status, response_text)))?;
185
186            if error_response.status != "error" {
187                return Err(AuthError::InvalidResponse(format!(
188                    "HTTP {}: expected status='error', got status='{}'",
189                    status, error_response.status
190                )));
191            }
192
193            lighty_core::trace_error!(reason = %error_response.reason, message = %error_response.message, "Authentication failed");
194
195            let error = match error_response.reason.as_str() {
196                "invalid_credentials" => AuthError::InvalidCredentials,
197                "2fa" => AuthError::TwoFactorRequired,
198                "invalid_2fa" => AuthError::Invalid2FACode,
199                "email_not_verified" => AuthError::EmailNotVerified,
200                "banned" => AuthError::AccountBanned(String::new()),
201                _ => AuthError::Custom(error_response.message.clone()),
202            };
203
204            #[cfg(feature = "events")]
205            if let Some(bus) = event_bus {
206                bus.emit(Event::Auth(AuthEvent::AuthenticationFailed {
207                    provider: "Azuriom".to_string(),
208                    error: error_response.message,
209                }));
210            }
211
212            Err(error)
213        }
214    }
215
216    async fn verify(&self, token: &str) -> AuthResult<UserProfile> {
217        let url = format!("{}/api/auth/verify", self.base_url);
218        lighty_core::trace_debug!(url = %url, "Verifying token");
219
220        let response = CLIENT
221            .post(&url)
222            .json(&serde_json::json!({
223                "access_token": token
224            }))
225            .send()
226            .await?;
227
228        let status = response.status();
229        let response_text = response.text().await?;
230
231        if status.is_success() {
232            let azuriom_response: AzuriomAuthResponse = serde_json::from_str(&response_text)
233                .map_err(|e| AuthError::InvalidResponse(format!("Failed to parse response: {}", e)))?;
234
235            lighty_core::trace_info!(username = %azuriom_response.username, "Token verified successfully");
236
237            let routed = route_token(
238                azuriom_response.access_token,
239                self.keyring_service(),
240                &format!("azuriom:{}", azuriom_response.uuid),
241            )?;
242            Ok(UserProfile {
243                id: Some(azuriom_response.id),
244                username: azuriom_response.username,
245                uuid: azuriom_response.uuid,
246                access_token: routed.access_token,
247                #[cfg(feature = "keyring")]
248                token_handle: routed.token_handle,
249                xuid: None,
250                email: None,
251                email_verified: azuriom_response.email_verified.unwrap_or(true),
252                money: azuriom_response.money,
253                role: azuriom_response.role.map(|r| UserRole {
254                    name: r.name,
255                    color: r.color,
256                }),
257                banned: azuriom_response.banned.unwrap_or(false),
258                provider: AuthProvider::Azuriom { base_url: self.base_url.clone() },
259            })
260        } else {
261            lighty_core::trace_error!(status = %status, "Token verification failed");
262            Err(AuthError::InvalidToken)
263        }
264    }
265
266    async fn logout(&self, token: &str) -> AuthResult<()> {
267        let url = format!("{}/api/auth/logout", self.base_url);
268        lighty_core::trace_debug!(url = %url, "Logging out");
269
270        let response = CLIENT
271            .post(&url)
272            .json(&serde_json::json!({
273                "access_token": token
274            }))
275            .send()
276            .await?;
277
278        if response.status().is_success() {
279            lighty_core::trace_info!("Successfully logged out");
280            Ok(())
281        } else {
282            lighty_core::trace_error!(status = %response.status(), "Logout failed");
283            Err(AuthError::InvalidToken)
284        }
285    }
286}
287
288