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