lighty_auth/
microsoft.rs

1// Copyright (c) 2025 Hamadi
2// Licensed under the MIT License
3
4//! Microsoft OAuth 2.0 authentication for Minecraft
5//!
6//! Implements the Device Code Flow for authenticating Minecraft accounts via Microsoft.
7//! This is a multi-step process:
8//! 1. Request a device code
9//! 2. User authorizes via browser
10//! 3. Poll for token
11//! 4. Exchange for Xbox Live token
12//! 5. Exchange for XSTS token
13//! 6. Exchange for Minecraft token
14//! 7. Fetch Minecraft profile
15
16use crate::{Authenticator, AuthError, AuthResult, UserProfile};
17use lighty_core::hosts::HTTP_CLIENT as CLIENT;
18use serde::Deserialize;
19use std::time::Duration;
20use tokio::time::sleep;
21
22#[cfg(feature = "events")]
23use lighty_event::{EventBus, Event, AuthEvent};
24
25const MS_AUTH_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0";
26const XBOX_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate";
27const XSTS_AUTH_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
28const MC_AUTH_URL: &str = "https://api.minecraftservices.com/authentication/login_with_xbox";
29const MC_PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
30
31/// Microsoft authenticator using Device Code Flow
32///
33/// This authentication method is suitable for CLI applications and launchers.
34/// The user will need to visit a URL and enter a code to authorize.
35///
36/// # Example
37/// ```no_run
38/// use lighty_auth::microsoft::MicrosoftAuth;
39/// use lighty_auth::Authenticator;
40///
41/// #[tokio::main]
42/// async fn main() {
43///     let mut auth = MicrosoftAuth::new("your-client-id");
44///
45///     // Set a callback to display the device code to the user
46///     auth.set_device_code_callback(|code, url| {
47///         println!("Please visit: {}", url);
48///         println!("And enter code: {}", code);
49///     });
50///
51///     let profile = auth.authenticate().await.unwrap();
52///     println!("Logged in as: {}", profile.username);
53/// }
54/// ```
55pub struct MicrosoftAuth {
56    client_id: String,
57    device_code_callback: Option<Box<dyn Fn(&str, &str) + Send + Sync>>,
58    poll_interval: Duration,
59    timeout: Duration,
60}
61
62impl MicrosoftAuth {
63    /// Create a new Microsoft authenticator
64    ///
65    /// # Arguments
66    /// - `client_id`: Your Azure AD application client ID
67    pub fn new(client_id: impl Into<String>) -> Self {
68        Self {
69            client_id: client_id.into(),
70            device_code_callback: None,
71            poll_interval: Duration::from_secs(5),
72            timeout: Duration::from_secs(300), // 5 minutes
73        }
74    }
75
76    /// Set a callback to display the device code to the user
77    ///
78    /// # Arguments
79    /// - `callback`: Function that receives (code, verification_url)
80    pub fn set_device_code_callback<F>(&mut self, callback: F)
81    where
82        F: Fn(&str, &str) + Send + Sync + 'static,
83    {
84        self.device_code_callback = Some(Box::new(callback));
85    }
86
87    /// Set the polling interval
88    ///
89    /// Default: 5 seconds
90    pub fn set_poll_interval(&mut self, interval: Duration) {
91        self.poll_interval = interval;
92    }
93
94    /// Set the authentication timeout
95    ///
96    /// Default: 5 minutes
97    pub fn set_timeout(&mut self, timeout: Duration) {
98        self.timeout = timeout;
99    }
100
101    /// Step 1: Request device code
102    async fn request_device_code(&self) -> AuthResult<DeviceCodeResponse> {
103        lighty_core::trace_debug!("Requesting device code");
104
105        let response = CLIENT
106            .post(&format!("{}/devicecode", MS_AUTH_URL))
107            .form(&[
108                ("client_id", self.client_id.as_str()),
109                ("scope", "XboxLive.signin offline_access"),
110            ])
111            .send()
112            .await?;
113
114        if !response.status().is_success() {
115            let error_text = response.text().await?;
116            lighty_core::trace_error!(error = %error_text, "Failed to request device code");
117            return Err(AuthError::InvalidResponse(error_text));
118        }
119
120        let device_code: DeviceCodeResponse = response.json().await?;
121        lighty_core::trace_info!(user_code = %device_code.user_code, "Device code obtained");
122
123        Ok(device_code)
124    }
125
126    /// Step 2: Poll for Microsoft token
127    async fn poll_for_token(&self, device_code: &str) -> AuthResult<MicrosoftTokenResponse> {
128        lighty_core::trace_debug!("Polling for Microsoft token");
129
130        let start = std::time::Instant::now();
131
132        loop {
133            if start.elapsed() > self.timeout {
134                lighty_core::trace_error!("Device code expired");
135                return Err(AuthError::DeviceCodeExpired);
136            }
137
138            sleep(self.poll_interval).await;
139
140            let response = CLIENT
141                .post(&format!("{}/token", MS_AUTH_URL))
142                .form(&[
143                    ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
144                    ("client_id", &self.client_id),
145                    ("device_code", device_code),
146                ])
147                .send()
148                .await?;
149
150            if response.status().is_success() {
151                let token: MicrosoftTokenResponse = response.json().await?;
152                lighty_core::trace_info!("Microsoft token obtained");
153                return Ok(token);
154            }
155
156            let error: OAuthError = response.json().await?;
157
158            match error.error.as_str() {
159                "authorization_pending" => {
160                    lighty_core::trace_debug!("Authorization pending, continuing to poll");
161                    continue;
162                }
163                "authorization_declined" => {
164                    lighty_core::trace_error!("User declined authorization");
165                    return Err(AuthError::Cancelled);
166                }
167                "expired_token" => {
168                    lighty_core::trace_error!("Device code expired");
169                    return Err(AuthError::DeviceCodeExpired);
170                }
171                _ => {
172                    lighty_core::trace_error!(error = %error.error, description = ?error.error_description, "OAuth error");
173                    return Err(AuthError::Custom(error.error));
174                }
175            }
176        }
177    }
178
179    /// Step 3: Exchange Microsoft token for Xbox Live token
180    async fn get_xbox_token(&self, ms_token: &str) -> AuthResult<XboxTokenResponse> {
181        lighty_core::trace_debug!("Requesting Xbox Live token");
182
183        let response = CLIENT
184            .post(XBOX_AUTH_URL)
185            .json(&serde_json::json!({
186                "Properties": {
187                    "AuthMethod": "RPS",
188                    "SiteName": "user.auth.xboxlive.com",
189                    "RpsTicket": format!("d={}", ms_token)
190                },
191                "RelyingParty": "http://auth.xboxlive.com",
192                "TokenType": "JWT"
193            }))
194            .send()
195            .await?;
196
197        if !response.status().is_success() {
198            let error_text = response.text().await?;
199            lighty_core::trace_error!(error = %error_text, "Failed to get Xbox Live token");
200            return Err(AuthError::InvalidResponse(error_text));
201        }
202
203        let xbox_token: XboxTokenResponse = response.json().await?;
204        lighty_core::trace_info!("Xbox Live token obtained");
205
206        Ok(xbox_token)
207    }
208
209    /// Step 4: Exchange Xbox Live token for XSTS token
210    async fn get_xsts_token(&self, xbox_token: &str) -> AuthResult<XboxTokenResponse> {
211        lighty_core::trace_debug!("Requesting XSTS token");
212
213        let response = CLIENT
214            .post(XSTS_AUTH_URL)
215            .json(&serde_json::json!({
216                "Properties": {
217                    "SandboxId": "RETAIL",
218                    "UserTokens": [xbox_token]
219                },
220                "RelyingParty": "rp://api.minecraftservices.com/",
221                "TokenType": "JWT"
222            }))
223            .send()
224            .await?;
225
226        if !response.status().is_success() {
227            let status = response.status();
228            let error_text = response.text().await?;
229
230            // Check for specific error codes
231            if error_text.contains("2148916233") {
232                lighty_core::trace_error!("Account doesn't own Minecraft");
233                return Err(AuthError::Custom("This Microsoft account doesn't own Minecraft".into()));
234            }
235            if error_text.contains("2148916238") {
236                lighty_core::trace_error!("Account is from a country where Xbox Live is unavailable");
237                return Err(AuthError::Custom("Xbox Live is not available in your country".into()));
238            }
239
240            lighty_core::trace_error!(status = %status, error = %error_text, "Failed to get XSTS token");
241            return Err(AuthError::InvalidResponse(error_text));
242        }
243
244        let xsts_token: XboxTokenResponse = response.json().await?;
245        lighty_core::trace_info!("XSTS token obtained");
246
247        Ok(xsts_token)
248    }
249
250    /// Step 5: Exchange XSTS token for Minecraft token
251    async fn get_minecraft_token(&self, xsts_token: &str, uhs: &str) -> AuthResult<MinecraftTokenResponse> {
252        lighty_core::trace_debug!("Requesting Minecraft token");
253
254        let response = CLIENT
255            .post(MC_AUTH_URL)
256            .json(&serde_json::json!({
257                "identityToken": format!("XBL3.0 x={};{}", uhs, xsts_token)
258            }))
259            .send()
260            .await?;
261
262        if !response.status().is_success() {
263            let error_text = response.text().await?;
264            lighty_core::trace_error!(error = %error_text, "Failed to get Minecraft token");
265            return Err(AuthError::InvalidResponse(error_text));
266        }
267
268        let mc_token: MinecraftTokenResponse = response.json().await?;
269        lighty_core::trace_info!("Minecraft token obtained");
270
271        Ok(mc_token)
272    }
273
274    /// Step 6: Fetch Minecraft profile
275    async fn get_minecraft_profile(&self, mc_token: &str) -> AuthResult<MinecraftProfile> {
276        lighty_core::trace_debug!("Fetching Minecraft profile");
277
278        let response = CLIENT
279            .get(MC_PROFILE_URL)
280            .header("Authorization", format!("Bearer {}", mc_token))
281            .send()
282            .await?;
283
284        if !response.status().is_success() {
285            let status = response.status();
286            let error_text = response.text().await?;
287            lighty_core::trace_error!(status = %status, error = %error_text, "Failed to get Minecraft profile");
288            return Err(AuthError::InvalidResponse(error_text));
289        }
290
291        let profile: MinecraftProfile = response.json().await?;
292        lighty_core::trace_info!(username = %profile.name, uuid = %profile.id, "Minecraft profile obtained");
293
294        Ok(profile)
295    }
296}
297
298impl Authenticator for MicrosoftAuth {
299    async fn authenticate(
300        &mut self,
301        #[cfg(feature = "events")] event_bus: Option<&EventBus>,
302    ) -> AuthResult<UserProfile> {
303        // Emit authentication started
304        #[cfg(feature = "events")]
305        if let Some(bus) = event_bus {
306            bus.emit(Event::Auth(AuthEvent::AuthenticationStarted {
307                provider: "Microsoft".to_string(),
308            }));
309        }
310
311        // Step 1: Request device code
312        #[cfg(feature = "events")]
313        if let Some(bus) = event_bus {
314            bus.emit(Event::Auth(AuthEvent::AuthenticationInProgress {
315                provider: "Microsoft".to_string(),
316                step: "Requesting device code".to_string(),
317            }));
318        }
319
320        let device_code_response = match self.request_device_code().await {
321            Ok(response) => response,
322            Err(e) => {
323                #[cfg(feature = "events")]
324                if let Some(bus) = event_bus {
325                    bus.emit(Event::Auth(AuthEvent::AuthenticationFailed {
326                        provider: "Microsoft".to_string(),
327                        error: format!("Failed to request device code: {}", e),
328                    }));
329                }
330                return Err(e);
331            }
332        };
333
334        // Notify user via callback
335        if let Some(callback) = &self.device_code_callback {
336            callback(&device_code_response.user_code, &device_code_response.verification_uri);
337        } else {
338            lighty_core::trace_warn!("No device code callback set - user won't see the authorization URL");
339        }
340
341        // Step 2: Poll for Microsoft token
342        #[cfg(feature = "events")]
343        if let Some(bus) = event_bus {
344            bus.emit(Event::Auth(AuthEvent::AuthenticationInProgress {
345                provider: "Microsoft".to_string(),
346                step: "Waiting for user authorization".to_string(),
347            }));
348        }
349
350        let ms_token = match self.poll_for_token(&device_code_response.device_code).await {
351            Ok(token) => token,
352            Err(e) => {
353                #[cfg(feature = "events")]
354                if let Some(bus) = event_bus {
355                    bus.emit(Event::Auth(AuthEvent::AuthenticationFailed {
356                        provider: "Microsoft".to_string(),
357                        error: format!("Failed to get Microsoft token: {}", e),
358                    }));
359                }
360                return Err(e);
361            }
362        };
363
364        // Step 3: Get Xbox Live token
365        #[cfg(feature = "events")]
366        if let Some(bus) = event_bus {
367            bus.emit(Event::Auth(AuthEvent::AuthenticationInProgress {
368                provider: "Microsoft".to_string(),
369                step: "Exchanging for Xbox Live token".to_string(),
370            }));
371        }
372
373        let xbox_token = match self.get_xbox_token(&ms_token.access_token).await {
374            Ok(token) => token,
375            Err(e) => {
376                #[cfg(feature = "events")]
377                if let Some(bus) = event_bus {
378                    bus.emit(Event::Auth(AuthEvent::AuthenticationFailed {
379                        provider: "Microsoft".to_string(),
380                        error: format!("Failed to get Xbox Live token: {}", e),
381                    }));
382                }
383                return Err(e);
384            }
385        };
386
387        // Step 4: Get XSTS token
388        #[cfg(feature = "events")]
389        if let Some(bus) = event_bus {
390            bus.emit(Event::Auth(AuthEvent::AuthenticationInProgress {
391                provider: "Microsoft".to_string(),
392                step: "Exchanging for XSTS token".to_string(),
393            }));
394        }
395
396        let xsts_token = match self.get_xsts_token(&xbox_token.token).await {
397            Ok(token) => token,
398            Err(e) => {
399                #[cfg(feature = "events")]
400                if let Some(bus) = event_bus {
401                    bus.emit(Event::Auth(AuthEvent::AuthenticationFailed {
402                        provider: "Microsoft".to_string(),
403                        error: format!("Failed to get XSTS token: {}", e),
404                    }));
405                }
406                return Err(e);
407            }
408        };
409
410        // Extract UHS from XSTS token
411        let uhs = xsts_token
412            .display_claims
413            .get("xui")
414            .and_then(|xui| xui.get(0))
415            .and_then(|user| user.get("uhs"))
416            .and_then(|v| v.as_str())
417            .ok_or_else(|| {
418                let error = AuthError::InvalidResponse("Missing UHS in XSTS token".into());
419                #[cfg(feature = "events")]
420                if let Some(bus) = event_bus {
421                    bus.emit(Event::Auth(AuthEvent::AuthenticationFailed {
422                        provider: "Microsoft".to_string(),
423                        error: "Missing UHS in XSTS token".to_string(),
424                    }));
425                }
426                error
427            })?;
428
429        // Step 5: Get Minecraft token
430        #[cfg(feature = "events")]
431        if let Some(bus) = event_bus {
432            bus.emit(Event::Auth(AuthEvent::AuthenticationInProgress {
433                provider: "Microsoft".to_string(),
434                step: "Exchanging for Minecraft token".to_string(),
435            }));
436        }
437
438        let mc_token = match self.get_minecraft_token(&xsts_token.token, uhs).await {
439            Ok(token) => token,
440            Err(e) => {
441                #[cfg(feature = "events")]
442                if let Some(bus) = event_bus {
443                    bus.emit(Event::Auth(AuthEvent::AuthenticationFailed {
444                        provider: "Microsoft".to_string(),
445                        error: format!("Failed to get Minecraft token: {}", e),
446                    }));
447                }
448                return Err(e);
449            }
450        };
451
452        // Step 6: Get Minecraft profile
453        #[cfg(feature = "events")]
454        if let Some(bus) = event_bus {
455            bus.emit(Event::Auth(AuthEvent::AuthenticationInProgress {
456                provider: "Microsoft".to_string(),
457                step: "Fetching Minecraft profile".to_string(),
458            }));
459        }
460
461        let mc_profile = match self.get_minecraft_profile(&mc_token.access_token).await {
462            Ok(profile) => profile,
463            Err(e) => {
464                #[cfg(feature = "events")]
465                if let Some(bus) = event_bus {
466                    bus.emit(Event::Auth(AuthEvent::AuthenticationFailed {
467                        provider: "Microsoft".to_string(),
468                        error: format!("Failed to get Minecraft profile: {}", e),
469                    }));
470                }
471                return Err(e);
472            }
473        };
474
475        // Format UUID with dashes
476        let uuid = format_uuid(&mc_profile.id);
477
478        // Emit authentication success
479        #[cfg(feature = "events")]
480        if let Some(bus) = event_bus {
481            bus.emit(Event::Auth(AuthEvent::AuthenticationSuccess {
482                provider: "Microsoft".to_string(),
483                username: mc_profile.name.clone(),
484                uuid: uuid.clone(),
485            }));
486        }
487
488        Ok(UserProfile {
489            id: None,
490            username: mc_profile.name,
491            uuid,
492            access_token: Some(mc_token.access_token),
493            email: None,
494            email_verified: true,
495            money: None,
496            role: None,
497            banned: false,
498        })
499    }
500}
501
502/// Format UUID string with dashes
503fn format_uuid(uuid: &str) -> String {
504    if uuid.len() != 32 {
505        return uuid.to_string();
506    }
507
508    format!(
509        "{}-{}-{}-{}-{}",
510        &uuid[0..8],
511        &uuid[8..12],
512        &uuid[12..16],
513        &uuid[16..20],
514        &uuid[20..32]
515    )
516}
517
518// Response structures
519
520#[derive(Debug, Deserialize)]
521struct DeviceCodeResponse {
522    device_code: String,
523    user_code: String,
524    verification_uri: String,
525    expires_in: u64,
526    interval: u64,
527}
528
529#[derive(Debug, Deserialize)]
530struct MicrosoftTokenResponse {
531    access_token: String,
532    refresh_token: Option<String>,
533    expires_in: u64,
534}
535
536#[derive(Debug, Deserialize)]
537struct XboxTokenResponse {
538    #[serde(rename = "Token")]
539    token: String,
540    #[serde(rename = "DisplayClaims")]
541    display_claims: serde_json::Value,
542}
543
544#[derive(Debug, Deserialize)]
545struct MinecraftTokenResponse {
546    access_token: String,
547    expires_in: u64,
548}
549
550#[derive(Debug, Deserialize)]
551struct MinecraftProfile {
552    id: String,
553    name: String,
554}
555
556#[derive(Debug, Deserialize)]
557struct OAuthError {
558    error: String,
559    error_description: Option<String>,
560}