Skip to main content

lighty_auth/
microsoft.rs

1// Copyright (c) 2025 Hamadi
2// Licensed under the MIT License
3
4//! Microsoft OAuth 2.0 (Device Code Flow) authentication for Minecraft.
5
6use crate::auth::route_token;
7use crate::{Authenticator, AuthError, AuthProvider, AuthResult, UserProfile};
8use lighty_core::hosts::HTTP_CLIENT as CLIENT;
9use secrecy::{ExposeSecret, SecretString};
10use serde::Deserialize;
11use std::time::Duration;
12use tokio::time::sleep;
13
14#[cfg(feature = "events")]
15use lighty_event::{EventBus, Event, AuthEvent};
16
17const MS_AUTH_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0";
18const XBOX_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate";
19const XSTS_AUTH_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
20const MC_AUTH_URL: &str = "https://api.minecraftservices.com/authentication/login_with_xbox";
21const MC_PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
22
23/// Microsoft authenticator using Device Code Flow.
24pub struct MicrosoftAuth {
25    client_id: String,
26    device_code_callback: Option<Box<dyn Fn(&str, &str) + Send + Sync>>,
27    poll_interval: Duration,
28    timeout: Duration,
29    #[cfg(feature = "keyring")]
30    keyring_service: Option<String>,
31}
32
33impl MicrosoftAuth {
34    /// Creates a new Microsoft authenticator from an Azure AD client ID.
35    pub fn new(client_id: impl Into<String>) -> Self {
36        Self {
37            client_id: client_id.into(),
38            device_code_callback: None,
39            poll_interval: Duration::from_secs(5),
40            timeout: Duration::from_secs(300),
41            #[cfg(feature = "keyring")]
42            keyring_service: None,
43        }
44    }
45
46    /// Route subsequent `access_token` / `refresh_token` into the OS
47    /// keychain under `service` (and `username = format!("microsoft:{uuid}")`,
48    /// plus `microsoft:{uuid}:refresh` for the refresh token). The returned
49    /// `UserProfile` carries a [`TokenHandle`](crate::TokenHandle) instead
50    /// of the raw token.
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    /// Set a callback that receives `(code, verification_url)` for the user.
69    pub fn set_device_code_callback<F>(&mut self, callback: F)
70    where
71        F: Fn(&str, &str) + Send + Sync + 'static,
72    {
73        self.device_code_callback = Some(Box::new(callback));
74    }
75
76    /// Set the polling interval (default 5 seconds).
77    pub fn set_poll_interval(&mut self, interval: Duration) {
78        self.poll_interval = interval;
79    }
80
81    /// Set the authentication timeout (default 5 minutes).
82    pub fn set_timeout(&mut self, timeout: Duration) {
83        self.timeout = timeout;
84    }
85
86    /// Request a device code from Microsoft.
87    async fn request_device_code(&self) -> AuthResult<DeviceCodeResponse> {
88        lighty_core::trace_debug!("Requesting device code");
89
90        let response = CLIENT
91            .post(&format!("{}/devicecode", MS_AUTH_URL))
92            .form(&[
93                ("client_id", self.client_id.as_str()),
94                ("scope", "XboxLive.signin offline_access"),
95            ])
96            .send()
97            .await?;
98
99        if !response.status().is_success() {
100            let error_text = response.text().await?;
101            lighty_core::trace_error!(error = %error_text, "Failed to request device code");
102            return Err(AuthError::InvalidResponse(error_text));
103        }
104
105        let device_code: DeviceCodeResponse = response.json().await?;
106        lighty_core::trace_info!(user_code = %device_code.user_code, "Device code obtained");
107
108        Ok(device_code)
109    }
110
111    /// Poll for the Microsoft token after the user has authorized.
112    async fn poll_for_token(&self, device_code: &str) -> AuthResult<MicrosoftTokenResponse> {
113        lighty_core::trace_debug!("Polling for Microsoft token");
114
115        let start = std::time::Instant::now();
116
117        loop {
118            if start.elapsed() > self.timeout {
119                lighty_core::trace_error!("Device code expired");
120                return Err(AuthError::DeviceCodeExpired);
121            }
122
123            sleep(self.poll_interval).await;
124
125            let response = CLIENT
126                .post(&format!("{}/token", MS_AUTH_URL))
127                .form(&[
128                    ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
129                    ("client_id", &self.client_id),
130                    ("device_code", device_code),
131                ])
132                .send()
133                .await?;
134
135            if response.status().is_success() {
136                let token: MicrosoftTokenResponse = response.json().await?;
137                lighty_core::trace_info!("Microsoft token obtained");
138                return Ok(token);
139            }
140
141            let error: OAuthError = response.json().await?;
142
143            match error.error.as_str() {
144                "authorization_pending" => {
145                    lighty_core::trace_debug!("Authorization pending, continuing to poll");
146                    continue;
147                }
148                "authorization_declined" => {
149                    lighty_core::trace_error!("User declined authorization");
150                    return Err(AuthError::Cancelled);
151                }
152                "expired_token" => {
153                    lighty_core::trace_error!("Device code expired");
154                    return Err(AuthError::DeviceCodeExpired);
155                }
156                _ => {
157                    lighty_core::trace_error!(error = %error.error, description = ?error.error_description, "OAuth error");
158                    return Err(AuthError::Custom(error.error));
159                }
160            }
161        }
162    }
163
164    /// Exchange the Microsoft token for an Xbox Live token.
165    async fn get_xbox_token(&self, ms_token: &str) -> AuthResult<XboxTokenResponse> {
166        lighty_core::trace_debug!("Requesting Xbox Live token");
167
168        let response = CLIENT
169            .post(XBOX_AUTH_URL)
170            .json(&serde_json::json!({
171                "Properties": {
172                    "AuthMethod": "RPS",
173                    "SiteName": "user.auth.xboxlive.com",
174                    "RpsTicket": format!("d={}", ms_token)
175                },
176                "RelyingParty": "http://auth.xboxlive.com",
177                "TokenType": "JWT"
178            }))
179            .send()
180            .await?;
181
182        if !response.status().is_success() {
183            let error_text = response.text().await?;
184            lighty_core::trace_error!(error = %error_text, "Failed to get Xbox Live token");
185            return Err(AuthError::InvalidResponse(error_text));
186        }
187
188        let xbox_token: XboxTokenResponse = response.json().await?;
189        lighty_core::trace_info!("Xbox Live token obtained");
190
191        Ok(xbox_token)
192    }
193
194    /// Exchange the Xbox Live token for an XSTS token.
195    async fn get_xsts_token(&self, xbox_token: &str) -> AuthResult<XboxTokenResponse> {
196        lighty_core::trace_debug!("Requesting XSTS token");
197
198        let response = CLIENT
199            .post(XSTS_AUTH_URL)
200            .json(&serde_json::json!({
201                "Properties": {
202                    "SandboxId": "RETAIL",
203                    "UserTokens": [xbox_token]
204                },
205                "RelyingParty": "rp://api.minecraftservices.com/",
206                "TokenType": "JWT"
207            }))
208            .send()
209            .await?;
210
211        if !response.status().is_success() {
212            let status = response.status();
213            let error_text = response.text().await?;
214
215            if error_text.contains("2148916233") {
216                lighty_core::trace_error!("Account doesn't own Minecraft");
217                return Err(AuthError::Custom("This Microsoft account doesn't own Minecraft".into()));
218            }
219            if error_text.contains("2148916238") {
220                lighty_core::trace_error!("Account is from a country where Xbox Live is unavailable");
221                return Err(AuthError::Custom("Xbox Live is not available in your country".into()));
222            }
223
224            lighty_core::trace_error!(status = %status, error = %error_text, "Failed to get XSTS token");
225            return Err(AuthError::InvalidResponse(error_text));
226        }
227
228        let xsts_token: XboxTokenResponse = response.json().await?;
229        lighty_core::trace_info!("XSTS token obtained");
230
231        Ok(xsts_token)
232    }
233
234    /// Exchange the XSTS token for a Minecraft token.
235    async fn get_minecraft_token(&self, xsts_token: &str, uhs: &str) -> AuthResult<MinecraftTokenResponse> {
236        lighty_core::trace_debug!("Requesting Minecraft token");
237
238        let response = CLIENT
239            .post(MC_AUTH_URL)
240            .json(&serde_json::json!({
241                "identityToken": format!("XBL3.0 x={};{}", uhs, xsts_token)
242            }))
243            .send()
244            .await?;
245
246        if !response.status().is_success() {
247            let error_text = response.text().await?;
248            lighty_core::trace_error!(error = %error_text, "Failed to get Minecraft token");
249            return Err(AuthError::InvalidResponse(error_text));
250        }
251
252        let mc_token: MinecraftTokenResponse = response.json().await?;
253        lighty_core::trace_info!("Minecraft token obtained");
254
255        Ok(mc_token)
256    }
257
258    /// Fetch the Minecraft profile using the Minecraft access token.
259    async fn get_minecraft_profile(&self, mc_token: &str) -> AuthResult<MinecraftProfile> {
260        lighty_core::trace_debug!("Fetching Minecraft profile");
261
262        let response = CLIENT
263            .get(MC_PROFILE_URL)
264            .header("Authorization", format!("Bearer {}", mc_token))
265            .send()
266            .await?;
267
268        if !response.status().is_success() {
269            let status = response.status();
270            let error_text = response.text().await?;
271            lighty_core::trace_error!(status = %status, error = %error_text, "Failed to get Minecraft profile");
272            return Err(AuthError::InvalidResponse(error_text));
273        }
274
275        let profile: MinecraftProfile = response.json().await?;
276        lighty_core::trace_info!(username = %profile.name, uuid = %profile.id, "Minecraft profile obtained");
277
278        Ok(profile)
279    }
280
281    /// Refresh a Microsoft access-token using a long-lived refresh token.
282    /// Note: Microsoft rotates the refresh token on most calls — callers must
283    /// replace the stored one with whatever this returns.
284    async fn refresh_microsoft_token(&self, refresh_token: &str) -> AuthResult<MicrosoftTokenResponse> {
285        lighty_core::trace_debug!("Refreshing Microsoft token via refresh_token grant");
286
287        let response = CLIENT
288            .post(&format!("{}/token", MS_AUTH_URL))
289            .form(&[
290                ("grant_type", "refresh_token"),
291                ("client_id", &self.client_id),
292                ("refresh_token", refresh_token),
293                ("scope", "XboxLive.signin offline_access"),
294            ])
295            .send()
296            .await?;
297
298        if !response.status().is_success() {
299            let error_text = response.text().await?;
300            lighty_core::trace_warn!(error = %error_text, "Refresh token grant rejected (token likely expired or revoked)");
301            return Err(AuthError::InvalidToken);
302        }
303
304        let token: MicrosoftTokenResponse = response.json().await?;
305        lighty_core::trace_info!("Microsoft token refreshed silently");
306        Ok(token)
307    }
308
309    /// Runs the Xbox -> XSTS -> Minecraft -> Profile chain starting from
310    /// an already-obtained Microsoft access token. Shared between the
311    /// device-code and silent-refresh paths.
312    async fn finalize_from_ms_token(
313        &self,
314        ms_token: MicrosoftTokenResponse,
315        #[cfg(feature = "events")] event_bus: Option<&EventBus>,
316    ) -> AuthResult<UserProfile> {
317        #[cfg(feature = "events")]
318        if let Some(bus) = event_bus {
319            bus.emit(Event::Auth(AuthEvent::AuthenticationInProgress {
320                provider: "Microsoft".to_string(),
321                step: "Exchanging for Xbox Live token".to_string(),
322            }));
323        }
324        let xbox_token = self.get_xbox_token(&ms_token.access_token).await?;
325
326        #[cfg(feature = "events")]
327        if let Some(bus) = event_bus {
328            bus.emit(Event::Auth(AuthEvent::AuthenticationInProgress {
329                provider: "Microsoft".to_string(),
330                step: "Exchanging for XSTS token".to_string(),
331            }));
332        }
333        let xsts_token = self.get_xsts_token(&xbox_token.token).await?;
334
335        let uhs = xsts_token
336            .display_claims
337            .get("xui")
338            .and_then(|xui| xui.get(0))
339            .and_then(|user| user.get("uhs"))
340            .and_then(|v| v.as_str())
341            .ok_or_else(|| AuthError::InvalidResponse("Missing UHS in XSTS token".into()))?;
342
343        #[cfg(feature = "events")]
344        if let Some(bus) = event_bus {
345            bus.emit(Event::Auth(AuthEvent::AuthenticationInProgress {
346                provider: "Microsoft".to_string(),
347                step: "Exchanging for Minecraft token".to_string(),
348            }));
349        }
350        let mc_token = self.get_minecraft_token(&xsts_token.token, uhs).await?;
351
352        let xuid = decode_xuid_from_jwt(&mc_token.access_token);
353        if xuid.is_none() {
354            lighty_core::trace_warn!("Could not decode xuid from MC token JWT — --xuid will fall back to 0");
355        }
356
357        #[cfg(feature = "events")]
358        if let Some(bus) = event_bus {
359            bus.emit(Event::Auth(AuthEvent::AuthenticationInProgress {
360                provider: "Microsoft".to_string(),
361                step: "Fetching Minecraft profile".to_string(),
362            }));
363        }
364        let mc_profile = self.get_minecraft_profile(&mc_token.access_token).await?;
365
366        let uuid = format_uuid(&mc_profile.id);
367
368        #[cfg(feature = "events")]
369        if let Some(bus) = event_bus {
370            bus.emit(Event::Auth(AuthEvent::AuthenticationSuccess {
371                provider: "Microsoft".to_string(),
372                username: mc_profile.name.clone(),
373                uuid: uuid.clone(),
374            }));
375        }
376
377        let access = route_token(
378            mc_token.access_token,
379            self.keyring_service(),
380            &format!("microsoft:{}", uuid),
381        )?;
382        let refresh_secret = ms_token.refresh_token.map(|t| {
383            // Refresh token must stay accessible to the in-process
384            // refresh flow; storing it in the keychain would force a
385            // round-trip per refresh. Keep it secret-wrapped.
386            SecretString::from(t)
387        });
388        Ok(UserProfile {
389            id: None,
390            username: mc_profile.name,
391            uuid,
392            access_token: access.access_token,
393            #[cfg(feature = "keyring")]
394            token_handle: access.token_handle,
395            xuid,
396            email: None,
397            email_verified: true,
398            money: None,
399            role: None,
400            banned: false,
401            provider: AuthProvider::Microsoft {
402                client_id: self.client_id.clone(),
403                refresh_token: refresh_secret,
404            },
405        })
406    }
407
408    /// Silent re-authentication using a stored MS refresh token.
409    /// Returns `AuthError::InvalidToken` if the refresh token has expired
410    /// (~90 days of inactivity) or been revoked; caller should then fall
411    /// back to [`Authenticator::authenticate`].
412    pub async fn authenticate_with_refresh_token(
413        &mut self,
414        refresh_token: &SecretString,
415        #[cfg(feature = "events")] event_bus: Option<&EventBus>,
416    ) -> AuthResult<UserProfile> {
417        #[cfg(feature = "events")]
418        if let Some(bus) = event_bus {
419            bus.emit(Event::Auth(AuthEvent::AuthenticationStarted {
420                provider: "Microsoft".to_string(),
421            }));
422            bus.emit(Event::Auth(AuthEvent::AuthenticationInProgress {
423                provider: "Microsoft".to_string(),
424                step: "Refreshing Microsoft token".to_string(),
425            }));
426        }
427
428        let ms_token = match self.refresh_microsoft_token(refresh_token.expose_secret()).await {
429            Ok(t) => t,
430            Err(e) => {
431                #[cfg(feature = "events")]
432                if let Some(bus) = event_bus {
433                    bus.emit(Event::Auth(AuthEvent::AuthenticationFailed {
434                        provider: "Microsoft".to_string(),
435                        error: format!("Refresh failed: {}", e),
436                    }));
437                }
438                return Err(e);
439            }
440        };
441
442        self.finalize_from_ms_token(
443            ms_token,
444            #[cfg(feature = "events")] event_bus,
445        ).await
446    }
447}
448
449impl Authenticator for MicrosoftAuth {
450    async fn authenticate(
451        &mut self,
452        #[cfg(feature = "events")] event_bus: Option<&EventBus>,
453    ) -> AuthResult<UserProfile> {
454        #[cfg(feature = "events")]
455        if let Some(bus) = event_bus {
456            bus.emit(Event::Auth(AuthEvent::AuthenticationStarted {
457                provider: "Microsoft".to_string(),
458            }));
459            bus.emit(Event::Auth(AuthEvent::AuthenticationInProgress {
460                provider: "Microsoft".to_string(),
461                step: "Requesting device code".to_string(),
462            }));
463        }
464
465        let device_code_response = self.request_device_code().await?;
466
467        if let Some(callback) = &self.device_code_callback {
468            callback(&device_code_response.user_code, &device_code_response.verification_uri);
469        } else {
470            lighty_core::trace_warn!("No device code callback set - user won't see the authorization URL");
471        }
472
473        #[cfg(feature = "events")]
474        if let Some(bus) = event_bus {
475            bus.emit(Event::Auth(AuthEvent::AuthenticationInProgress {
476                provider: "Microsoft".to_string(),
477                step: "Waiting for user authorization".to_string(),
478            }));
479        }
480
481        let ms_token = self.poll_for_token(&device_code_response.device_code).await?;
482
483        self.finalize_from_ms_token(
484            ms_token,
485            #[cfg(feature = "events")] event_bus,
486        ).await
487    }
488}
489
490/// Pulls the `xuid` claim out of the Minecraft access-token JWT.
491/// Prefers `xuid`, falls back to legacy `xid`. The signature is not
492/// verified (the token transits over TLS from Mojang), but the JWT
493/// header `alg` is checked: anything outside `RS256` / `HS256` is
494/// refused so a spoofed token with an exotic algo can't slip through.
495fn decode_xuid_from_jwt(token: &str) -> Option<String> {
496    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
497    use base64::Engine;
498
499    let mut parts = token.split('.');
500    let header_b64 = parts.next()?;
501    let payload_b64 = parts.next()?;
502
503    let header_bytes = URL_SAFE_NO_PAD.decode(header_b64).ok()?;
504    let header: JwtHeader = serde_json::from_slice(&header_bytes).ok()?;
505    if !matches!(header.alg.as_str(), "RS256" | "HS256") {
506        lighty_core::trace_warn!(
507            alg = %header.alg,
508            "Unexpected JWT alg from Microsoft, refusing to decode xuid"
509        );
510        return None;
511    }
512
513    let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
514    let claims: MinecraftAccessTokenClaims = serde_json::from_slice(&payload_bytes).ok()?;
515    claims.xuid.or(claims.xid)
516}
517
518/// Format a 32-char UUID string with dashes.
519fn format_uuid(uuid: &str) -> String {
520    if uuid.len() != 32 {
521        return uuid.to_string();
522    }
523
524    format!(
525        "{}-{}-{}-{}-{}",
526        &uuid[0..8],
527        &uuid[8..12],
528        &uuid[12..16],
529        &uuid[16..20],
530        &uuid[20..32]
531    )
532}
533
534/// Minimal subset of the Minecraft access-token JWT payload.
535#[derive(Debug, Deserialize)]
536struct MinecraftAccessTokenClaims {
537    xuid: Option<String>,
538    xid: Option<String>,
539}
540
541#[derive(Debug, Deserialize)]
542struct JwtHeader {
543    alg: String,
544}
545
546#[derive(Debug, Deserialize)]
547struct DeviceCodeResponse {
548    device_code: String,
549    user_code: String,
550    verification_uri: String,
551}
552
553#[derive(Debug, Deserialize)]
554struct MicrosoftTokenResponse {
555    access_token: String,
556    refresh_token: Option<String>,
557}
558
559#[derive(Debug, Deserialize)]
560struct XboxTokenResponse {
561    #[serde(rename = "Token")]
562    token: String,
563    #[serde(rename = "DisplayClaims")]
564    display_claims: serde_json::Value,
565}
566
567#[derive(Debug, Deserialize)]
568struct MinecraftTokenResponse {
569    access_token: String,
570}
571
572#[derive(Debug, Deserialize)]
573struct MinecraftProfile {
574    id: String,
575    name: String,
576}
577
578#[derive(Debug, Deserialize)]
579struct OAuthError {
580    error: String,
581    error_description: Option<String>,
582}