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//! # Example
6//!
7//! ```no_run
8//! # use minecraft_msa_auth::MinecraftAuthorizationFlow;
9//! # use oauth2::basic::BasicClient;
10//! # use oauth2::devicecode::StandardDeviceAuthorizationResponse;
11//! # use oauth2::reqwest::async_http_client;
12//! # use oauth2::{AuthUrl, ClientId, DeviceAuthorizationUrl, Scope, TokenResponse, TokenUrl};
13//! # use reqwest::Client;
14//! #
15//! # const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode";
16//! # const MSA_AUTHORIZE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize";
17//! # const MSA_TOKEN_URL: &str = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
18//! #
19//! # #[tokio::main]
20//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
21//! # let client_id = std::env::args().nth(1).expect("client_id as first argument");
22//! let client = BasicClient::new(
23//!     ClientId::new(client_id),
24//!     None,
25//!     AuthUrl::new(MSA_AUTHORIZE_URL.to_string())?,
26//!     Some(TokenUrl::new(MSA_TOKEN_URL.to_string())?),
27//! )
28//! .set_device_authorization_url(DeviceAuthorizationUrl::new(DEVICE_CODE_URL.to_string())?);
29//!
30//! let details: StandardDeviceAuthorizationResponse = client
31//!     .exchange_device_code()?
32//!     .add_scope(Scope::new("XboxLive.signin offline_access".to_string()))
33//!     .request_async(async_http_client)
34//!     .await?;
35//!
36//! println!(
37//!     "Open this URL in your browser:\n{}\nand enter the code: {}",
38//!     details.verification_uri().to_string(),
39//!     details.user_code().secret().to_string()
40//! );
41//!
42//! let token = client
43//!     .exchange_device_access_token(&details)
44//!     .request_async(async_http_client, tokio::time::sleep, None)
45//!     .await?;
46//! println!("microsoft token: {:?}", token);
47//!
48//! let mc_flow = MinecraftAuthorizationFlow::new(Client::new());
49//! let mc_token = mc_flow.exchange_microsoft_token(token.access_token().secret()).await?;
50//! println!("minecraft token: {:?}", mc_token);
51//! # Ok(())
52//! # }
53//! ```
54use std::collections::HashMap;
55use std::fmt::Debug;
56
57use getset::{CopyGetters, Getters};
58use nutype::nutype;
59use reqwest::{Client, StatusCode};
60use serde::{Deserialize, Serialize};
61use serde_json::json;
62use thiserror::Error;
63
64const MINECRAFT_LOGIN_WITH_XBOX: &str = "https://api.minecraftservices.com/authentication/login_with_xbox";
65const XBOX_USER_AUTHENTICATE: &str = "https://user.auth.xboxlive.com/user/authenticate";
66const XBOX_XSTS_AUTHORIZE: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
67
68/// Represents a Minecraft access token
69#[nutype(
70    validate(not_empty),
71    derive(Clone, PartialEq, Eq, Hash, Deserialize, Serialize, AsRef, Into)
72)]
73pub struct MinecraftAccessToken(String);
74
75impl Debug for MinecraftAccessToken {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_tuple("MinecraftAccessToken").field(&"[redacted]").finish()
78    }
79}
80
81/// Represents the token type of a Minecraft access token
82#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
83#[serde(rename_all = "PascalCase")]
84pub enum MinecraftTokenType {
85    Bearer,
86}
87
88/// Represents an error that can occur when authenticating with Minecraft.
89#[derive(Error, Debug)]
90pub enum MinecraftAuthorizationError {
91    /// An error occurred while sending the request
92    #[error(transparent)]
93    Reqwest(#[from] reqwest::Error),
94
95    /// Account belongs to a minor who needs to be added to a microsoft family
96    #[error("Minor must be added to microsoft family")]
97    AddToFamily,
98
99    /// Account does not have xbox, user must create an xbox account to continue
100    #[error("Account does not have xbox")]
101    NoXbox,
102
103    /// Claims were missing from the response
104    #[error("missing claims from response")]
105    MissingClaims,
106}
107
108/// The response from Minecraft when attempting to authenticate with an xbox
109/// token
110#[derive(Deserialize, Serialize, Debug, Getters, CopyGetters, Clone)]
111pub struct MinecraftAuthenticationResponse {
112    /// UUID of the Xbox account.
113    /// Please note that this is not the Minecraft player's UUID
114    #[getset(get = "pub")]
115    username: String,
116
117    /// The minecraft JWT access token
118    #[getset(get = "pub")]
119    access_token: MinecraftAccessToken,
120
121    /// The type of access token
122    #[getset(get = "pub")]
123    token_type: MinecraftTokenType,
124
125    /// How many seconds until the token expires
126    #[getset(get_copy = "pub")]
127    expires_in: u32,
128}
129
130/// The response from Xbox when authenticating with a Microsoft token
131#[derive(Deserialize, Debug)]
132#[serde(rename_all = "PascalCase")]
133struct XboxLiveAuthenticationResponse {
134    /// The xbox authentication token to use
135    token: String,
136
137    /// An object that contains a vec of `uhs` objects
138    /// Looks like { "xui": [{"uhs": "xbl_token"}] }
139    display_claims: HashMap<String, Vec<HashMap<String, String>>>,
140}
141
142/// The error response from Xbox when authenticating with a Microsoft token
143#[derive(Serialize, Deserialize)]
144#[serde(rename_all = "PascalCase")]
145pub struct XboxLiveAuthenticationResponseError {
146    /// Always zero
147    identity: String,
148
149    /// Error id
150    /// 2148916238 means <18 and needs to be added to microsoft family
151    /// 2148916233 means xbox account needs to be created
152    x_err: i64,
153
154    /// Message about error
155    message: String,
156
157    /// Where to go to fix the error as a user
158    redirect: String,
159}
160
161/// The flow for authenticating with a Microsoft access token and getting a
162/// Minecraft access token.
163pub struct MinecraftAuthorizationFlow {
164    http_client: Client,
165}
166
167impl MinecraftAuthorizationFlow {
168    /// Creates a new [MinecraftAuthorizationFlow] using the given
169    /// [Client].
170    pub const fn new(http_client: Client) -> Self {
171        Self { http_client }
172    }
173
174    /// Authenticates with the Microsoft identity platform using the given
175    /// Microsoft access token and returns a [MinecraftAuthenticationResponse]
176    /// that contains the Minecraft access token.
177    pub async fn exchange_microsoft_token(
178        &self, microsoft_access_token: impl AsRef<str>,
179    ) -> Result<MinecraftAuthenticationResponse, MinecraftAuthorizationError> {
180        let (xbox_token, user_hash) = self.xbox_token(microsoft_access_token).await?;
181        let xbox_security_token = self.xbox_security_token(xbox_token).await?;
182
183        let response = self
184            .http_client
185            .post(MINECRAFT_LOGIN_WITH_XBOX)
186            .json(&json!({
187                "identityToken":
188                    format!(
189                        "XBL3.0 x={user_hash};{xsts_token}",
190                        user_hash = user_hash,
191                        xsts_token = xbox_security_token.token
192                    )
193            }))
194            .send()
195            .await?;
196        response.error_for_status_ref()?;
197
198        let response = response.json().await?;
199        Ok(response)
200    }
201
202    async fn xbox_security_token(
203        &self, xbox_token: String,
204    ) -> Result<XboxLiveAuthenticationResponse, MinecraftAuthorizationError> {
205        let response = self
206            .http_client
207            .post(XBOX_XSTS_AUTHORIZE)
208            .json(&json!({
209                "Properties": {
210                    "SandboxId": "RETAIL",
211                    "UserTokens": [xbox_token]
212                },
213                "RelyingParty": "rp://api.minecraftservices.com/",
214                "TokenType": "JWT"
215            }))
216            .send()
217            .await?;
218        if response.status() == StatusCode::UNAUTHORIZED {
219            let xbox_security_token_err_resp_res = response.json().await;
220            if xbox_security_token_err_resp_res.is_err() {
221                return Err(MinecraftAuthorizationError::MissingClaims);
222            }
223            let xbox_security_token_err_resp: XboxLiveAuthenticationResponseError =
224                xbox_security_token_err_resp_res.expect("This should succeed always");
225            match xbox_security_token_err_resp.x_err {
226                2148916238 => Err(MinecraftAuthorizationError::AddToFamily),
227                2148916233 => Err(MinecraftAuthorizationError::NoXbox),
228                _ => Err(MinecraftAuthorizationError::MissingClaims),
229            }
230        } else {
231            response.error_for_status_ref()?;
232            let xbox_security_token_resp: XboxLiveAuthenticationResponse = response.json().await?;
233            Ok(xbox_security_token_resp)
234        }
235    }
236
237    async fn xbox_token(
238        &self, microsoft_access_token: impl AsRef<str>,
239    ) -> Result<(String, String), MinecraftAuthorizationError> {
240        let xbox_authenticate_json = json!({
241            "Properties": {
242                "AuthMethod": "RPS",
243                "SiteName": "user.auth.xboxlive.com",
244                "RpsTicket": &format!("d={}", microsoft_access_token.as_ref())
245            },
246            "RelyingParty": "http://auth.xboxlive.com",
247            "TokenType": "JWT"
248        });
249        let response = self
250            .http_client
251            .post(XBOX_USER_AUTHENTICATE)
252            .json(&xbox_authenticate_json)
253            .send()
254            .await?;
255        response.error_for_status_ref()?;
256        let xbox_resp: XboxLiveAuthenticationResponse = response.json().await?;
257        let xbox_token = xbox_resp.token;
258        let user_hash = xbox_resp
259            .display_claims
260            .get("xui")
261            .ok_or(MinecraftAuthorizationError::MissingClaims)?
262            .first()
263            .ok_or(MinecraftAuthorizationError::MissingClaims)?
264            .get("uhs")
265            .ok_or(MinecraftAuthorizationError::MissingClaims)?
266            .to_owned();
267        Ok((xbox_token, user_hash))
268    }
269}