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 let verification_uri = &res.verification_uri;
406 let user_code = &res.user_code;
407 println!(
408 "Go to \x1b[1m{verification_uri}?otc={user_code}\x1b[m and enter the code \x1b[1m{user_code}\x1b[m for \x1b[1m{email}\x1b[m",
409 );
410
411 get_ms_auth_token(client, res, client_id).await
412}
413
414#[derive(Debug, Error)]
415pub enum RefreshMicrosoftAuthTokenError {
416 #[error("Http error: {0}")]
417 Http(#[from] reqwest::Error),
418 #[error("Error parsing JSON: {0}")]
419 Json(#[from] serde_json::Error),
420}
421
422pub async fn refresh_ms_auth_token(
423 client: &reqwest::Client,
424 refresh_token: &str,
425 client_id: Option<&str>,
426 scope: Option<&str>,
427) -> Result<ExpiringValue<AccessTokenResponse>, RefreshMicrosoftAuthTokenError> {
428 let client_id = client_id.unwrap_or(CLIENT_ID);
429 let scope = scope.unwrap_or(SCOPE);
430
431 let access_token_response_text = client
432 .post("https://login.live.com/oauth20_token.srf")
433 .form(&[
434 ("scope", scope),
435 ("client_id", client_id),
436 ("grant_type", "refresh_token"),
437 ("refresh_token", refresh_token),
438 ])
439 .send()
440 .await?
441 .text()
442 .await?;
443 let access_token_response: AccessTokenResponse =
444 serde_json::from_str(&access_token_response_text)?;
445
446 let expires_at = SystemTime::now() + Duration::from_secs(access_token_response.expires_in);
447 Ok(ExpiringValue {
448 data: access_token_response,
449 expires_at: expires_at
450 .duration_since(UNIX_EPOCH)
451 .expect("Time went backwards")
452 .as_secs(),
453 })
454}
455
456#[derive(Debug, Error)]
457pub enum XboxLiveAuthError {
458 #[error("Http error: {0}")]
459 Http(#[from] reqwest::Error),
460 #[error("Invalid expiry date: {0}")]
461 InvalidExpiryDate(String),
462}
463
464async fn auth_with_xbox_live(
465 client: &reqwest::Client,
466 access_token: &str,
467) -> Result<ExpiringValue<XboxLiveAuth>, XboxLiveAuthError> {
468 let auth_json = json!({
469 "Properties": {
470 "AuthMethod": "RPS",
471 "SiteName": "user.auth.xboxlive.com",
472 "RpsTicket": access_token
475 },
476 "RelyingParty": "http://auth.xboxlive.com",
477 "TokenType": "JWT"
478 });
479 let payload = auth_json.to_string();
480 trace!("auth_json: {:#?}", auth_json);
481 let res = client
482 .post("https://user.auth.xboxlive.com/user/authenticate")
483 .header("Content-Type", "application/json")
484 .header("Accept", "application/json")
485 .header("x-xbl-contract-version", "1")
486 .body(payload)
489 .send()
490 .await?
491 .json::<XboxLiveAuthResponse>()
492 .await?;
493 trace!("Xbox Live auth response: {:?}", res);
494
495 let expires_at = DateTime::parse_from_rfc3339(&res.not_after)
497 .map_err(|e| XboxLiveAuthError::InvalidExpiryDate(format!("{}: {e}", res.not_after)))?
498 .with_timezone(&Utc)
499 .timestamp() as u64;
500 Ok(ExpiringValue {
501 data: XboxLiveAuth {
502 token: res.token,
503 user_hash: res.display_claims["xui"].first().unwrap()["uhs"].clone(),
504 },
505 expires_at,
506 })
507}
508
509#[derive(Debug, Error)]
510pub enum MinecraftXstsAuthError {
511 #[error("Http error: {0}")]
512 Http(#[from] reqwest::Error),
513}
514
515async fn obtain_xsts_for_minecraft(
516 client: &reqwest::Client,
517 xbl_auth_token: &str,
518) -> Result<String, MinecraftXstsAuthError> {
519 let res = client
520 .post("https://xsts.auth.xboxlive.com/xsts/authorize")
521 .header("Accept", "application/json")
522 .json(&json!({
523 "Properties": {
524 "SandboxId": "RETAIL",
525 "UserTokens": [xbl_auth_token.to_string()]
526 },
527 "RelyingParty": "rp://api.minecraftservices.com/",
528 "TokenType": "JWT"
529 }))
530 .send()
531 .await?
532 .json::<XboxLiveAuthResponse>()
533 .await?;
534 trace!("Xbox Live auth response (for XSTS): {:?}", res);
535
536 Ok(res.token)
537}
538
539#[derive(Debug, Error)]
540pub enum MinecraftAuthError {
541 #[error("Http error: {0}")]
542 Http(#[from] reqwest::Error),
543}
544
545async fn auth_with_minecraft(
546 client: &reqwest::Client,
547 user_hash: &str,
548 xsts_token: &str,
549) -> Result<ExpiringValue<MinecraftAuthResponse>, MinecraftAuthError> {
550 let res = client
551 .post("https://api.minecraftservices.com/authentication/login_with_xbox")
552 .header("Accept", "application/json")
553 .json(&json!({
554 "identityToken": format!("XBL3.0 x={user_hash};{xsts_token}")
555 }))
556 .send()
557 .await?
558 .json::<MinecraftAuthResponse>()
559 .await?;
560 trace!("{:?}", res);
561
562 let expires_at = SystemTime::now() + Duration::from_secs(res.expires_in);
563 Ok(ExpiringValue {
564 data: res,
565 expires_at: expires_at.duration_since(UNIX_EPOCH).unwrap().as_secs(),
567 })
568}
569
570#[derive(Debug, Error)]
571pub enum CheckOwnershipError {
572 #[error("Http error: {0}")]
573 Http(#[from] reqwest::Error),
574}
575
576pub async fn check_ownership(
577 client: &reqwest::Client,
578 minecraft_access_token: &str,
579) -> Result<bool, CheckOwnershipError> {
580 let res = client
581 .get("https://api.minecraftservices.com/entitlements/mcstore")
582 .header("Authorization", format!("Bearer {minecraft_access_token}"))
583 .send()
584 .await?
585 .json::<GameOwnershipResponse>()
586 .await?;
587 trace!("{:?}", res);
588
589 Ok(!res.items.is_empty())
593}
594
595#[derive(Debug, Error)]
596pub enum GetProfileError {
597 #[error("Http error: {0}")]
598 Http(#[from] reqwest::Error),
599}
600
601pub async fn get_profile(
602 client: &reqwest::Client,
603 minecraft_access_token: &str,
604) -> Result<ProfileResponse, GetProfileError> {
605 let res = client
606 .get("https://api.minecraftservices.com/minecraft/profile")
607 .header("Authorization", format!("Bearer {minecraft_access_token}"))
608 .send()
609 .await?
610 .json::<ProfileResponse>()
611 .await?;
612 trace!("{:?}", res);
613
614 Ok(res)
615}