1use 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
23pub 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 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 #[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 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 pub fn set_poll_interval(&mut self, interval: Duration) {
78 self.poll_interval = interval;
79 }
80
81 pub fn set_timeout(&mut self, timeout: Duration) {
83 self.timeout = timeout;
84 }
85
86 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 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 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 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 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 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 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 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 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 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
490fn 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
518fn 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#[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}