helixlauncher_core/
auth.rs

1mod account;
2mod request_structs;
3
4use reqwest::{Client, StatusCode};
5use serde_json::json;
6use std::time::Duration;
7use thiserror::Error;
8
9use account::Account;
10use request_structs::*;
11
12#[derive(Error, Debug)]
13pub enum AuthenticationError {
14    #[error(transparent)]
15    ReqwestError(#[from] reqwest::Error),
16
17    #[error("OAuth request was declined by the user.")]
18    OAuthDeclined,
19
20    #[error("OAuth request timed out, the user took too long.")]
21    OAuthExpired,
22
23    #[error("A request returned an unexpected result.")]
24    Unexpected,
25}
26
27pub struct MinecraftAuthenticator {
28    client_id: String,
29    reqwest_client: Client,
30}
31
32impl MinecraftAuthenticator {
33    pub fn new<I: Into<String>>(client_id: I) -> Self {
34        Self {
35            client_id: client_id.into(),
36            reqwest_client: Client::new(),
37        }
38    }
39
40    pub async fn initial_auth(
41        &self,
42        callback: fn(code: String, uri: String, message: String),
43    ) -> Result<Account, AuthenticationError> {
44        let code_response: CodeResponse = self
45            .reqwest_client
46            .get("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode")
47            .form(&[
48                ("client_id", self.client_id.as_str()),
49                ("scope", "XboxLive.signin offline_access"),
50            ])
51            .send()
52            .await?
53            .json()
54            .await?;
55
56        callback(
57            code_response.user_code,
58            code_response.verification_uri,
59            code_response.message,
60        );
61
62        loop {
63            tokio::time::sleep(Duration::from_secs(code_response.interval as u64)).await;
64
65            let grant_response = self
66                .reqwest_client
67                .post("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")
68                .form(&[
69                    ("client_id", self.client_id.as_str()),
70                    ("device_code", code_response.device_code.as_str()),
71                    (
72                        "grant_type",
73                        &"urn:ietf:params:oauth:grant-type:device_code",
74                    ),
75                ])
76                .send()
77                .await?;
78
79            return if grant_response.status() == StatusCode::OK {
80                let poll_success: GrantSuccessResponse = grant_response.json().await?;
81
82                self.authenticate(poll_success.access_token, poll_success.refresh_token)
83                    .await
84            } else {
85                let poll_error: GrantFailureResponse = grant_response.json().await?;
86
87                match poll_error.error.as_str() {
88                    "authorization_pending" => continue,
89                    "authorization_declined" => Err(AuthenticationError::OAuthDeclined),
90                    "expired_token" => Err(AuthenticationError::OAuthExpired),
91                    _ => Err(AuthenticationError::Unexpected),
92                }
93            };
94        }
95    }
96
97    pub async fn refresh(&self, account: Account) -> Result<Account, AuthenticationError> {
98        let grant_response: GrantSuccessResponse = self
99            .reqwest_client
100            .post("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")
101            .form(&[
102                ("client_id", self.client_id.as_str()),
103                ("grant_type", "refresh_token"),
104                ("scope", "XboxLive.signin offline_access"),
105                ("refresh_token", account.refresh_token.as_str()),
106            ])
107            .send()
108            .await?
109            .json()
110            .await?;
111
112        self.authenticate(grant_response.access_token, grant_response.refresh_token)
113            .await
114    }
115
116    async fn authenticate(
117        &self,
118        access_token: String,
119        refresh_token: String,
120    ) -> Result<Account, AuthenticationError> {
121        let xbox_live_response: XboxLiveResponse = self
122            .reqwest_client
123            .post("https://user.auth.xboxlive.com/user/authenticate")
124            .json(&json!({
125                "Properties": {
126                    "AuthMethod": "RPS",
127                    "SiteName": "user.auth.xboxlive.com",
128                    "RpsTicket": &format!("d={access_token}")
129                },
130                "RelyingParty": "http://auth.xboxlive.com",
131                "TokenType": "JWT"
132            }))
133            .send()
134            .await?
135            .json()
136            .await?;
137
138        // Reuse the struct here, the response is laid out the same
139        let xsts_response: XboxLiveResponse = self
140            .reqwest_client
141            .post("https://xsts.auth.xboxlive.com/xsts/authorize")
142            .json(&json!({
143                "Properties": {
144                    "SandboxId": "RETAIL",
145                    "UserTokens": [ xbox_live_response.token ]
146                },
147                "RelyingParty": "rp://api.minecraftservices.com/",
148                "TokenType": "JWT"
149            }))
150            .send()
151            .await?
152            .json()
153            .await?;
154
155        let identity_token = format!(
156            "XBL3.0 x={};{}",
157            xsts_response.display_claims["xui"][0]["uhs"], xsts_response.token
158        );
159        let minecraft_response: MinecraftResponse = self
160            .reqwest_client
161            .post("https://api.minecraftservices.com/authentication/login_with_xbox")
162            .json(&json!({ "identityToken": &identity_token }))
163            .send()
164            .await?
165            .json()
166            .await?;
167
168        let profile_response: ProfileResponse = self
169            .reqwest_client
170            .get("https://api.minecraftservices.com/minecraft/profile")
171            .header(
172                "Authorization",
173                format!("Bearer {}", minecraft_response.access_token),
174            )
175            .send()
176            .await?
177            .json()
178            .await?;
179
180        Ok(Account {
181            uuid: profile_response.id,
182            username: profile_response.name,
183            refresh_token,
184            token: minecraft_response.access_token,
185        })
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use crate::auth::MinecraftAuthenticator;
192
193    #[tokio::test]
194    async fn test() {
195        let authenticator = MinecraftAuthenticator::new("1d644380-5a23-4a84-89c3-5d29615fbac2");
196
197        let account = authenticator
198            .initial_auth(|_, _, message| println!("{}", message))
199            .await
200            .unwrap();
201
202        println!("{}", serde_json::to_string(&account).unwrap());
203
204        let account = authenticator.refresh(account).await.unwrap();
205
206        println!("{}", serde_json::to_string(&account).unwrap());
207    }
208}