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    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            // this value should have "d=" prepended if you're using your own app (as opposed to
473            // the default nintendo switch one)
474            "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        // .header("Cache-Control", "no-store, must-revalidate, no-cache")
487        // .header("Signature", base64::encode(signature))
488        .body(payload)
489        .send()
490        .await?
491        .json::<XboxLiveAuthResponse>()
492        .await?;
493    trace!("Xbox Live auth response: {:?}", res);
494
495    // not_after looks like 2020-12-21T19:52:08.4463796Z
496    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        // to seconds since epoch
566        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    // vanilla checks here to make sure the signatures are right, but it's not
590    // actually required so we just don't
591
592    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}