Skip to main content

lighty_auth/
auth.rs

1//! Core authentication types: [`Authenticator`] trait, [`UserProfile`],
2//! [`UserRole`], [`AuthProvider`], plus [`generate_offline_uuid`].
3
4use std::fmt;
5use std::future::Future;
6use secrecy::SecretString;
7use serde::{Deserialize, Serialize};
8use crate::AuthError;
9
10#[cfg(feature = "keyring")]
11use crate::keyring::TokenHandle;
12
13#[cfg(feature = "events")]
14use lighty_event::EventBus;
15
16pub type AuthResult<T> = Result<T, AuthError>;
17
18/// User profile returned after successful authentication.
19///
20/// Intentionally not `Serialize` / `Deserialize`: dumping a profile in
21/// plain JSON would leak the session token. See `AUTH_SECRETS.md`.
22#[derive(Clone)]
23pub struct UserProfile {
24    pub id: Option<u64>,
25    pub username: String,
26    pub uuid: String,
27    /// Minecraft/Azuriom session token. Wrapped in [`SecretString`] so
28    /// `Debug` prints `[REDACTED]` and `serde` cannot serialise it.
29    /// Read at launch-time via [`secrecy::ExposeSecret::expose_secret`].
30    pub access_token: Option<SecretString>,
31    /// Opt-in OS-keychain handle. When present, the token is stored
32    /// outside the process address space and `access_token` is `None`.
33    #[cfg(feature = "keyring")]
34    pub token_handle: Option<TokenHandle>,
35    pub xuid: Option<String>,
36    pub email: Option<String>,
37    pub email_verified: bool,
38    pub money: Option<f64>,
39    pub role: Option<UserRole>,
40    pub banned: bool,
41    pub provider: AuthProvider,
42}
43
44impl UserProfile {
45    /// Minimal offline-mode profile — intended for tests, doctests and
46    /// `OfflineAuth` integrations. All optional fields default to `None`.
47    pub fn offline(username: impl Into<String>, uuid: impl Into<String>) -> Self {
48        Self {
49            id: None,
50            username: username.into(),
51            uuid: uuid.into(),
52            access_token: None,
53            #[cfg(feature = "keyring")]
54            token_handle: None,
55            xuid: None,
56            email: None,
57            email_verified: false,
58            money: None,
59            role: None,
60            banned: false,
61            provider: AuthProvider::Offline,
62        }
63    }
64}
65
66impl fmt::Debug for UserProfile {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        f.debug_struct("UserProfile")
69            .field("id", &self.id)
70            .field("username", &self.username)
71            .field("uuid", &self.uuid)
72            .field("access_token", &self.access_token.as_ref().map(|_| "[REDACTED]"))
73            .field("xuid", &self.xuid)
74            .field("email", &self.email)
75            .field("email_verified", &self.email_verified)
76            .field("money", &self.money)
77            .field("role", &self.role)
78            .field("banned", &self.banned)
79            .field("provider", &self.provider)
80            .finish()
81    }
82}
83
84/// User role/rank information.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct UserRole {
87    pub name: String,
88    pub color: Option<String>,
89}
90
91/// Authentication provider type.
92///
93/// `Microsoft.refresh_token` is wrapped in [`SecretString`] for the
94/// same reason as `UserProfile.access_token`. The enum is therefore
95/// not `Serialize` / `Deserialize`.
96#[derive(Debug, Clone)]
97pub enum AuthProvider {
98    Offline,
99    Azuriom {
100        base_url: String,
101    },
102    Microsoft {
103        client_id: String,
104        refresh_token: Option<SecretString>,
105    },
106    Custom {
107        base_url: String,
108    },
109}
110
111/// Helper return type for [`route_token`].
112pub(crate) struct TokenRouting {
113    pub access_token: Option<SecretString>,
114    #[cfg(feature = "keyring")]
115    pub token_handle: Option<TokenHandle>,
116}
117
118/// Wraps a freshly obtained token. If the provider was configured with
119/// `with_keyring(service)`, the secret is written to the OS keychain
120/// under `keyring_key` and only a [`TokenHandle`] is returned. Otherwise
121/// the secret stays in process memory inside a [`SecretString`].
122pub(crate) fn route_token(
123    token: String,
124    _keyring_service: Option<&str>,
125    _keyring_key: &str,
126) -> Result<TokenRouting, AuthError> {
127    let secret = SecretString::from(token);
128    #[cfg(feature = "keyring")]
129    if let Some(service) = _keyring_service {
130        let handle = TokenHandle::new(service, _keyring_key);
131        handle.store(&secret)?;
132        return Ok(TokenRouting {
133            access_token: None,
134            token_handle: Some(handle),
135        });
136    }
137    Ok(TokenRouting {
138        access_token: Some(secret),
139        #[cfg(feature = "keyring")]
140        token_handle: None,
141    })
142}
143
144impl PartialEq for AuthProvider {
145    fn eq(&self, other: &Self) -> bool {
146        use secrecy::ExposeSecret;
147        match (self, other) {
148            (Self::Offline, Self::Offline) => true,
149            (Self::Azuriom { base_url: a }, Self::Azuriom { base_url: b }) => a == b,
150            (Self::Custom { base_url: a }, Self::Custom { base_url: b }) => a == b,
151            (
152                Self::Microsoft { client_id: ca, refresh_token: ta },
153                Self::Microsoft { client_id: cb, refresh_token: tb },
154            ) => {
155                ca == cb
156                    && ta.as_ref().map(|s| s.expose_secret().to_string())
157                        == tb.as_ref().map(|s| s.expose_secret().to_string())
158            }
159            _ => false,
160        }
161    }
162}
163
164impl Eq for AuthProvider {}
165
166/// Core authentication trait implemented by every provider.
167pub trait Authenticator {
168    /// Authenticate a user and return their profile.
169    fn authenticate(
170        &mut self,
171        #[cfg(feature = "events")] event_bus: Option<&EventBus>,
172    ) -> impl Future<Output = AuthResult<UserProfile>> + Send;
173
174    /// Verify if a token is still valid.
175    fn verify(&self, token: &str) -> impl Future<Output = AuthResult<UserProfile>> + Send {
176        async move {
177            let _ = token;
178            Err(AuthError::Custom("Verification not supported for this provider".into()))
179        }
180    }
181
182    /// Logout and invalidate the token.
183    fn logout(&self, token: &str) -> impl Future<Output = AuthResult<()>> + Send {
184        async move {
185            let _ = token;
186            Ok(())
187        }
188    }
189}
190
191/// Generates a deterministic UUID v5 (SHA1-based) from a username for offline mode.
192pub fn generate_offline_uuid(username: &str) -> String {
193    const NAMESPACE: &[u8] = b"OfflinePlayer:";
194
195    let mut data = Vec::with_capacity(NAMESPACE.len() + username.len());
196    data.extend_from_slice(NAMESPACE);
197    data.extend_from_slice(username.as_bytes());
198
199    let hash = lighty_core::calculate_sha1_bytes_raw(&data);
200
201    // Version bits: 0101 (5) in the 13th position
202    // Variant bits: 10xx in the 17th position (RFC 4122)
203    format!(
204        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-5{:01x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
205        hash[0], hash[1], hash[2], hash[3],
206        hash[4], hash[5],
207        hash[6] & 0x0f, hash[7],
208        (hash[8] & 0x3f) | 0x80, hash[9],
209        hash[10], hash[11], hash[12], hash[13], hash[14], hash[15]
210    )
211}