Skip to main content

minecraft_msa_auth/
lib.rs

1//! This crate allows you to authenticate into Minecraft online services using a
2//! Microsoft Oauth2 token. You can integrate it with [oauth2-rs](https://github.com/ramosbugs/oauth2-rs)
3//! and build interactive authentication flows.
4//!
5//! By default the flow is asynchronous and uses [reqwest](https://crates.io/crates/reqwest)
6//! as the HTTP client, but any other client can be plugged in by implementing
7//! the [HttpClient] trait and disabling the default `reqwest` feature.
8//!
9//! Enabling the `is_sync` feature turns the whole API synchronous: the
10//! [HttpClient] trait and the flow methods lose their `async`, and the
11//! `reqwest` implementation switches to [reqwest::blocking::Client]. This is
12//! aimed at small launchers that do not want an async runtime, ideally
13//! combined with the [ureq](https://crates.io/crates/ureq)-backed [UreqClient]
14//! available behind the `ureq` feature.
15//!
16//! The example below assumes the default asynchronous mode.
17//!
18//! # Example
19//!
20//! ```no_run
21//! # #[cfg(not(feature = "is_sync"))]
22//! # {
23//! # use minecraft_msa_auth::MinecraftAuthorizationFlow;
24//! # use oauth2::basic::BasicClient;
25//! # use oauth2::{
26//! #     AuthUrl, ClientId, DeviceAuthorizationUrl, Scope, StandardDeviceAuthorizationResponse, TokenResponse,
27//! #     TokenUrl,
28//! # };
29//! # use reqwest::Client;
30//! #
31//! # const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode";
32//! # const MSA_AUTHORIZE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize";
33//! # const MSA_TOKEN_URL: &str = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
34//! #
35//! # #[tokio::main]
36//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
37//! # let client_id = std::env::args().nth(1).expect("client_id as first argument");
38//! let client = BasicClient::new(ClientId::new(client_id))
39//!     .set_auth_uri(AuthUrl::new(MSA_AUTHORIZE_URL.to_string())?)
40//!     .set_token_uri(TokenUrl::new(MSA_TOKEN_URL.to_string())?)
41//!     .set_device_authorization_url(DeviceAuthorizationUrl::new(DEVICE_CODE_URL.to_string())?);
42//!
43//! // oauth2 bundles its own reqwest version, which may differ from the one
44//! // minecraft-msa-auth is built against.
45//! let oauth_http_client = oauth2::reqwest::Client::new();
46//! let details: StandardDeviceAuthorizationResponse = client
47//!     .exchange_device_code()
48//!     .add_scope(Scope::new("XboxLive.signin offline_access".to_string()))
49//!     .request_async(&oauth_http_client)
50//!     .await?;
51//!
52//! println!(
53//!     "Open this URL in your browser:\n{}\nand enter the code: {}",
54//!     details.verification_uri().to_string(),
55//!     details.user_code().secret().to_string()
56//! );
57//!
58//! let token = client
59//!     .exchange_device_access_token(&details)
60//!     .request_async(&oauth_http_client, tokio::time::sleep, None)
61//!     .await?;
62//! println!("microsoft token: {:?}", token);
63//!
64//! let mc_flow = MinecraftAuthorizationFlow::new(Client::new());
65//! let mc_token = mc_flow.exchange_microsoft_token(token.access_token().secret()).await?;
66//! println!("minecraft token: {:?}", mc_token);
67//! # Ok(())
68//! # }
69//! # }
70//! ```
71use std::collections::HashMap;
72use std::fmt::Debug;
73
74use getset::{CopyGetters, Getters};
75use http::header::{ACCEPT, CONTENT_TYPE};
76use http::{Method, Request, StatusCode};
77use nutype::nutype;
78use serde::{Deserialize, Serialize};
79use thiserror::Error;
80
81const MINECRAFT_LOGIN_WITH_XBOX: &str = "https://api.minecraftservices.com/authentication/login_with_xbox";
82const XBOX_USER_AUTHENTICATE: &str = "https://user.auth.xboxlive.com/user/authenticate";
83const XBOX_XSTS_AUTHORIZE: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
84
85/// An HTTP request executed by an [HttpClient].
86pub type HttpRequest = Request<Vec<u8>>;
87
88/// An HTTP response returned by an [HttpClient].
89pub type HttpResponse = http::Response<Vec<u8>>;
90
91/// An HTTP client capable of executing the requests made by
92/// [MinecraftAuthorizationFlow].
93///
94/// The trait is asynchronous by default and becomes synchronous when the
95/// `is_sync` feature is enabled. Implementations should be annotated with
96/// [macro@maybe_async::maybe_async] to support both modes.
97///
98/// An implementation for [reqwest::Client] ([reqwest::blocking::Client] with
99/// `is_sync`) is provided behind the `reqwest` feature, which is enabled by
100/// default.
101#[maybe_async::maybe_async]
102pub trait HttpClient {
103    /// The error type returned when a request fails at the transport level.
104    type Error: std::error::Error + Send + Sync + 'static;
105
106    /// Executes the given request, returning the response with its full body.
107    async fn call(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error>;
108}
109
110/// Represents a Minecraft access token
111#[nutype(
112    validate(not_empty),
113    derive(Clone, PartialEq, Eq, Hash, Deserialize, Serialize, AsRef, Into)
114)]
115pub struct MinecraftAccessToken(String);
116
117impl Debug for MinecraftAccessToken {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.debug_tuple("MinecraftAccessToken").field(&"[redacted]").finish()
120    }
121}
122
123/// Represents the token type of a Minecraft access token
124#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
125#[serde(rename_all = "PascalCase")]
126pub enum MinecraftTokenType {
127    Bearer,
128}
129
130/// Represents an error that can occur when authenticating with Minecraft.
131#[derive(Error, Debug)]
132pub enum MinecraftAuthorizationError<E: std::error::Error> {
133    /// An error occurred while executing the HTTP request
134    #[error(transparent)]
135    Http(E),
136
137    /// The server responded with a non-success status code
138    #[error("HTTP status error: {0}")]
139    HttpStatus(StatusCode),
140
141    /// An error occurred while serializing or deserializing JSON
142    #[error(transparent)]
143    Json(#[from] serde_json::Error),
144
145    /// Account belongs to a minor who needs to be added to a microsoft family
146    #[error("Minor must be added to microsoft family")]
147    AddToFamily,
148
149    /// Account does not have xbox, user must create an xbox account to continue
150    #[error("Account does not have xbox")]
151    NoXbox,
152
153    /// Claims were missing from the response
154    #[error("missing claims from response")]
155    MissingClaims,
156
157    /// Xbox Live rejected authentication with an unrecognized error code
158    #[error("Xbox Live authentication failed with error code {code}")]
159    XboxLive { code: u32 },
160}
161
162/// The response from Minecraft when attempting to authenticate with an xbox
163/// token
164#[derive(Deserialize, Serialize, Debug, Getters, CopyGetters, Clone)]
165pub struct MinecraftAuthenticationResponse {
166    /// UUID of the Xbox account.
167    /// Please note that this is not the Minecraft player's UUID
168    #[getset(get = "pub")]
169    username: String,
170
171    /// The minecraft JWT access token
172    #[getset(get = "pub")]
173    access_token: MinecraftAccessToken,
174
175    /// The type of access token
176    #[getset(get = "pub")]
177    token_type: MinecraftTokenType,
178
179    /// How many seconds until the token expires
180    #[getset(get_copy = "pub")]
181    expires_in: u32,
182}
183
184/// The response from Xbox when authenticating with a Microsoft token
185#[derive(Deserialize, Debug)]
186#[serde(rename_all = "PascalCase")]
187struct XboxLiveAuthenticationResponse {
188    /// The xbox authentication token to use
189    token: String,
190
191    /// An object that contains a vec of `uhs` objects
192    /// Looks like { "xui": [{"uhs": "xbl_token"}] }
193    display_claims: HashMap<String, Vec<HashMap<String, String>>>,
194}
195
196/// The error response from Xbox when authenticating with a Microsoft token.
197#[derive(Debug, Deserialize)]
198#[serde(rename_all = "PascalCase")]
199struct XboxLiveErrorResponse {
200    #[serde(rename = "XErr")]
201    code: XboxLiveErrorCode,
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
205#[serde(from = "u32")]
206enum XboxLiveErrorCode {
207    XboxAccountRequired,
208    FamilyMembershipRequired,
209    Unknown(u32),
210}
211
212impl From<u32> for XboxLiveErrorCode {
213    fn from(code: u32) -> Self {
214        match code {
215            2_148_916_233 => Self::XboxAccountRequired,
216            2_148_916_238 => Self::FamilyMembershipRequired,
217            code => Self::Unknown(code),
218        }
219    }
220}
221
222/// The flow for authenticating with a Microsoft access token and getting a
223/// Minecraft access token.
224pub struct MinecraftAuthorizationFlow<C> {
225    http_client: C,
226}
227
228impl<C> MinecraftAuthorizationFlow<C> {
229    /// Creates a new [MinecraftAuthorizationFlow] using the given
230    /// [HttpClient].
231    pub const fn new(http_client: C) -> Self {
232        Self { http_client }
233    }
234}
235
236#[maybe_async::maybe_async]
237impl<C: HttpClient> MinecraftAuthorizationFlow<C> {
238    /// Authenticates with the Microsoft identity platform using the given
239    /// Microsoft access token and returns a [MinecraftAuthenticationResponse]
240    /// that contains the Minecraft access token.
241    pub async fn exchange_microsoft_token(
242        &self, microsoft_access_token: impl AsRef<str>,
243    ) -> Result<MinecraftAuthenticationResponse, MinecraftAuthorizationError<C::Error>> {
244        #[derive(Serialize)]
245        struct MinecraftAuthenticationRequest {
246            #[serde(rename = "identityToken")]
247            identity_token: String,
248        }
249
250        let (xbox_token, user_hash) = self.xbox_token(microsoft_access_token).await?;
251        let xbox_security_token = self.xbox_security_token(xbox_token).await?;
252
253        let response = self
254            .post_json(MINECRAFT_LOGIN_WITH_XBOX, &MinecraftAuthenticationRequest {
255                identity_token: format!(
256                    "XBL3.0 x={user_hash};{xsts_token}",
257                    user_hash = user_hash,
258                    xsts_token = xbox_security_token.token
259                ),
260            })
261            .await?;
262        if !response.status().is_success() {
263            return Err(MinecraftAuthorizationError::HttpStatus(response.status()));
264        }
265
266        let response = serde_json::from_slice(response.body())?;
267        Ok(response)
268    }
269
270    async fn xbox_security_token(
271        &self, xbox_token: String,
272    ) -> Result<XboxLiveAuthenticationResponse, MinecraftAuthorizationError<C::Error>> {
273        #[derive(Serialize)]
274        struct Properties {
275            #[serde(rename = "SandboxId")]
276            sandbox_id: &'static str,
277            #[serde(rename = "UserTokens")]
278            user_tokens: [String; 1],
279        }
280
281        #[derive(Serialize)]
282        struct XboxSecurityTokenRequest {
283            #[serde(rename = "Properties")]
284            properties: Properties,
285            #[serde(rename = "RelyingParty")]
286            relying_party: &'static str,
287            #[serde(rename = "TokenType")]
288            token_type: &'static str,
289        }
290
291        let response = self
292            .post_json(XBOX_XSTS_AUTHORIZE, &XboxSecurityTokenRequest {
293                properties: Properties {
294                    sandbox_id: "RETAIL",
295                    user_tokens: [xbox_token],
296                },
297                relying_party: "rp://api.minecraftservices.com/",
298                token_type: "JWT",
299            })
300            .await?;
301        if response.status() == StatusCode::UNAUTHORIZED {
302            let error: XboxLiveErrorResponse = serde_json::from_slice(response.body())?;
303            Err(match error.code {
304                XboxLiveErrorCode::XboxAccountRequired => MinecraftAuthorizationError::NoXbox,
305                XboxLiveErrorCode::FamilyMembershipRequired => MinecraftAuthorizationError::AddToFamily,
306                XboxLiveErrorCode::Unknown(code) => MinecraftAuthorizationError::XboxLive { code },
307            })
308        } else if !response.status().is_success() {
309            Err(MinecraftAuthorizationError::HttpStatus(response.status()))
310        } else {
311            let xbox_security_token_resp: XboxLiveAuthenticationResponse = serde_json::from_slice(response.body())?;
312            Ok(xbox_security_token_resp)
313        }
314    }
315
316    async fn xbox_token(
317        &self, microsoft_access_token: impl AsRef<str>,
318    ) -> Result<(String, String), MinecraftAuthorizationError<C::Error>> {
319        #[derive(Serialize)]
320        struct Properties {
321            #[serde(rename = "AuthMethod")]
322            auth_method: &'static str,
323            #[serde(rename = "SiteName")]
324            site_name: &'static str,
325            #[serde(rename = "RpsTicket")]
326            rps_ticket: String,
327        }
328
329        #[derive(Serialize)]
330        struct XboxTokenRequest {
331            #[serde(rename = "Properties")]
332            properties: Properties,
333            #[serde(rename = "RelyingParty")]
334            relying_party: &'static str,
335            #[serde(rename = "TokenType")]
336            token_type: &'static str,
337        }
338
339        let response = self
340            .post_json(XBOX_USER_AUTHENTICATE, &XboxTokenRequest {
341                properties: Properties {
342                    auth_method: "RPS",
343                    site_name: "user.auth.xboxlive.com",
344                    rps_ticket: format!("d={}", microsoft_access_token.as_ref()),
345                },
346                relying_party: "http://auth.xboxlive.com",
347                token_type: "JWT",
348            })
349            .await?;
350        if !response.status().is_success() {
351            return Err(MinecraftAuthorizationError::HttpStatus(response.status()));
352        }
353
354        let xbox_resp: XboxLiveAuthenticationResponse = serde_json::from_slice(response.body())?;
355        let xbox_token = xbox_resp.token;
356        let user_hash = xbox_resp
357            .display_claims
358            .get("xui")
359            .ok_or(MinecraftAuthorizationError::MissingClaims)?
360            .first()
361            .ok_or(MinecraftAuthorizationError::MissingClaims)?
362            .get("uhs")
363            .ok_or(MinecraftAuthorizationError::MissingClaims)?
364            .to_owned();
365        Ok((xbox_token, user_hash))
366    }
367
368    async fn post_json<T: Serialize>(
369        &self, url: &str, body: &T,
370    ) -> Result<HttpResponse, MinecraftAuthorizationError<C::Error>> {
371        let request = Request::builder()
372            .method(Method::POST)
373            .uri(url)
374            .header(CONTENT_TYPE, "application/json")
375            .header(ACCEPT, "application/json")
376            .body(serde_json::to_vec(body)?)
377            .expect("static request parts should be valid");
378        self.http_client
379            .call(request)
380            .await
381            .map_err(MinecraftAuthorizationError::Http)
382    }
383}
384
385#[cfg(feature = "reqwest")]
386mod reqwest_client {
387    use super::{HttpClient, HttpRequest, HttpResponse};
388
389    #[maybe_async::async_impl]
390    impl HttpClient for reqwest::Client {
391        type Error = reqwest::Error;
392
393        async fn call(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error> {
394            let response = self.execute(reqwest::Request::try_from(request)?).await?;
395            let status = response.status();
396            let headers = response.headers().clone();
397            let body = response.bytes().await?.to_vec();
398
399            let mut response = HttpResponse::new(body);
400            *response.status_mut() = status;
401            *response.headers_mut() = headers;
402            Ok(response)
403        }
404    }
405
406    #[maybe_async::sync_impl]
407    impl HttpClient for reqwest::blocking::Client {
408        type Error = reqwest::Error;
409
410        fn call(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error> {
411            let response = self.execute(reqwest::blocking::Request::try_from(request)?)?;
412            let status = response.status();
413            let headers = response.headers().clone();
414            let body = response.bytes()?.to_vec();
415
416            let mut response = HttpResponse::new(body);
417            *response.status_mut() = status;
418            *response.headers_mut() = headers;
419            Ok(response)
420        }
421    }
422}
423
424#[cfg(feature = "ureq")]
425pub use ureq_client::UreqClient;
426
427#[cfg(feature = "ureq")]
428mod ureq_client {
429    use super::{HttpClient, HttpRequest, HttpResponse};
430
431    /// An [HttpClient] backed by [ureq](https://crates.io/crates/ureq).
432    ///
433    /// Requests are always executed synchronously, so this client is intended
434    /// for use with the `is_sync` feature, where the whole flow becomes
435    /// blocking and no async runtime is needed:
436    ///
437    /// ```ignore
438    /// // with features = ["ureq", "is_sync"]
439    /// let mc_flow = MinecraftAuthorizationFlow::new(UreqClient::default());
440    /// let mc_token = mc_flow.exchange_microsoft_token("msa token")?;
441    /// ```
442    ///
443    /// Without `is_sync` the client still works, but it will block the
444    /// executor thread while a request is in flight.
445    #[derive(Debug, Clone)]
446    pub struct UreqClient(ureq::Agent);
447
448    impl UreqClient {
449        /// Creates a [UreqClient] from an existing [ureq::Agent].
450        ///
451        /// The agent must be configured with
452        /// [http_status_as_error(false)](ureq::config::ConfigBuilder::http_status_as_error),
453        /// otherwise error responses from the Xbox services cannot be
454        /// inspected and specific errors like
455        /// [MinecraftAuthorizationError::AddToFamily](super::MinecraftAuthorizationError::AddToFamily)
456        /// cannot be reported.
457        pub fn with_agent(agent: ureq::Agent) -> Self {
458            Self(agent)
459        }
460    }
461
462    impl Default for UreqClient {
463        fn default() -> Self {
464            Self(ureq::Agent::config_builder().http_status_as_error(false).build().into())
465        }
466    }
467
468    #[maybe_async::maybe_async]
469    impl HttpClient for UreqClient {
470        type Error = ureq::Error;
471
472        async fn call(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error> {
473            let response = self.0.run(request)?;
474            let (parts, mut body) = response.into_parts();
475            Ok(HttpResponse::from_parts(parts, body.read_to_vec()?))
476        }
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::{XboxLiveErrorCode, XboxLiveErrorResponse};
483
484    #[test]
485    fn deserializes_known_xbox_live_error_codes() {
486        let no_xbox: XboxLiveErrorResponse =
487            serde_json::from_str(r#"{"Identity":"0","XErr":2148916233,"Message":"","Redirect":""}"#).unwrap();
488        let add_to_family: XboxLiveErrorResponse =
489            serde_json::from_str(r#"{"Identity":"0","XErr":2148916238,"Message":"","Redirect":""}"#).unwrap();
490
491        assert_eq!(no_xbox.code, XboxLiveErrorCode::XboxAccountRequired);
492        assert_eq!(add_to_family.code, XboxLiveErrorCode::FamilyMembershipRequired);
493    }
494
495    #[test]
496    fn preserves_unknown_xbox_live_error_codes() {
497        let response: XboxLiveErrorResponse = serde_json::from_str(r#"{"XErr":42}"#).unwrap();
498
499        assert_eq!(response.code, XboxLiveErrorCode::Unknown(42));
500    }
501
502    #[test]
503    fn rejects_malformed_xbox_live_error_responses() {
504        let response = serde_json::from_str::<XboxLiveErrorResponse>(r#"{"XErr":"invalid"}"#);
505
506        assert!(response.is_err());
507    }
508}