Skip to main content

lighty_auth/
offline.rs

1// Copyright (c) 2025 Hamadi
2// Licensed under the MIT License
3
4//! Offline authentication: deterministic UUID v5 derived from the username.
5
6use crate::{Authenticator, AuthError, AuthProvider, AuthResult, UserProfile, generate_offline_uuid};
7
8#[cfg(feature = "events")]
9use lighty_event::{EventBus, Event, AuthEvent};
10
11/// Offline authenticator — no network calls, suitable for offline play or testing.
12pub struct OfflineAuth {
13    username: String,
14}
15
16impl OfflineAuth {
17    /// Create a new offline authenticator.
18    pub fn new(username: impl Into<String>) -> Self {
19        Self {
20            username: username.into(),
21        }
22    }
23
24    /// Get the username.
25    pub fn username(&self) -> &str {
26        &self.username
27    }
28}
29
30impl Authenticator for OfflineAuth {
31    async fn authenticate(
32        &mut self,
33        #[cfg(feature = "events")] event_bus: Option<&EventBus>,
34    ) -> AuthResult<UserProfile> {
35        #[cfg(feature = "events")]
36        if let Some(bus) = event_bus {
37            bus.emit(Event::Auth(AuthEvent::AuthenticationStarted {
38                provider: "Offline".to_string(),
39            }));
40        }
41
42        if self.username.is_empty() {
43            #[cfg(feature = "events")]
44            if let Some(bus) = event_bus {
45                bus.emit(Event::Auth(AuthEvent::AuthenticationFailed {
46                    provider: "Offline".to_string(),
47                    error: "Username cannot be empty".to_string(),
48                }));
49            }
50            return Err(AuthError::InvalidCredentials);
51        }
52
53        if self.username.len() < 3 || self.username.len() > 16 {
54            return Err(rejected(
55                AuthError::UsernameLength { min: 3, max: 16 },
56                #[cfg(feature = "events")]
57                event_bus,
58            ));
59        }
60
61        if !self.username.chars().all(|c| c.is_alphanumeric() || c == '_') {
62            return Err(rejected(
63                AuthError::UsernameCharset,
64                #[cfg(feature = "events")]
65                event_bus,
66            ));
67        }
68
69        let uuid = generate_offline_uuid(&self.username);
70
71        #[cfg(feature = "events")]
72        if let Some(bus) = event_bus {
73            bus.emit(Event::Auth(AuthEvent::AuthenticationSuccess {
74                provider: "Offline".to_string(),
75                username: self.username.clone(),
76                uuid: uuid.clone(),
77            }));
78        }
79
80        Ok(UserProfile {
81            id: None,
82            username: self.username.clone(),
83            uuid,
84            access_token: None,
85            #[cfg(feature = "keyring")]
86            token_handle: None,
87            xuid: None,
88            email: None,
89            email_verified: false,
90            money: None,
91            role: None,
92            banned: false,
93            provider: AuthProvider::Offline,
94        })
95    }
96}
97
98/// Announces a rejected username and hands the error back, so the event text
99/// and the error can never drift apart.
100fn rejected(
101    error: AuthError,
102    #[cfg(feature = "events")] event_bus: Option<&EventBus>,
103) -> AuthError {
104    #[cfg(feature = "events")]
105    if let Some(bus) = event_bus {
106        bus.emit(Event::Auth(AuthEvent::AuthenticationFailed {
107            provider: "Offline".to_string(),
108            error: error.to_string(),
109        }));
110    }
111
112    error
113}