1use std::{
4 collections::HashMap,
5 path::PathBuf,
6 time::{Instant, SystemTime, UNIX_EPOCH},
7};
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use serde_json::json;
12use thiserror::Error;
13use tracing::{error, trace};
14use uuid::Uuid;
15
16use crate::cache::{self, CachedAccount, ExpiringValue};
17
18#[derive(Default)]
19pub struct AuthOpts<'a> {
20 pub check_ownership: bool,
24 pub cache_file: Option<PathBuf>,
30 pub client_id: Option<&'a str>,
33 pub scope: Option<&'a str>,
36}
37
38#[derive(Debug, Error)]
39pub enum AuthError {
40 #[error(
41 "The Minecraft API is indicating that you don't own the game. \
42 If you're using Xbox Game Pass, set `check_ownership` to false in the auth options."
43 )]
44 DoesNotOwnGame,
45 #[error("Error getting Microsoft auth token: {0}")]
46 GetMicrosoftAuthToken(#[from] GetMicrosoftAuthTokenError),
47 #[error("Error refreshing Microsoft auth token: {0}")]
48 RefreshMicrosoftAuthToken(#[from] RefreshMicrosoftAuthTokenError),
49 #[error("Error getting Xbox Live auth token: {0}")]
50 GetXboxLiveAuthToken(#[from] MinecraftXstsAuthError),
51 #[error("Error getting Minecraft profile: {0}")]
52 GetMinecraftProfile(#[from] GetProfileError),
53 #[error("Error checking ownership: {0}")]
54 CheckOwnership(#[from] CheckOwnershipError),
55 #[error("Error getting Minecraft auth token: {0}")]
56 GetMinecraftAuthToken(#[from] MinecraftAuthError),
57 #[error("Error authenticating with Xbox Live: {0}")]
58 GetXboxLiveAuth(#[from] XboxLiveAuthError),
59}
60
61pub async fn auth(email: &str, opts: AuthOpts<'_>) -> Result<AuthResult, AuthError> {
72 let cached_account = if let Some(cache_file) = &opts.cache_file {
73 cache::get_account_in_cache(cache_file, email).await
74 } else {
75 None
76 };
77
78 if cached_account.is_some() && !cached_account.as_ref().unwrap().mca.is_expired() {
79 let account = cached_account.as_ref().unwrap();
80 Ok(AuthResult {
84 access_token: account.mca.data.access_token.clone(),
85 profile: account.profile.clone(),
86 })
87 } else {
88 let client_id = opts.client_id.unwrap_or(CLIENT_ID);
89 let scope = opts.scope.unwrap_or(SCOPE);
90
91 let client = reqwest::Client::new();
92 let mut msa = if let Some(account) = cached_account {
93 account.msa
94 } else {
95 interactive_get_ms_auth_token(&client, email, Some(client_id), Some(scope)).await?
96 };
97 if msa.is_expired() {
98 trace!("refreshing Microsoft auth token");
99 match refresh_ms_auth_token(
100 &client,
101 &msa.data.refresh_token,
102 opts.client_id,
103 opts.scope,
104 )
105 .await
106 {
107 Ok(new_msa) => msa = new_msa,
108 Err(e) => {
109 error!("Error refreshing Microsoft auth token: {}", e);
111 msa =
112 interactive_get_ms_auth_token(&client, email, Some(client_id), Some(scope))
113 .await?;
114 }
115 }
116 }
117
118 let msa_token = &msa.data.access_token;
119 trace!("Got access token: {msa_token}");
120
121 let res = get_minecraft_token(&client, msa_token).await?;
122
123 if opts.check_ownership {
124 let has_game = check_ownership(&client, &res.minecraft_access_token).await?;
125 if !has_game {
126 return Err(AuthError::DoesNotOwnGame);
127 }
128 }
129
130 let profile: ProfileResponse = get_profile(&client, &res.minecraft_access_token).await?;
131
132 if let Some(cache_file) = opts.cache_file {
133 if let Err(e) = cache::set_account_in_cache(
134 &cache_file,
135 email,
136 CachedAccount {
137 email: email.to_string(),
138 mca: res.mca,
139 msa,
140 xbl: res.xbl,
141 profile: profile.clone(),
142 },
143 )
144 .await
145 {
146 error!("{}", e);
147 }
148 }
149
150 Ok(AuthResult {
151 access_token: res.minecraft_access_token,
152 profile,
153 })
154 }
155}
156
157pub async fn get_minecraft_token(
168 client: &reqwest::Client,
169 msa: &str,
170) -> Result<MinecraftTokenResponse, AuthError> {
171 let xbl_auth = auth_with_xbox_live(client, msa).await?;
172
173 let xsts_token = obtain_xsts_for_minecraft(
174 client,
175 &xbl_auth
176 .get()
177 .expect("Xbox Live auth token shouldn't have expired yet")
178 .token,
179 )
180 .await?;
181
182 let mca = auth_with_minecraft(client, &xbl_auth.data.user_hash, &xsts_token).await?;
184
185 let minecraft_access_token: String = mca
186 .get()
187 .expect("Minecraft auth shouldn't have expired yet")
188 .access_token
189 .to_string();
190
191 Ok(MinecraftTokenResponse {
192 mca,
193 xbl: xbl_auth,
194 minecraft_access_token,
195 })
196}
197
198#[derive(Debug)]
199pub struct MinecraftTokenResponse {
200 pub mca: ExpiringValue<MinecraftAuthResponse>,
201 pub xbl: ExpiringValue<XboxLiveAuth>,
202 pub minecraft_access_token: String,
203}
204
205#[derive(Debug)]
206pub struct AuthResult {
207 pub access_token: String,
208 pub profile: ProfileResponse,
209}
210
211#[derive(Debug, Deserialize)]
212pub struct DeviceCodeResponse {
213 pub user_code: String,
214 pub device_code: String,
215 pub verification_uri: String,
216 pub expires_in: u64,
217 pub interval: u64,
218}
219
220#[allow(unused)]
221#[derive(Debug, Deserialize, Serialize, Clone)]
222pub struct AccessTokenResponse {
223 pub token_type: String,
224 pub expires_in: u64,
225 pub scope: String,
226 pub access_token: String,
227 pub refresh_token: String,
228 pub user_id: String,
229}
230
231#[allow(unused)]
232#[derive(Debug, Deserialize)]
233#[serde(rename_all = "PascalCase")]
234pub struct XboxLiveAuthResponse {
235 pub issue_instant: String,
236 pub not_after: String,
237 pub token: String,
238 pub display_claims: HashMap<String, Vec<HashMap<String, String>>>,
239}
240
241#[derive(Serialize, Deserialize, Debug)]
243pub struct XboxLiveAuth {
244 pub token: String,
245 pub user_hash: String,
246}
247
248#[allow(unused)]
249#[derive(Debug, Deserialize, Serialize)]
250pub struct MinecraftAuthResponse {
251 pub username: String,
252 pub roles: Vec<String>,
253 pub access_token: String,
254 pub token_type: String,
255 pub expires_in: u64,
256}
257
258#[derive(Debug, Deserialize)]
259pub struct GameOwnershipResponse {
260 pub items: Vec<GameOwnershipItem>,
261 pub signature: String,
262 #[serde(rename = "keyId")]
263 pub key_id: String,
264}
265
266#[allow(unused)]
267#[derive(Debug, Deserialize)]
268pub struct GameOwnershipItem {
269 pub name: String,
270 pub signature: String,
271}
272
273#[derive(Debug, Clone, Deserialize, Serialize)]
274pub struct ProfileResponse {
275 pub id: Uuid,
276 pub name: String,
277 pub skins: Vec<serde_json::Value>,
278 pub capes: Vec<serde_json::Value>,
279}
280
281const CLIENT_ID: &str = "00000000441cc96b";
283const SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
284
285#[derive(Debug, Error)]
286pub enum GetMicrosoftAuthTokenError {
287 #[error("Http error: {0}")]
288 Http(#[from] reqwest::Error),
289 #[error("Authentication timed out")]
290 Timeout,
291}
292
293pub async fn get_ms_link_code(
317 client: &reqwest::Client,
318 client_id: Option<&str>,
319 scope: Option<&str>,
320) -> Result<DeviceCodeResponse, GetMicrosoftAuthTokenError> {
321 let client_id = if let Some(c) = client_id {
322 c
323 } else {
324 CLIENT_ID
325 };
326
327 let scope = if let Some(c) = scope { c } else { SCOPE };
328
329 Ok(client
330 .post("https://login.live.com/oauth20_connect.srf")
331 .form(&vec![
332 ("scope", scope),
333 ("client_id", client_id),
334 ("response_type", "device_code"),
335 ])
336 .send()
337 .await?
338 .json::<DeviceCodeResponse>()
339 .await?)
340}
341
342pub async fn get_ms_auth_token(
347 client: &reqwest::Client,
348 res: DeviceCodeResponse,
349 client_id: Option<&str>,
350) -> Result<ExpiringValue<AccessTokenResponse>, GetMicrosoftAuthTokenError> {
351 let client_id = if let Some(c) = client_id {
352 c
353 } else {
354 CLIENT_ID
355 };
356
357 let login_expires_at = Instant::now() + std::time::Duration::from_secs(res.expires_in);
358
359 while Instant::now() < login_expires_at {
360 tokio::time::sleep(std::time::Duration::from_secs(res.interval)).await;
361
362 trace!("Polling to check if user has logged in...");
363 if let Ok(access_token_response) = client
364 .post(format!(
365 "https://login.live.com/oauth20_token.srf?client_id={client_id}"
366 ))
367 .form(&vec![
368 ("client_id", client_id),
369 ("device_code", &res.device_code),
370 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
371 ])
372 .send()
373 .await?
374 .json::<AccessTokenResponse>()
375 .await
376 {
377 trace!("access_token_response: {:?}", access_token_response);
378 let expires_at = SystemTime::now()
379 + std::time::Duration::from_secs(access_token_response.expires_in);
380 return Ok(ExpiringValue {
381 data: access_token_response,
382 expires_at: expires_at
383 .duration_since(UNIX_EPOCH)
384 .expect("Time went backwards")
385 .as_secs(),
386 });
387 }
388 }
389
390 Err(GetMicrosoftAuthTokenError::Timeout)
391}
392
393pub async fn interactive_get_ms_auth_token(
397 client: &reqwest::Client,
398 email: &str,
399 client_id: Option<&str>,
400 scope: Option<&str>,
401) -> Result<ExpiringValue<AccessTokenResponse>, GetMicrosoftAuthTokenError> {
402 let res = get_ms_link_code(client, client_id, scope).await?;
403 trace!("Device code response: {:?}", res);
404 println!(
405 "Go to \x1b[1m{}\x1b[m and enter the code \x1b[1m{}\x1b[m for \x1b[1m{}\x1b[m",
406 res.verification_uri, res.user_code, email
407 );
408
409 get_ms_auth_token(client, res, client_id).await
410}
411
412#[derive(Debug, Error)]
413pub enum RefreshMicrosoftAuthTokenError {
414 #[error("Http error: {0}")]
415 Http(#[from] reqwest::Error),
416 #[error("Error parsing JSON: {0}")]
417 Json(#[from] serde_json::Error),
418}
419
420pub async fn refresh_ms_auth_token(
421 client: &reqwest::Client,
422 refresh_token: &str,
423 client_id: Option<&str>,
424 scope: Option<&str>,
425) -> Result<ExpiringValue<AccessTokenResponse>, RefreshMicrosoftAuthTokenError> {
426 let client_id = client_id.unwrap_or(CLIENT_ID);
427 let scope = scope.unwrap_or(SCOPE);
428
429 let access_token_response_text = client
430 .post("https://login.live.com/oauth20_token.srf")
431 .form(&vec![
432 ("scope", scope),
433 ("client_id", client_id),
434 ("grant_type", "refresh_token"),
435 ("refresh_token", refresh_token),
436 ])
437 .send()
438 .await?
439 .text()
440 .await?;
441 let access_token_response: AccessTokenResponse =
442 serde_json::from_str(&access_token_response_text)?;
443
444 let expires_at =
445 SystemTime::now() + std::time::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() + std::time::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}