1use std::{
4 collections::HashMap,
5 path::PathBuf,
6 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
7};
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use serde_json::json;
12use thiserror::Error;
13use tokio::time::sleep;
14use tracing::{error, trace};
15use uuid::Uuid;
16
17use crate::cache::{self, CachedAccount, ExpiringValue};
18
19#[derive(Default)]
20pub struct AuthOpts<'a> {
21 pub check_ownership: bool,
25 pub cache_file: Option<PathBuf>,
31 pub client_id: Option<&'a str>,
34 pub scope: Option<&'a str>,
37}
38
39#[derive(Debug, Error)]
40pub enum AuthError {
41 #[error(
42 "The Minecraft API is indicating that you don't own the game. \
43 If you're using Xbox Game Pass, set `check_ownership` to false in the auth options."
44 )]
45 DoesNotOwnGame,
46 #[error("Error getting Microsoft auth token: {0}")]
47 GetMicrosoftAuthToken(#[from] GetMicrosoftAuthTokenError),
48 #[error("Error refreshing Microsoft auth token: {0}")]
49 RefreshMicrosoftAuthToken(#[from] RefreshMicrosoftAuthTokenError),
50 #[error("Error getting Xbox Live auth token: {0}")]
51 GetXboxLiveAuthToken(#[from] MinecraftXstsAuthError),
52 #[error("Error getting Minecraft profile: {0}")]
53 GetMinecraftProfile(#[from] GetProfileError),
54 #[error("Error checking ownership: {0}")]
55 CheckOwnership(#[from] CheckOwnershipError),
56 #[error("Error getting Minecraft auth token: {0}")]
57 GetMinecraftAuthToken(#[from] MinecraftAuthError),
58 #[error("Error authenticating with Xbox Live: {0}")]
59 GetXboxLiveAuth(#[from] XboxLiveAuthError),
60}
61
62pub async fn auth(cache_key: &str, opts: AuthOpts<'_>) -> Result<AuthResult, AuthError> {
73 let cached_account = if let Some(cache_file) = &opts.cache_file {
74 cache::get_account_in_cache(cache_file, cache_key).await
75 } else {
76 None
77 };
78
79 if let Some(account) = &cached_account
80 && !account.mca.is_expired()
81 {
82 Ok(AuthResult {
86 access_token: account.mca.data.access_token.clone(),
87 profile: account.profile.clone(),
88 })
89 } else {
90 let client_id = opts.client_id.unwrap_or(CLIENT_ID);
91 let scope = opts.scope.unwrap_or(SCOPE);
92
93 let client = reqwest::Client::new();
94 let mut msa = if let Some(account) = cached_account {
95 account.msa
96 } else {
97 interactive_get_ms_auth_token(&client, cache_key, Some(client_id), Some(scope)).await?
98 };
99 if msa.is_expired() {
100 trace!("refreshing Microsoft auth token");
101 match refresh_ms_auth_token(
102 &client,
103 &msa.data.refresh_token,
104 opts.client_id,
105 opts.scope,
106 )
107 .await
108 {
109 Ok(new_msa) => msa = new_msa,
110 Err(e) => {
111 error!("Error refreshing Microsoft auth token: {}", e);
113 msa = interactive_get_ms_auth_token(
114 &client,
115 cache_key,
116 Some(client_id),
117 Some(scope),
118 )
119 .await?;
120 }
121 }
122 }
123
124 let msa_token = &msa.data.access_token;
125 trace!("Got access token: {msa_token}");
126
127 let res = get_minecraft_token(&client, msa_token).await?;
128
129 if opts.check_ownership {
130 let has_game = check_ownership(&client, &res.minecraft_access_token).await?;
131 if !has_game {
132 return Err(AuthError::DoesNotOwnGame);
133 }
134 }
135
136 let profile: ProfileResponse = get_profile(&client, &res.minecraft_access_token).await?;
137
138 if let Some(cache_file) = opts.cache_file
139 && let Err(e) = cache::set_account_in_cache(
140 &cache_file,
141 cache_key,
142 CachedAccount {
143 cache_key: cache_key.to_string(),
144 mca: res.mca,
145 msa,
146 xbl: res.xbl,
147 profile: profile.clone(),
148 },
149 )
150 .await
151 {
152 error!("{}", e);
153 }
154
155 Ok(AuthResult {
156 access_token: res.minecraft_access_token,
157 profile,
158 })
159 }
160}
161
162pub async fn get_minecraft_token(
173 client: &reqwest::Client,
174 msa: &str,
175) -> Result<MinecraftTokenResponse, AuthError> {
176 let xbl_auth = auth_with_xbox_live(client, msa).await?;
177
178 let xsts_token = obtain_xsts_for_minecraft(
179 client,
180 &xbl_auth
181 .get()
182 .expect("Xbox Live auth token shouldn't have expired yet")
183 .token,
184 )
185 .await?;
186
187 let mca = auth_with_minecraft(client, &xbl_auth.data.user_hash, &xsts_token).await?;
189
190 let minecraft_access_token: String = mca
191 .get()
192 .expect("Minecraft auth shouldn't have expired yet")
193 .access_token
194 .to_string();
195
196 Ok(MinecraftTokenResponse {
197 mca,
198 xbl: xbl_auth,
199 minecraft_access_token,
200 })
201}
202
203#[derive(Debug)]
204pub struct MinecraftTokenResponse {
205 pub mca: ExpiringValue<MinecraftAuthResponse>,
206 pub xbl: ExpiringValue<XboxLiveAuth>,
207 pub minecraft_access_token: String,
208}
209
210#[derive(Debug)]
211pub struct AuthResult {
212 pub access_token: String,
213 pub profile: ProfileResponse,
214}
215
216#[derive(Debug, Deserialize)]
217pub struct DeviceCodeResponse {
218 pub user_code: String,
219 pub device_code: String,
220 pub verification_uri: String,
221 pub expires_in: u64,
222 pub interval: u64,
223}
224
225#[allow(unused)]
226#[derive(Debug, Deserialize, Serialize, Clone)]
227pub struct AccessTokenResponse {
228 pub token_type: String,
229 pub expires_in: u64,
230 pub scope: String,
231 pub access_token: String,
232 pub refresh_token: String,
233 pub user_id: String,
234}
235
236#[allow(unused)]
237#[derive(Debug, Deserialize)]
238#[serde(rename_all = "PascalCase")]
239pub struct XboxLiveAuthResponse {
240 pub issue_instant: String,
241 pub not_after: String,
242 pub token: String,
243 pub display_claims: HashMap<String, Vec<HashMap<String, String>>>,
244}
245
246#[derive(Serialize, Deserialize, Debug)]
248pub struct XboxLiveAuth {
249 pub token: String,
250 pub user_hash: String,
251}
252
253#[allow(unused)]
254#[derive(Debug, Deserialize, Serialize)]
255pub struct MinecraftAuthResponse {
256 pub username: String,
257 pub roles: Vec<String>,
258 pub access_token: String,
259 pub token_type: String,
260 pub expires_in: u64,
261}
262
263#[derive(Debug, Deserialize)]
264pub struct GameOwnershipResponse {
265 pub items: Vec<GameOwnershipItem>,
266 pub signature: String,
267 #[serde(rename = "keyId")]
268 pub key_id: String,
269}
270
271#[allow(unused)]
272#[derive(Debug, Deserialize)]
273pub struct GameOwnershipItem {
274 pub name: String,
275 pub signature: String,
276}
277
278#[derive(Debug, Clone, Deserialize, Serialize)]
279pub struct ProfileResponse {
280 pub id: Uuid,
281 pub name: String,
282 pub skins: Vec<serde_json::Value>,
283 pub capes: Vec<serde_json::Value>,
284}
285
286const CLIENT_ID: &str = "00000000441cc96b";
288const SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
289
290#[derive(Debug, Error)]
291pub enum GetMicrosoftAuthTokenError {
292 #[error("Http error: {0}")]
293 Http(#[from] reqwest::Error),
294 #[error("Authentication timed out")]
295 Timeout,
296}
297
298pub async fn get_ms_link_code(
322 client: &reqwest::Client,
323 client_id: Option<&str>,
324 scope: Option<&str>,
325) -> Result<DeviceCodeResponse, GetMicrosoftAuthTokenError> {
326 let client_id = if let Some(c) = client_id {
327 c
328 } else {
329 CLIENT_ID
330 };
331
332 let scope = if let Some(c) = scope { c } else { SCOPE };
333
334 Ok(client
335 .post("https://login.live.com/oauth20_connect.srf")
336 .form(&[
337 ("scope", scope),
338 ("client_id", client_id),
339 ("response_type", "device_code"),
340 ])
341 .send()
342 .await?
343 .json::<DeviceCodeResponse>()
344 .await?)
345}
346
347pub async fn get_ms_auth_token(
352 client: &reqwest::Client,
353 res: DeviceCodeResponse,
354 client_id: Option<&str>,
355) -> Result<ExpiringValue<AccessTokenResponse>, GetMicrosoftAuthTokenError> {
356 let client_id = if let Some(c) = client_id {
357 c
358 } else {
359 CLIENT_ID
360 };
361
362 let login_expires_at = Instant::now() + Duration::from_secs(res.expires_in);
363
364 while Instant::now() < login_expires_at {
365 sleep(Duration::from_secs(res.interval)).await;
366
367 trace!("Polling to check if user has logged in...");
368 let res = client
369 .post(format!(
370 "https://login.live.com/oauth20_token.srf?client_id={client_id}"
371 ))
372 .form(&[
373 ("client_id", client_id),
374 ("device_code", &res.device_code),
375 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
376 ])
377 .send()
378 .await?
379 .json::<AccessTokenResponse>()
380 .await;
381 if let Ok(access_token_response) = res {
382 trace!("access_token_response: {:?}", access_token_response);
383 let expires_at =
384 SystemTime::now() + Duration::from_secs(access_token_response.expires_in);
385 return Ok(ExpiringValue {
386 data: access_token_response,
387 expires_at: expires_at
388 .duration_since(UNIX_EPOCH)
389 .expect("Time went backwards")
390 .as_secs(),
391 });
392 }
393 }
394
395 Err(GetMicrosoftAuthTokenError::Timeout)
396}
397
398pub async fn interactive_get_ms_auth_token(
403 client: &reqwest::Client,
404 cache_key: &str,
405 client_id: Option<&str>,
406 scope: Option<&str>,
407) -> Result<ExpiringValue<AccessTokenResponse>, GetMicrosoftAuthTokenError> {
408 let res = get_ms_link_code(client, client_id, scope).await?;
409 trace!("Device code response: {:?}", res);
410 let verification_uri = &res.verification_uri;
411 let user_code = &res.user_code;
412 println!(
413 "Go to \x1b[1m{verification_uri}?otc={user_code}\x1b[m and enter the code \x1b[1m{user_code}\x1b[m for \x1b[1m{cache_key}\x1b[m",
414 );
415
416 get_ms_auth_token(client, res, client_id).await
417}
418
419#[derive(Debug, Error)]
420pub enum RefreshMicrosoftAuthTokenError {
421 #[error("Http error: {0}")]
422 Http(#[from] reqwest::Error),
423 #[error("Error parsing JSON: {0}")]
424 Json(#[from] serde_json::Error),
425}
426
427pub async fn refresh_ms_auth_token(
428 client: &reqwest::Client,
429 refresh_token: &str,
430 client_id: Option<&str>,
431 scope: Option<&str>,
432) -> Result<ExpiringValue<AccessTokenResponse>, RefreshMicrosoftAuthTokenError> {
433 let client_id = client_id.unwrap_or(CLIENT_ID);
434 let scope = scope.unwrap_or(SCOPE);
435
436 let access_token_response_text = client
437 .post("https://login.live.com/oauth20_token.srf")
438 .form(&[
439 ("scope", scope),
440 ("client_id", client_id),
441 ("grant_type", "refresh_token"),
442 ("refresh_token", refresh_token),
443 ])
444 .send()
445 .await?
446 .text()
447 .await?;
448 let access_token_response: AccessTokenResponse =
449 serde_json::from_str(&access_token_response_text)?;
450
451 let expires_at = SystemTime::now() + Duration::from_secs(access_token_response.expires_in);
452 Ok(ExpiringValue {
453 data: access_token_response,
454 expires_at: expires_at
455 .duration_since(UNIX_EPOCH)
456 .expect("Time went backwards")
457 .as_secs(),
458 })
459}
460
461#[derive(Debug, Error)]
462pub enum XboxLiveAuthError {
463 #[error("Http error: {0}")]
464 Http(#[from] reqwest::Error),
465 #[error("Invalid expiry date: {0}")]
466 InvalidExpiryDate(String),
467}
468
469async fn auth_with_xbox_live(
470 client: &reqwest::Client,
471 access_token: &str,
472) -> Result<ExpiringValue<XboxLiveAuth>, XboxLiveAuthError> {
473 let auth_json = json!({
474 "Properties": {
475 "AuthMethod": "RPS",
476 "SiteName": "user.auth.xboxlive.com",
477 "RpsTicket": access_token
480 },
481 "RelyingParty": "http://auth.xboxlive.com",
482 "TokenType": "JWT"
483 });
484 let payload = auth_json.to_string();
485 trace!("auth_json: {:#?}", auth_json);
486 let res = client
487 .post("https://user.auth.xboxlive.com/user/authenticate")
488 .header("Content-Type", "application/json")
489 .header("Accept", "application/json")
490 .header("x-xbl-contract-version", "1")
491 .body(payload)
494 .send()
495 .await?
496 .json::<XboxLiveAuthResponse>()
497 .await?;
498 trace!("Xbox Live auth response: {:?}", res);
499
500 let expires_at = DateTime::parse_from_rfc3339(&res.not_after)
502 .map_err(|e| XboxLiveAuthError::InvalidExpiryDate(format!("{}: {e}", res.not_after)))?
503 .with_timezone(&Utc)
504 .timestamp() as u64;
505 Ok(ExpiringValue {
506 data: XboxLiveAuth {
507 token: res.token,
508 user_hash: res.display_claims["xui"].first().unwrap()["uhs"].clone(),
509 },
510 expires_at,
511 })
512}
513
514#[derive(Debug, Error)]
515pub enum MinecraftXstsAuthError {
516 #[error("Http error: {0}")]
517 Http(#[from] reqwest::Error),
518}
519
520async fn obtain_xsts_for_minecraft(
521 client: &reqwest::Client,
522 xbl_auth_token: &str,
523) -> Result<String, MinecraftXstsAuthError> {
524 let res = client
525 .post("https://xsts.auth.xboxlive.com/xsts/authorize")
526 .header("Accept", "application/json")
527 .json(&json!({
528 "Properties": {
529 "SandboxId": "RETAIL",
530 "UserTokens": [xbl_auth_token.to_string()]
531 },
532 "RelyingParty": "rp://api.minecraftservices.com/",
533 "TokenType": "JWT"
534 }))
535 .send()
536 .await?
537 .json::<XboxLiveAuthResponse>()
538 .await?;
539 trace!("Xbox Live auth response (for XSTS): {:?}", res);
540
541 Ok(res.token)
542}
543
544#[derive(Debug, Error)]
545pub enum MinecraftAuthError {
546 #[error("Http error: {0}")]
547 Http(#[from] reqwest::Error),
548}
549
550async fn auth_with_minecraft(
551 client: &reqwest::Client,
552 user_hash: &str,
553 xsts_token: &str,
554) -> Result<ExpiringValue<MinecraftAuthResponse>, MinecraftAuthError> {
555 let res = client
556 .post("https://api.minecraftservices.com/authentication/login_with_xbox")
557 .header("Accept", "application/json")
558 .json(&json!({
559 "identityToken": format!("XBL3.0 x={user_hash};{xsts_token}")
560 }))
561 .send()
562 .await?
563 .json::<MinecraftAuthResponse>()
564 .await?;
565 trace!("{:?}", res);
566
567 let expires_at = SystemTime::now() + Duration::from_secs(res.expires_in);
568 Ok(ExpiringValue {
569 data: res,
570 expires_at: expires_at.duration_since(UNIX_EPOCH).unwrap().as_secs(),
572 })
573}
574
575#[derive(Debug, Error)]
576pub enum CheckOwnershipError {
577 #[error("Http error: {0}")]
578 Http(#[from] reqwest::Error),
579}
580
581pub async fn check_ownership(
582 client: &reqwest::Client,
583 minecraft_access_token: &str,
584) -> Result<bool, CheckOwnershipError> {
585 let res = client
586 .get("https://api.minecraftservices.com/entitlements/mcstore")
587 .header("Authorization", format!("Bearer {minecraft_access_token}"))
588 .send()
589 .await?
590 .json::<GameOwnershipResponse>()
591 .await?;
592 trace!("{:?}", res);
593
594 Ok(!res.items.is_empty())
598}
599
600#[derive(Debug, Error)]
601pub enum GetProfileError {
602 #[error("Http error: {0}")]
603 Http(#[from] reqwest::Error),
604}
605
606pub async fn get_profile(
607 client: &reqwest::Client,
608 minecraft_access_token: &str,
609) -> Result<ProfileResponse, GetProfileError> {
610 let res = client
611 .get("https://api.minecraftservices.com/minecraft/profile")
612 .header("Authorization", format!("Bearer {minecraft_access_token}"))
613 .send()
614 .await?
615 .json::<ProfileResponse>()
616 .await?;
617 trace!("{:?}", res);
618
619 Ok(res)
620}