azalea_auth/
auth.rs

1//! Handle Minecraft (Xbox) authentication.
2
3use 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    /// Whether we should check if the user actually owns the game. This will
22    /// fail if the user has Xbox Game Pass! Note that this isn't really
23    /// necessary, since getting the user profile will check this anyways.
24    pub check_ownership: bool,
25    // /// Whether we should get the Minecraft profile data (i.e. username, uuid,
26    // /// skin, etc) for the player.
27    // pub get_profile: bool,
28    /// The directory to store the cache in. If this is not set, caching is not
29    /// done.
30    pub cache_file: Option<PathBuf>,
31    /// If you choose to use your own Microsoft authentication instead of using
32    /// Nintendo Switch, just put your client_id here.
33    pub client_id: Option<&'a str>,
34    /// If you want to use custom scope instead of default one, just put your
35    /// scope here.
36    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
62/// Authenticate with Microsoft. If the data isn't cached,
63/// they'll be asked to go to log into Microsoft in a web page.
64///
65/// The cache key is an arbitrary string that's used to identify the account in
66/// the future. As a convention, we usually prefer to put the account email
67/// here.
68///
69/// If you want to use your own code to cache or show the auth code to the user
70/// in a different way, use [`get_ms_link_code`], [`get_ms_auth_token`],
71/// [`get_minecraft_token`] and [`get_profile`] instead.
72pub 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        // the minecraft auth data is cached and not expired, so we can just
83        // use that instead of doing auth all over again :)
84
85        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                    // can't refresh, ask the user to auth again
112                    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
162/// Authenticate with Minecraft when we already have a Microsoft auth token.
163///
164/// Usually you don't need this since [`auth`] will call it for you, but it's
165/// useful if you want more control over what it does.
166///
167/// If you don't have a Microsoft auth token, you can get it from
168/// [`get_ms_link_code`] and then [`get_ms_auth_token`].
169///
170/// If you got the MSA token from your own app (as opposed to the default
171/// Nintendo Switch one), you may have to prepend "d=" to the token.
172pub 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    // Minecraft auth
188    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/// Just the important data
247#[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
286// nintendo switch (so it works for accounts that are under 18 years old)
287const 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
298/// Get the Microsoft link code that's shown to the user for logging into
299/// Microsoft.
300///
301/// You should call [`get_ms_auth_token`] right after showing the user the
302/// [`verification_uri`](DeviceCodeResponse::verification_uri) and
303/// [`user_code`](DeviceCodeResponse::user_code).
304///
305/// If showing the link code in the terminal is acceptable, then you can just
306/// use [`interactive_get_ms_auth_token`] instead.
307///
308/// ```
309/// # async fn example(client: &reqwest::Client) -> Result<(), Box<dyn std::error::Error>> {
310/// let res = azalea_auth::get_ms_link_code(&client, None, None).await?;
311/// println!(
312///     "Go to {} and enter the code {}",
313///     res.verification_uri, res.user_code
314/// );
315/// let msa = azalea_auth::get_ms_auth_token(client, res, None).await?;
316/// let minecraft = azalea_auth::get_minecraft_token(client, &msa.data.access_token).await?;
317/// let profile = azalea_auth::get_profile(&client, &minecraft.minecraft_access_token).await?;
318/// # Ok(())
319/// # }
320/// ```
321pub 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
347/// Wait until the user logged into Microsoft with the given code. You get the
348/// device code response needed for this function from [`get_ms_link_code`].
349///
350/// You should pass the response from this to [`get_minecraft_token`].
351pub 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
398/// Asks the user to go to a webpage and log in with Microsoft.
399///
400/// If you need to access the code, then use [`get_ms_link_code`] and then
401/// [`get_ms_auth_token`] instead.
402pub 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            // this value should have "d=" prepended if you're using your own app (as opposed to
478            // the default nintendo switch one)
479            "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        // .header("Cache-Control", "no-store, must-revalidate, no-cache")
492        // .header("Signature", base64::encode(signature))
493        .body(payload)
494        .send()
495        .await?
496        .json::<XboxLiveAuthResponse>()
497        .await?;
498    trace!("Xbox Live auth response: {:?}", res);
499
500    // not_after looks like 2020-12-21T19:52:08.4463796Z
501    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        // to seconds since epoch
571        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    // vanilla checks here to make sure the signatures are right, but it's not
595    // actually required so we just don't
596
597    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}