1use 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
31pub 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 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), }
74 }
75
76 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 pub fn set_poll_interval(&mut self, interval: Duration) {
91 self.poll_interval = interval;
92 }
93
94 pub fn set_timeout(&mut self, timeout: Duration) {
98 self.timeout = timeout;
99 }
100
101 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 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 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 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 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 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 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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 let uuid = format_uuid(&mc_profile.id);
477
478 #[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
502fn 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#[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}