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 email is technically only used as a cache key, so it *could* be
66/// anything. You should just have it be the actual email so it's not confusing
67/// though, and in case the Microsoft API does start providing the real email.
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(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        // 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, 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                    // can't refresh, ask the user to auth again
112                    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
158/// Authenticate with Minecraft when we already have a Microsoft auth token.
159///
160/// Usually you don't need this since [`auth`] will call it for you, but it's
161/// useful if you want more control over what it does.
162///
163/// If you don't have a Microsoft auth token, you can get it from
164/// [`get_ms_link_code`] and then [`get_ms_auth_token`].
165///
166/// If you got the MSA token from your own app (as opposed to the default
167/// Nintendo Switch one), you may have to prepend "d=" to the token.
168pub 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    // Minecraft auth
184    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/// Just the important data
243#[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
282// nintendo switch (so it works for accounts that are under 18 years old)
283const 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
294/// Get the Microsoft link code that's shown to the user for logging into
295/// Microsoft.
296///
297/// You should call [`get_ms_auth_token`] right after showing the user the
298/// [`verification_uri`](DeviceCodeResponse::verification_uri) and
299/// [`user_code`](DeviceCodeResponse::user_code).
300///
301/// If showing the link code in the terminal is acceptable, then you can just
302/// use [`interactive_get_ms_auth_token`] instead.
303///
304/// ```
305/// # async fn example(client: &reqwest::Client) -> Result<(), Box<dyn std::error::Error>> {
306/// let res = azalea_auth::get_ms_link_code(&client, None, None).await?;
307/// println!(
308///     "Go to {} and enter the code {}",
309///     res.verification_uri, res.user_code
310/// );
311/// let msa = azalea_auth::get_ms_auth_token(client, res, None).await?;
312/// let minecraft = azalea_auth::get_minecraft_token(client, &msa.data.access_token).await?;
313/// let profile = azalea_auth::get_profile(&client, &minecraft.minecraft_access_token).await?;
314/// # Ok(())
315/// # }
316/// ```
317pub 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
343/// Wait until the user logged into Microsoft with the given code. You get the
344/// device code response needed for this function from [`get_ms_link_code`].
345///
346/// You should pass the response from this to [`get_minecraft_token`].
347pub 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
394/// Asks the user to go to a webpage and log in with Microsoft. If you need to
395/// access the code, then use [`get_ms_link_code`] and then
396/// [`get_ms_auth_token`] instead.
397pub 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            // this value should have "d=" prepended if you're using your own app (as opposed to
472            // the default nintendo switch one)
473            "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        // .header("Cache-Control", "no-store, must-revalidate, no-cache")
486        // .header("Signature", base64::encode(signature))
487        .body(payload)
488        .send()
489        .await?
490        .json::<XboxLiveAuthResponse>()
491        .await?;
492    trace!("Xbox Live auth response: {:?}", res);
493
494    // not_after looks like 2020-12-21T19:52:08.4463796Z
495    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        // to seconds since epoch
565        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    // vanilla checks here to make sure the signatures are right, but it's not
589    // actually required so we just don't
590
591    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}