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.
22    ///
23    /// This will fail if the user has Xbox Game Pass. Note that this isn't
24    /// really necessary, since getting the user profile will check this
25    /// anyways.
26    pub check_ownership: bool,
27    /// The directory to store the cache in.
28    ///
29    /// If this is `None`, azalea-auth will not keep its own cache.
30    pub cache_file: Option<PathBuf>,
31    /// An override for the Microsoft Client ID to authenticate with.
32    ///
33    /// The default client ID is for Nintendo Switch, but you can replace this
34    /// if you'd like authentication to have your own branding.
35    ///
36    /// For more information about this, see <https://minecraft.wiki/w/Microsoft_authentication#Microsoft_OAuth2_flow>.
37    pub client_id: Option<&'a str>,
38    /// An override for the OAuth2 scope to authenticate with.
39    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
65/// Authenticate with Microsoft. If the data isn't cached, the user will be
66/// asked to go to log into Microsoft in a web page.
67///
68/// The cache key is an arbitrary string that's used to identify the account in
69/// the future. The account email is often used for this.
70///
71/// If you want to use your own code to cache or show the auth code to the user
72/// in a different way, use [`get_ms_link_code`], [`get_ms_auth_token`],
73/// [`get_minecraft_token`] and [`get_profile`] instead.
74pub 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        // the minecraft auth data is cached and not expired, so we can just
85        // use that instead of doing auth all over again :)
86
87        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                    // can't refresh, ask the user to auth again
114                    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
164/// Authenticate with Minecraft when we already have a Microsoft auth token.
165///
166/// Usually you don't need this since [`auth`] will call it for you, but it's
167/// useful if you want more control over what it does.
168///
169/// If you don't have a Microsoft auth token, you can get it from
170/// [`get_ms_link_code`] and then [`get_ms_auth_token`].
171///
172/// If you got the MSA token from your own app (as opposed to the default
173/// Nintendo Switch one), you may have to prepend "d=" to the token.
174pub 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    // Minecraft auth
190    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/// Just the important data
249#[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
288// nintendo switch (so it works for accounts that are under 18 years old)
289const 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
300/// Get the Microsoft link code that's shown to the user for logging into
301/// Microsoft.
302///
303/// You should call [`get_ms_auth_token`] right after showing the user the
304/// [`verification_uri`](DeviceCodeResponse::verification_uri) and
305/// [`user_code`](DeviceCodeResponse::user_code).
306///
307/// If showing the link code in the terminal is acceptable, then you can just
308/// use [`interactive_get_ms_auth_token`] instead.
309///
310/// ```
311/// # async fn example(client: &reqwest::Client) -> Result<(), Box<dyn std::error::Error>> {
312/// let res = azalea_auth::get_ms_link_code(&client, None, None).await?;
313/// println!(
314///     "Go to {} and enter the code {}",
315///     res.verification_uri, res.user_code
316/// );
317/// let msa = azalea_auth::get_ms_auth_token(client, res, None).await?;
318/// let minecraft = azalea_auth::get_minecraft_token(client, &msa.data.access_token).await?;
319/// let profile = azalea_auth::get_profile(&client, &minecraft.minecraft_access_token).await?;
320/// # Ok(())
321/// # }
322/// ```
323pub 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
349/// Wait until the user logged into Microsoft with the given code.
350///
351/// You get the device code response needed for this function from
352/// [`get_ms_link_code`].
353///
354/// You should pass the response from this to [`get_minecraft_token`].
355pub 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
402/// Asks the user to go to a webpage and log in with Microsoft.
403///
404/// If you need to access the code, then use [`get_ms_link_code`] and then
405/// [`get_ms_auth_token`] instead.
406pub 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            // this value should have "d=" prepended if you're using your own app (as opposed to
482            // the default nintendo switch one)
483            "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        // .header("Cache-Control", "no-store, must-revalidate, no-cache")
496        // .header("Signature", base64::encode(signature))
497        .body(payload)
498        .send()
499        .await?
500        .json::<XboxLiveAuthResponse>()
501        .await?;
502    trace!("Xbox Live auth response: {:?}", res);
503
504    // not_after looks like 2020-12-21T19:52:08.4463796Z
505    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        // to seconds since epoch
575        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    // vanilla checks here to make sure the signatures are right, but it's not
599    // actually required so we just don't
600
601    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}