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(email: &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, email).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, email, 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 =
114 interactive_get_ms_auth_token(&client, email, Some(client_id), Some(scope))
115 .await?;
116 }
117 }
118 }
119
120 let msa_token = &msa.data.access_token;
121 trace!("Got access token: {msa_token}");
122
123 let res = get_minecraft_token(&client, msa_token).await?;
124
125 if opts.check_ownership {
126 let has_game = check_ownership(&client, &res.minecraft_access_token).await?;
127 if !has_game {
128 return Err(AuthError::DoesNotOwnGame);
129 }
130 }
131
132 let profile: ProfileResponse = get_profile(&client, &res.minecraft_access_token).await?;
133
134 if let Some(cache_file) = opts.cache_file
135 && let Err(e) = cache::set_account_in_cache(
136 &cache_file,
137 email,
138 CachedAccount {
139 email: email.to_string(),
140 mca: res.mca,
141 msa,
142 xbl: res.xbl,
143 profile: profile.clone(),
144 },
145 )
146 .await
147 {
148 error!("{}", e);
149 }
150
151 Ok(AuthResult {
152 access_token: res.minecraft_access_token,
153 profile,
154 })
155 }
156}
157
158pub async fn get_minecraft_token(
169 client: &reqwest::Client,
170 msa: &str,
171) -> Result<MinecraftTokenResponse, AuthError> {
172 let xbl_auth = auth_with_xbox_live(client, msa).await?;
173
174 let xsts_token = obtain_xsts_for_minecraft(
175 client,
176 &xbl_auth
177 .get()
178 .expect("Xbox Live auth token shouldn't have expired yet")
179 .token,
180 )
181 .await?;
182
183 let mca = auth_with_minecraft(client, &xbl_auth.data.user_hash, &xsts_token).await?;
185
186 let minecraft_access_token: String = mca
187 .get()
188 .expect("Minecraft auth shouldn't have expired yet")
189 .access_token
190 .to_string();
191
192 Ok(MinecraftTokenResponse {
193 mca,
194 xbl: xbl_auth,
195 minecraft_access_token,
196 })
197}
198
199#[derive(Debug)]
200pub struct MinecraftTokenResponse {
201 pub mca: ExpiringValue<MinecraftAuthResponse>,
202 pub xbl: ExpiringValue<XboxLiveAuth>,
203 pub minecraft_access_token: String,
204}
205
206#[derive(Debug)]
207pub struct AuthResult {
208 pub access_token: String,
209 pub profile: ProfileResponse,
210}
211
212#[derive(Debug, Deserialize)]
213pub struct DeviceCodeResponse {
214 pub user_code: String,
215 pub device_code: String,
216 pub verification_uri: String,
217 pub expires_in: u64,
218 pub interval: u64,
219}
220
221#[allow(unused)]
222#[derive(Debug, Deserialize, Serialize, Clone)]
223pub struct AccessTokenResponse {
224 pub token_type: String,
225 pub expires_in: u64,
226 pub scope: String,
227 pub access_token: String,
228 pub refresh_token: String,
229 pub user_id: String,
230}
231
232#[allow(unused)]
233#[derive(Debug, Deserialize)]
234#[serde(rename_all = "PascalCase")]
235pub struct XboxLiveAuthResponse {
236 pub issue_instant: String,
237 pub not_after: String,
238 pub token: String,
239 pub display_claims: HashMap<String, Vec<HashMap<String, String>>>,
240}
241
242#[derive(Serialize, Deserialize, Debug)]
244pub struct XboxLiveAuth {
245 pub token: String,
246 pub user_hash: String,
247}
248
249#[allow(unused)]
250#[derive(Debug, Deserialize, Serialize)]
251pub struct MinecraftAuthResponse {
252 pub username: String,
253 pub roles: Vec<String>,
254 pub access_token: String,
255 pub token_type: String,
256 pub expires_in: u64,
257}
258
259#[derive(Debug, Deserialize)]
260pub struct GameOwnershipResponse {
261 pub items: Vec<GameOwnershipItem>,
262 pub signature: String,
263 #[serde(rename = "keyId")]
264 pub key_id: String,
265}
266
267#[allow(unused)]
268#[derive(Debug, Deserialize)]
269pub struct GameOwnershipItem {
270 pub name: String,
271 pub signature: String,
272}
273
274#[derive(Debug, Clone, Deserialize, Serialize)]
275pub struct ProfileResponse {
276 pub id: Uuid,
277 pub name: String,
278 pub skins: Vec<serde_json::Value>,
279 pub capes: Vec<serde_json::Value>,
280}
281
282const CLIENT_ID: &str = "00000000441cc96b";
284const SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
285
286#[derive(Debug, Error)]
287pub enum GetMicrosoftAuthTokenError {
288 #[error("Http error: {0}")]
289 Http(#[from] reqwest::Error),
290 #[error("Authentication timed out")]
291 Timeout,
292}
293
294pub async fn get_ms_link_code(
318 client: &reqwest::Client,
319 client_id: Option<&str>,
320 scope: Option<&str>,
321) -> Result<DeviceCodeResponse, GetMicrosoftAuthTokenError> {
322 let client_id = if let Some(c) = client_id {
323 c
324 } else {
325 CLIENT_ID
326 };
327
328 let scope = if let Some(c) = scope { c } else { SCOPE };
329
330 Ok(client
331 .post("https://login.live.com/oauth20_connect.srf")
332 .form(&[
333 ("scope", scope),
334 ("client_id", client_id),
335 ("response_type", "device_code"),
336 ])
337 .send()
338 .await?
339 .json::<DeviceCodeResponse>()
340 .await?)
341}
342
343pub async fn get_ms_auth_token(
348 client: &reqwest::Client,
349 res: DeviceCodeResponse,
350 client_id: Option<&str>,
351) -> Result<ExpiringValue<AccessTokenResponse>, GetMicrosoftAuthTokenError> {
352 let client_id = if let Some(c) = client_id {
353 c
354 } else {
355 CLIENT_ID
356 };
357
358 let login_expires_at = Instant::now() + Duration::from_secs(res.expires_in);
359
360 while Instant::now() < login_expires_at {
361 sleep(Duration::from_secs(res.interval)).await;
362
363 trace!("Polling to check if user has logged in...");
364 let res = client
365 .post(format!(
366 "https://login.live.com/oauth20_token.srf?client_id={client_id}"
367 ))
368 .form(&[
369 ("client_id", client_id),
370 ("device_code", &res.device_code),
371 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
372 ])
373 .send()
374 .await?
375 .json::<AccessTokenResponse>()
376 .await;
377 if let Ok(access_token_response) = res {
378 trace!("access_token_response: {:?}", access_token_response);
379 let expires_at =
380 SystemTime::now() + Duration::from_secs(access_token_response.expires_in);
381 return Ok(ExpiringValue {
382 data: access_token_response,
383 expires_at: expires_at
384 .duration_since(UNIX_EPOCH)
385 .expect("Time went backwards")
386 .as_secs(),
387 });
388 }
389 }
390
391 Err(GetMicrosoftAuthTokenError::Timeout)
392}
393
394pub async fn interactive_get_ms_auth_token(
398 client: &reqwest::Client,
399 email: &str,
400 client_id: Option<&str>,
401 scope: Option<&str>,
402) -> Result<ExpiringValue<AccessTokenResponse>, GetMicrosoftAuthTokenError> {
403 let res = get_ms_link_code(client, client_id, scope).await?;
404 trace!("Device code response: {:?}", res);
405 println!(
406 "Go to \x1b[1m{}\x1b[m and enter the code \x1b[1m{}\x1b[m for \x1b[1m{}\x1b[m",
407 res.verification_uri, res.user_code, email
408 );
409
410 get_ms_auth_token(client, res, client_id).await
411}
412
413#[derive(Debug, Error)]
414pub enum RefreshMicrosoftAuthTokenError {
415 #[error("Http error: {0}")]
416 Http(#[from] reqwest::Error),
417 #[error("Error parsing JSON: {0}")]
418 Json(#[from] serde_json::Error),
419}
420
421pub async fn refresh_ms_auth_token(
422 client: &reqwest::Client,
423 refresh_token: &str,
424 client_id: Option<&str>,
425 scope: Option<&str>,
426) -> Result<ExpiringValue<AccessTokenResponse>, RefreshMicrosoftAuthTokenError> {
427 let client_id = client_id.unwrap_or(CLIENT_ID);
428 let scope = scope.unwrap_or(SCOPE);
429
430 let access_token_response_text = client
431 .post("https://login.live.com/oauth20_token.srf")
432 .form(&[
433 ("scope", scope),
434 ("client_id", client_id),
435 ("grant_type", "refresh_token"),
436 ("refresh_token", refresh_token),
437 ])
438 .send()
439 .await?
440 .text()
441 .await?;
442 let access_token_response: AccessTokenResponse =
443 serde_json::from_str(&access_token_response_text)?;
444
445 let expires_at = SystemTime::now() + Duration::from_secs(access_token_response.expires_in);
446 Ok(ExpiringValue {
447 data: access_token_response,
448 expires_at: expires_at
449 .duration_since(UNIX_EPOCH)
450 .expect("Time went backwards")
451 .as_secs(),
452 })
453}
454
455#[derive(Debug, Error)]
456pub enum XboxLiveAuthError {
457 #[error("Http error: {0}")]
458 Http(#[from] reqwest::Error),
459 #[error("Invalid expiry date: {0}")]
460 InvalidExpiryDate(String),
461}
462
463async fn auth_with_xbox_live(
464 client: &reqwest::Client,
465 access_token: &str,
466) -> Result<ExpiringValue<XboxLiveAuth>, XboxLiveAuthError> {
467 let auth_json = json!({
468 "Properties": {
469 "AuthMethod": "RPS",
470 "SiteName": "user.auth.xboxlive.com",
471 "RpsTicket": access_token
474 },
475 "RelyingParty": "http://auth.xboxlive.com",
476 "TokenType": "JWT"
477 });
478 let payload = auth_json.to_string();
479 trace!("auth_json: {:#?}", auth_json);
480 let res = client
481 .post("https://user.auth.xboxlive.com/user/authenticate")
482 .header("Content-Type", "application/json")
483 .header("Accept", "application/json")
484 .header("x-xbl-contract-version", "1")
485 .body(payload)
488 .send()
489 .await?
490 .json::<XboxLiveAuthResponse>()
491 .await?;
492 trace!("Xbox Live auth response: {:?}", res);
493
494 let expires_at = DateTime::parse_from_rfc3339(&res.not_after)
496 .map_err(|e| XboxLiveAuthError::InvalidExpiryDate(format!("{}: {e}", res.not_after)))?
497 .with_timezone(&Utc)
498 .timestamp() as u64;
499 Ok(ExpiringValue {
500 data: XboxLiveAuth {
501 token: res.token,
502 user_hash: res.display_claims["xui"].first().unwrap()["uhs"].clone(),
503 },
504 expires_at,
505 })
506}
507
508#[derive(Debug, Error)]
509pub enum MinecraftXstsAuthError {
510 #[error("Http error: {0}")]
511 Http(#[from] reqwest::Error),
512}
513
514async fn obtain_xsts_for_minecraft(
515 client: &reqwest::Client,
516 xbl_auth_token: &str,
517) -> Result<String, MinecraftXstsAuthError> {
518 let res = client
519 .post("https://xsts.auth.xboxlive.com/xsts/authorize")
520 .header("Accept", "application/json")
521 .json(&json!({
522 "Properties": {
523 "SandboxId": "RETAIL",
524 "UserTokens": [xbl_auth_token.to_string()]
525 },
526 "RelyingParty": "rp://api.minecraftservices.com/",
527 "TokenType": "JWT"
528 }))
529 .send()
530 .await?
531 .json::<XboxLiveAuthResponse>()
532 .await?;
533 trace!("Xbox Live auth response (for XSTS): {:?}", res);
534
535 Ok(res.token)
536}
537
538#[derive(Debug, Error)]
539pub enum MinecraftAuthError {
540 #[error("Http error: {0}")]
541 Http(#[from] reqwest::Error),
542}
543
544async fn auth_with_minecraft(
545 client: &reqwest::Client,
546 user_hash: &str,
547 xsts_token: &str,
548) -> Result<ExpiringValue<MinecraftAuthResponse>, MinecraftAuthError> {
549 let res = client
550 .post("https://api.minecraftservices.com/authentication/login_with_xbox")
551 .header("Accept", "application/json")
552 .json(&json!({
553 "identityToken": format!("XBL3.0 x={user_hash};{xsts_token}")
554 }))
555 .send()
556 .await?
557 .json::<MinecraftAuthResponse>()
558 .await?;
559 trace!("{:?}", res);
560
561 let expires_at = SystemTime::now() + Duration::from_secs(res.expires_in);
562 Ok(ExpiringValue {
563 data: res,
564 expires_at: expires_at.duration_since(UNIX_EPOCH).unwrap().as_secs(),
566 })
567}
568
569#[derive(Debug, Error)]
570pub enum CheckOwnershipError {
571 #[error("Http error: {0}")]
572 Http(#[from] reqwest::Error),
573}
574
575pub async fn check_ownership(
576 client: &reqwest::Client,
577 minecraft_access_token: &str,
578) -> Result<bool, CheckOwnershipError> {
579 let res = client
580 .get("https://api.minecraftservices.com/entitlements/mcstore")
581 .header("Authorization", format!("Bearer {minecraft_access_token}"))
582 .send()
583 .await?
584 .json::<GameOwnershipResponse>()
585 .await?;
586 trace!("{:?}", res);
587
588 Ok(!res.items.is_empty())
592}
593
594#[derive(Debug, Error)]
595pub enum GetProfileError {
596 #[error("Http error: {0}")]
597 Http(#[from] reqwest::Error),
598}
599
600pub async fn get_profile(
601 client: &reqwest::Client,
602 minecraft_access_token: &str,
603) -> Result<ProfileResponse, GetProfileError> {
604 let res = client
605 .get("https://api.minecraftservices.com/minecraft/profile")
606 .header("Authorization", format!("Bearer {minecraft_access_token}"))
607 .send()
608 .await?
609 .json::<ProfileResponse>()
610 .await?;
611 trace!("{:?}", res);
612
613 Ok(res)
614}