diff --git a/Cargo.lock b/Cargo.lock index d50a771..324bca0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1591,6 +1591,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "thiserror", "tokio", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 5d4b372..dd729c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ reqwest = { version = "0.12.5", default-features = false, features = [ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10.8" +thiserror = "1.0.63" tokio = { version = "1.38.0", features = ["full"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter", "chrono"] } diff --git a/src/auth.rs b/src/auth.rs index 98ef6be..ea76e38 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,15 +1,14 @@ use std::time::Duration; -use color_eyre::{ - eyre::{eyre, OptionExt, Report, Result}, - Section, -}; +use color_eyre::eyre::Report; use oauth2::{ basic::BasicClient, reqwest::async_http_client, AuthUrl, ClientId, DeviceAuthorizationUrl, Scope, StandardDeviceAuthorizationResponse, TokenResponse, TokenUrl, }; +use reqwest::{Response, StatusCode, Url}; use serde::Deserialize; use serde_json::json; +use thiserror::Error; const CLIENT_ID: &str = "39bd526b-1897-42c9-b652-8b987550ab89"; @@ -25,7 +24,9 @@ const MINECRAFT_PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft #[serde(rename_all = "PascalCase")] struct XboxLiveResponse { token: String, - /// { "xui": [{"uhs": "userhash"}] } + /// ```json + /// {"xui": [{"uhs": "userhash"}]} + /// ``` display_claims: serde_json::Value, } @@ -37,11 +38,18 @@ impl XboxLiveResponse { .and_then(|v| v.get("uhs")) .and_then(|v| v.as_str()) .map(std::borrow::ToOwned::to_owned) - .ok_or_eyre("Could not fetch user hash") - .with_note(|| format!("DisplayClaims value: {}", self.display_claims)) + .ok_or_else(|| AuthenticationError::InvalidDisplayClaims(self.display_claims.clone())) } } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct XstsErrorResponse { + x_err: u32, + message: String, + redirect: String, +} + #[derive(Debug, Deserialize)] struct MinecraftLoginResponse { access_token: String, @@ -53,22 +61,77 @@ pub struct MinecraftProfile { pub name: String, } +#[derive(Debug, Error)] +pub enum AuthenticationError { + #[error("reqwest error")] + Reqwest(#[from] reqwest::Error), + /// from `eyre::Report` because oauth2 error types are too complicated + #[error(transparent)] + OAuth2(#[from] Report), + #[error("userhash mismatch (xbox live: {0} / xsts: {1})")] + UserHashMismatch(String, String), + #[error("invalid display claims: {0}")] + InvalidDisplayClaims(serde_json::Value), + #[error("authentication failed: {0} returned {1}, body:\n{2}")] + AuthenticationFailed(Url, StatusCode, String), + #[error(transparent)] + XstsError(#[from] XstsError), +} + +impl AuthenticationError { + async fn from_response(response: Response) -> Self { + let url = response.url().clone(); + let status = response.status(); + + match response.text().await { + Ok(body) => Self::AuthenticationFailed(url, status, body), + Err(e) => Self::Reqwest(e), + } + } +} + +#[derive(Debug, Error)] +pub enum XstsError { + #[error("this account does not have an xbox account")] + NoXboxAccount, + #[error("xbox live is unavailable in your region/country")] + XboxLiveUnavailable, + #[error("the account needs adult verification (south korea)")] + AdultVerificationNeededSK, + #[error("the account is a child and needs to be added to a family")] + AddToFamily, + #[error("unknown error: {}, {} (see {})", .0.x_err, .0.message, .0.redirect)] + Unknown(XstsErrorResponse), +} + +impl From for XstsError { + #[allow(clippy::unreadable_literal)] + fn from(res: XstsErrorResponse) -> Self { + match res.x_err { + 2148916233 => Self::NoXboxAccount, + 2148916235 => Self::XboxLiveUnavailable, + 2148916236 | 2148916237 => Self::AdultVerificationNeededSK, + 2148916238 => Self::AddToFamily, + _ => Self::Unknown(res), + } + } +} + +type Result = std::result::Result; + // TODO better error handling !!!! pub async fn login() -> Result { // temporary until i can access api.minecraftservices.com - return Ok(MinecraftProfile { - id: String::new(), - name: String::new(), - }); let access_token = request_msa_token().await?; let (xbox_token, userhash) = auth_xbox_live(&access_token).await?; let (xsts_token, xsts_userhash) = fetch_xsts_token(&xbox_token).await?; if userhash != xsts_userhash { - return Err(eyre!("user hashes do not match!").with_note(|| { - format!("xbox live userhash: {userhash} / xsts userhash: {xsts_userhash}") - })); + return Err(AuthenticationError::UserHashMismatch( + userhash, + xsts_userhash, + )); } let mc_token = login_to_mc(&xsts_token, &userhash).await?; @@ -80,16 +143,20 @@ async fn request_msa_token() -> Result { let client = BasicClient::new( ClientId::new(CLIENT_ID.to_string()), None, - AuthUrl::new(MSA_AUTHORIZE_URL.to_string())?, - Some(TokenUrl::new(MSA_TOKEN_URL.to_string())?), + AuthUrl::new(MSA_AUTHORIZE_URL.to_string()).map_err(Report::from)?, + Some(TokenUrl::new(MSA_TOKEN_URL.to_string()).map_err(Report::from)?), ) - .set_device_authorization_url(DeviceAuthorizationUrl::new(DEVICE_CODE_URL.to_string())?); + .set_device_authorization_url( + DeviceAuthorizationUrl::new(DEVICE_CODE_URL.to_string()).map_err(Report::from)?, + ); let details: StandardDeviceAuthorizationResponse = client - .exchange_device_code()? + .exchange_device_code() + .map_err(Report::from)? .add_scope(Scope::new("XboxLive.signin offline_access".to_string())) .request_async(async_http_client) - .await?; + .await + .map_err(Report::from)?; tracing::info!( "url: {} code: {}", @@ -104,7 +171,8 @@ async fn request_msa_token() -> Result { tokio::time::sleep, Some(Duration::from_secs(300)), ) - .await?; + .await + .map_err(Report::from)?; tracing::info!("fetched msa account token"); @@ -124,17 +192,20 @@ async fn auth_xbox_live(msa_token: &str) -> Result<(String, String)> { "TokenType": "JWT" }); - let xbox_res = crate::HTTP_CLIENT + let response = crate::HTTP_CLIENT .post(XBOX_USER_AUTH_URL) .json(&json) .send() - .await? - .json::() .await?; - let userhash = xbox_res.get_userhash()?; + if response.status() == StatusCode::OK { + let xbox_res = response.json::().await?; + let userhash = xbox_res.get_userhash()?; - Ok((xbox_res.token, userhash)) + Ok((xbox_res.token, userhash)) + } else { + Err(AuthenticationError::from_response(response).await) + } } async fn fetch_xsts_token(xbox_token: &str) -> Result<(String, String)> { @@ -149,17 +220,26 @@ async fn fetch_xsts_token(xbox_token: &str) -> Result<(String, String)> { "TokenType": "JWT" }); - let xsts_res = crate::HTTP_CLIENT + let response = crate::HTTP_CLIENT .post(XBOX_XSTS_AUTHORIZE_URL) .json(&json) .send() - .await? - .json::() .await?; - let userhash = xsts_res.get_userhash()?; + match response.status() { + StatusCode::OK => { + let xsts_res = response.json::().await?; + let userhash = xsts_res.get_userhash()?; - Ok((xsts_res.token, userhash)) + Ok((xsts_res.token, userhash)) + } + StatusCode::UNAUTHORIZED => { + let xsts_err = response.json::().await?; + + Err(XstsError::from(xsts_err).into()) + } + _ => Err(AuthenticationError::from_response(response).await), + } } async fn login_to_mc(xsts_token: &str, userhash: &str) -> Result { @@ -169,15 +249,19 @@ async fn login_to_mc(xsts_token: &str, userhash: &str) -> Result { "identityToken": format!("XBL3.0 x={userhash};{xsts_token}") }); - let mc_res = crate::HTTP_CLIENT + let response = crate::HTTP_CLIENT .post(MINECRAFT_AUTH_URL) .json(&json) .send() - .await? - .json::() .await?; - Ok(mc_res.access_token) + if response.status() == StatusCode::OK { + let mc_res = response.json::().await?; + + Ok(mc_res.access_token) + } else { + Err(AuthenticationError::from_response(response).await) + } } async fn get_minecraft_profile(mc_token: &str) -> Result { @@ -190,5 +274,5 @@ async fn get_minecraft_profile(mc_token: &str) -> Result { .await? .json::() .await - .map_err(Report::from) + .map_err(AuthenticationError::from) }