azalea_auth/
auth.rs

1//! Handle Minecraft (Xbox) authentication.
2
3use std::{
4    collections::HashMap,
5    path::PathBuf,
6    time::{Instant, SystemTime, UNIX_EPOCH},
7};
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use serde_json::json;
12use thiserror::Error;
13use tracing::{error, trace};
14use uuid::Uuid;
15
16use crate::cache::{self, CachedAccount, ExpiringValue};
17
18#[derive(Default)]
19pub struct AuthOpts<'a> {
20    /// Whether we should check if the user actually owns the game. This will
21    /// fail if the user has Xbox Game Pass! Note that this isn't really
22    /// necessary, since getting the user profile will check this anyways.
23    pub check_ownership: bool,
24    // /// Whether we should get the Minecraft profile data (i.e. username, uuid,
25    // /// skin, etc) for the player.
26    // pub get_profile: bool,
27    /// The directory to store the cache in. If this is not set, caching is not
28    /// done.
29    pub cache_file: Option<PathBuf>,
30    /// If you choose to use your own Microsoft authentication instead of using
31    /// Nintendo Switch, just put your client_id here.
32    pub client_id: Option<&'a str>,
33    /// If you want to use custom scope instead of default one, just put your
34    /// scope here.
35    pub scope: Option<&'a str>,
36}
37
38#[derive(Debug, Error)]
39pub enum AuthError {
40    #[error(
41        "The Minecraft API is indicating that you don't own the game. \
42        If you're using Xbox Game Pass, set `check_ownership` to false in the auth options."
43    )]
44    DoesNotOwnGame,
45    #[error("Error getting Microsoft auth token: {0}")]
46    GetMicrosoftAuthToken(#[from] GetMicrosoftAuthTokenError),
47    #[error("Error refreshing Microsoft auth token: {0}")]
48    RefreshMicrosoftAuthToken(#[from] RefreshMicrosoftAuthTokenError),
49    #[error("Error getting Xbox Live auth token: {0}")]
50    GetXboxLiveAuthToken(#[from] MinecraftXstsAuthError),
51    #[error("Error getting Minecraft profile: {0}")]
52    GetMinecraftProfile(#[from] GetProfileError),
53    #[error("Error checking ownership: {0}")]
54    CheckOwnership(#[from] CheckOwnershipError),
55    #[error("Error getting Minecraft auth token: {0}")]
56    GetMinecraftAuthToken(#[from] MinecraftAuthError),
57    #[error("Error authenticating with Xbox Live: {0}")]
58    GetXboxLiveAuth(#[from] XboxLiveAuthError),
59}
60
61/// Authenticate with Microsoft. If the data isn't cached,
62/// they'll be asked to go to log into Microsoft in a web page.
63///
64/// The email is technically only used as a cache key, so it *could* be
65/// anything. You should just have it be the actual email so it's not confusing
66/// though, and in case the Microsoft API does start providing the real email.
67///
68/// If you want to use your own code to cache or show the auth code to the user
69/// in a different way, use [`get_ms_link_code`], [`get_ms_auth_token`],
70/// [`get_minecraft_token`] and [`get_profile`] instead.
71pub async fn auth(email: &str, opts: AuthOpts<'_>) -> Result<AuthResult, AuthError> {
72    let cached_account = if let Some(cache_file) = &opts.cache_file {
73        cache::get_account_in_cache(cache_file, email).await
74    } else {
75        None
76    };
77
78    if cached_account.is_some() && !cached_account.as_ref().unwrap().mca.is_expired() {
79        let account = cached_account.as_ref().unwrap();
80        // the minecraft auth data is cached and not expired, so we can just
81        // use that instead of doing auth all over again :)
82
83        Ok(AuthResult {
84            access_token: account.mca.data.access_token.clone(),
85            profile: account.profile.clone(),
86        })
87    } else {
88        let client_id = opts.client_id.unwrap_or(CLIENT_ID);
89        let scope = opts.scope.unwrap_or(SCOPE);
90
91        let client = reqwest::Client::new();
92        let mut msa = if let Some(account) = cached_account {
93            account.msa
94        } else {
95            interactive_get_ms_auth_token(&client, email, Some(client_id), Some(scope)).await?
96        };
97        if msa.is_expired() {
98            trace!("refreshing Microsoft auth token");
99            match refresh_ms_auth_token(
100                &client,
101                &msa.data.refresh_token,
102                opts.client_id,
103                opts.scope,
104            )
105            .await
106            {
107                Ok(new_msa) => msa = new_msa,
108                Err(e) => {
109                    // can't refresh, ask the user to auth again
110                    error!("Error refreshing Microsoft auth token: {}", e);
111                    msa =
112                        interactive_get_ms_auth_token(&client, email, Some(client_id), Some(scope))
113                            .await?;
114                }
115            }
116        }
117
118        let msa_token = &msa.data.access_token;
119        trace!("Got access token: {msa_token}");
120
121        let res = get_minecraft_token(&client, msa_token).await?;
122
123        if opts.check_ownership {
124            let has_game = check_ownership(&client, &res.minecraft_access_token).await?;
125            if !has_game {
126                return Err(AuthError::DoesNotOwnGame);
127            }
128        }
129
130        let profile: ProfileResponse = get_profile(&client, &res.minecraft_access_token).await?;
131
132        if let Some(cache_file) = opts.cache_file {
133            if let Err(e) = cache::set_account_in_cache(
134                &cache_file,
135                email,
136                CachedAccount {
137                    email: email.to_string(),
138                    mca: res.mca,
139                    msa,
140                    xbl: res.xbl,
141                    profile: profile.clone(),
142                },
143            )
144            .await
145            {
146                error!("{}", e);
147            }
148        }
149
150        Ok(AuthResult {
151            access_token: res.minecraft_access_token,
152            profile,
153        })
154    }
155}
156
157/// Authenticate with Minecraft when we already have a Microsoft auth token.
158///
159/// Usually you don't need this since [`auth`] will call it for you, but it's
160/// useful if you want more control over what it does.
161///
162/// If you don't have a Microsoft auth token, you can get it from
163/// [`get_ms_link_code`] and then [`get_ms_auth_token`].
164///
165/// If you got the MSA token from your own app (as opposed to the default
166/// Nintendo Switch one), you may have to prepend "d=" to the token.
167pub async fn get_minecraft_token(
168    client: &reqwest::Client,
169    msa: &str,
170) -> Result<MinecraftTokenResponse, AuthError> {
171    let xbl_auth = auth_with_xbox_live(client, msa).await?;
172
173    let xsts_token = obtain_xsts_for_minecraft(
174        client,
175        &xbl_auth
176            .get()
177            .expect("Xbox Live auth token shouldn't have expired yet")
178            .token,
179    )
180    .await?;
181
182    // Minecraft auth
183    let mca = auth_with_minecraft(client, &xbl_auth.data.user_hash, &xsts_token).await?;
184
185    let minecraft_access_token: String = mca
186        .get()
187        .expect("Minecraft auth shouldn't have expired yet")
188        .access_token
189        .to_string();
190
191    Ok(MinecraftTokenResponse {
192        mca,
193        xbl: xbl_auth,
194        minecraft_access_token,
195    })
196}
197
198#[derive(Debug)]
199pub struct MinecraftTokenResponse {
200    pub mca: ExpiringValue<MinecraftAuthResponse>,
201    pub xbl: ExpiringValue<XboxLiveAuth>,
202    pub minecraft_access_token: String,
203}
204
205#[derive(Debug)]
206pub struct AuthResult {
207    pub access_token: String,
208    pub profile: ProfileResponse,
209}
210
211#[derive(Debug, Deserialize)]
212pub struct DeviceCodeResponse {
213    pub user_code: String,
214    pub device_code: String,
215    pub verification_uri: String,
216    pub expires_in: u64,
217    pub interval: u64,
218}
219
220#[allow(unused)]
221#[derive(Debug, Deserialize, Serialize, Clone)]
222pub struct AccessTokenResponse {
223    pub token_type: String,
224    pub expires_in: u64,
225    pub scope: String,
226    pub access_token: String,
227    pub refresh_token: String,
228    pub user_id: String,
229}
230
231#[allow(unused)]
232#[derive(Debug, Deserialize)]
233#[serde(rename_all = "PascalCase")]
234pub struct XboxLiveAuthResponse {
235    pub issue_instant: String,
236    pub not_after: String,
237    pub token: String,
238    pub display_claims: HashMap<String, Vec<HashMap<String, String>>>,
239}
240
241/// Just the important data
242#[derive(Serialize, Deserialize, Debug)]
243pub struct XboxLiveAuth {
244    pub token: String,
245    pub user_hash: String,
246}
247
248#[allow(unused)]
249#[derive(Debug, Deserialize, Serialize)]
250pub struct MinecraftAuthResponse {
251    pub username: String,
252    pub roles: Vec<String>,
253    pub access_token: String,
254    pub token_type: String,
255    pub expires_in: u64,
256}
257
258#[derive(Debug, Deserialize)]
259pub struct GameOwnershipResponse {
260    pub items: Vec<GameOwnershipItem>,
261    pub signature: String,
262    #[serde(rename = "keyId")]
263    pub key_id: String,
264}
265
266#[allow(unused)]
267#[derive(Debug, Deserialize)]
268pub struct GameOwnershipItem {
269    pub name: String,
270    pub signature: String,
271}
272
273#[derive(Debug, Clone, Deserialize, Serialize)]
274pub struct ProfileResponse {
275    pub id: Uuid,
276    pub name: String,
277    pub skins: Vec<serde_json::Value>,
278    pub capes: Vec<serde_json::Value>,
279}
280
281// nintendo switch (so it works for accounts that are under 18 years old)
282const CLIENT_ID: &str = "00000000441cc96b";
283const SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
284
285#[derive(Debug, Error)]
286pub enum GetMicrosoftAuthTokenError {
287    #[error("Http error: {0}")]
288    Http(#[from] reqwest::Error),
289    #[error("Authentication timed out")]
290    Timeout,
291}
292
293/// Get the Microsoft link code that's shown to the user for logging into
294/// Microsoft.
295///
296/// You should call [`get_ms_auth_token`] right after showing the user the
297/// [`verification_uri`](DeviceCodeResponse::verification_uri) and
298/// [`user_code`](DeviceCodeResponse::user_code).
299///
300/// If showing the link code in the terminal is acceptable, then you can just
301/// use [`interactive_get_ms_auth_token`] instead.
302///
303/// ```
304/// # async fn example(client: &reqwest::Client) -> Result<(), Box<dyn std::error::Error>> {
305/// let res = azalea_auth::get_ms_link_code(&client, None, None).await?;
306/// println!(
307///     "Go to {} and enter the code {}",
308///     res.verification_uri, res.user_code
309/// );
310/// let msa = azalea_auth::get_ms_auth_token(client, res, None).await?;
311/// let minecraft = azalea_auth::get_minecraft_token(client, &msa.data.access_token).await?;
312/// let profile = azalea_auth::get_profile(&client, &minecraft.minecraft_access_token).await?;
313/// # Ok(())
314/// # }
315/// ```
316pub async fn get_ms_link_code(
317    client: &reqwest::Client,
318    client_id: Option<&str>,
319    scope: Option<&str>,
320) -> Result<DeviceCodeResponse, GetMicrosoftAuthTokenError> {
321    let client_id = if let Some(c) = client_id {
322        c
323    } else {
324        CLIENT_ID
325    };
326
327    let scope = if let Some(c) = scope { c } else { SCOPE };
328
329    Ok(client
330        .post("https://login.live.com/oauth20_connect.srf")
331        .form(&vec![
332            ("scope", scope),
333            ("client_id", client_id),
334            ("response_type", "device_code"),
335        ])
336        .send()
337        .await?
338        .json::<DeviceCodeResponse>()
339        .await?)
340}
341
342/// Wait until the user logged into Microsoft with the given code. You get the
343/// device code response needed for this function from [`get_ms_link_code`].
344///
345/// You should pass the response from this to [`get_minecraft_token`].
346pub async fn get_ms_auth_token(
347    client: &reqwest::Client,
348    res: DeviceCodeResponse,
349    client_id: Option<&str>,
350) -> Result<ExpiringValue<AccessTokenResponse>, GetMicrosoftAuthTokenError> {
351    let client_id = if let Some(c) = client_id {
352        c
353    } else {
354        CLIENT_ID
355    };
356
357    let login_expires_at = Instant::now() + std::time::Duration::from_secs(res.expires_in);
358
359    while Instant::now() < login_expires_at {
360        tokio::time::sleep(std::time::Duration::from_secs(res.interval)).await;
361
362        trace!("Polling to check if user has logged in...");
363        if let Ok(access_token_response) = client
364            .post(format!(
365                "https://login.live.com/oauth20_token.srf?client_id={client_id}"
366            ))
367            .form(&vec![
368                ("client_id", client_id),
369                ("device_code", &res.device_code),
370                ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
371            ])
372            .send()
373            .await?
374            .json::<AccessTokenResponse>()
375            .await
376        {
377            trace!("access_token_response: {:?}", access_token_response);
378            let expires_at = SystemTime::now()
379                + std::time::Duration::from_secs(access_token_response.expires_in);
380            return Ok(ExpiringValue {
381                data: access_token_response,
382                expires_at: expires_at
383                    .duration_since(UNIX_EPOCH)
384                    .expect("Time went backwards")
385                    .as_secs(),
386            });
387        }
388    }
389
390    Err(GetMicrosoftAuthTokenError::Timeout)
391}
392
393/// Asks the user to go to a webpage and log in with Microsoft. If you need to
394/// access the code, then use [`get_ms_link_code`] and then
395/// [`get_ms_auth_token`] instead.
396pub async fn interactive_get_ms_auth_token(
397    client: &reqwest::Client,
398    email: &str,
399    client_id: Option<&str>,
400    scope: Option<&str>,
401) -> Result<ExpiringValue<AccessTokenResponse>, GetMicrosoftAuthTokenError> {
402    let res = get_ms_link_code(client, client_id, scope).await?;
403    trace!("Device code response: {:?}", res);
404    println!(
405        "Go to \x1b[1m{}\x1b[m and enter the code \x1b[1m{}\x1b[m for \x1b[1m{}\x1b[m",
406        res.verification_uri, res.user_code, email
407    );
408
409    get_ms_auth_token(client, res, client_id).await
410}
411
412#[derive(Debug, Error)]
413pub enum RefreshMicrosoftAuthTokenError {
414    #[error("Http error: {0}")]
415    Http(#[from] reqwest::Error),
416    #[error("Error parsing JSON: {0}")]
417    Json(#[from] serde_json::Error),
418}
419
420pub async fn refresh_ms_auth_token(
421    client: &reqwest::Client,
422    refresh_token: &str,
423    client_id: Option<&str>,
424    scope: Option<&str>,
425) -> Result<ExpiringValue<AccessTokenResponse>, RefreshMicrosoftAuthTokenError> {
426    let client_id = client_id.unwrap_or(CLIENT_ID);
427    let scope = scope.unwrap_or(SCOPE);
428
429    let access_token_response_text = client
430        .post("https://login.live.com/oauth20_token.srf")
431        .form(&vec![
432            ("scope", scope),
433            ("client_id", client_id),
434            ("grant_type", "refresh_token"),
435            ("refresh_token", refresh_token),
436        ])
437        .send()
438        .await?
439        .text()
440        .await?;
441    let access_token_response: AccessTokenResponse =
442        serde_json::from_str(&access_token_response_text)?;
443
444    let expires_at =
445        SystemTime::now() + std::time::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() + std::time::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}