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 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 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 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 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 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 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 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 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 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 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
493fn 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
521fn 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#[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}