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