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