feat(auth): proper error reporting

This commit is contained in:
uku 2024-07-31 12:17:01 +02:00
parent 4249195d0f
commit 406eec9a1b
Signed by: uku
SSH key fingerprint: SHA256:4P0aN6M8ajKukNi6aPOaX0LacanGYtlfjmN+m/sHY/o
3 changed files with 121 additions and 35 deletions

1
Cargo.lock generated
View file

@ -1591,6 +1591,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"thiserror",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",

View file

@ -18,6 +18,7 @@ reqwest = { version = "0.12.5", default-features = false, features = [
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
sha2 = "0.10.8" sha2 = "0.10.8"
thiserror = "1.0.63"
tokio = { version = "1.38.0", features = ["full"] } tokio = { version = "1.38.0", features = ["full"] }
tracing = "0.1.40" tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "chrono"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter", "chrono"] }

View file

@ -1,15 +1,14 @@
use std::time::Duration; use std::time::Duration;
use color_eyre::{ use color_eyre::eyre::Report;
eyre::{eyre, OptionExt, Report, Result},
Section,
};
use oauth2::{ use oauth2::{
basic::BasicClient, reqwest::async_http_client, AuthUrl, ClientId, DeviceAuthorizationUrl, basic::BasicClient, reqwest::async_http_client, AuthUrl, ClientId, DeviceAuthorizationUrl,
Scope, StandardDeviceAuthorizationResponse, TokenResponse, TokenUrl, Scope, StandardDeviceAuthorizationResponse, TokenResponse, TokenUrl,
}; };
use reqwest::{Response, StatusCode, Url};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use thiserror::Error;
const CLIENT_ID: &str = "39bd526b-1897-42c9-b652-8b987550ab89"; 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")] #[serde(rename_all = "PascalCase")]
struct XboxLiveResponse { struct XboxLiveResponse {
token: String, token: String,
/// { "xui": [{"uhs": "userhash"}] } /// ```json
/// {"xui": [{"uhs": "userhash"}]}
/// ```
display_claims: serde_json::Value, display_claims: serde_json::Value,
} }
@ -37,11 +38,18 @@ impl XboxLiveResponse {
.and_then(|v| v.get("uhs")) .and_then(|v| v.get("uhs"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(std::borrow::ToOwned::to_owned) .map(std::borrow::ToOwned::to_owned)
.ok_or_eyre("Could not fetch user hash") .ok_or_else(|| AuthenticationError::InvalidDisplayClaims(self.display_claims.clone()))
.with_note(|| format!("DisplayClaims value: {}", self.display_claims))
} }
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct XstsErrorResponse {
x_err: u32,
message: String,
redirect: String,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct MinecraftLoginResponse { struct MinecraftLoginResponse {
access_token: String, access_token: String,
@ -53,22 +61,77 @@ pub struct MinecraftProfile {
pub name: String, 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<XstsErrorResponse> 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<T> = std::result::Result<T, AuthenticationError>;
// TODO better error handling !!!! // TODO better error handling !!!!
pub async fn login() -> Result<MinecraftProfile> { pub async fn login() -> Result<MinecraftProfile> {
// temporary until i can access api.minecraftservices.com // 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 access_token = request_msa_token().await?;
let (xbox_token, userhash) = auth_xbox_live(&access_token).await?; let (xbox_token, userhash) = auth_xbox_live(&access_token).await?;
let (xsts_token, xsts_userhash) = fetch_xsts_token(&xbox_token).await?; let (xsts_token, xsts_userhash) = fetch_xsts_token(&xbox_token).await?;
if userhash != xsts_userhash { if userhash != xsts_userhash {
return Err(eyre!("user hashes do not match!").with_note(|| { return Err(AuthenticationError::UserHashMismatch(
format!("xbox live userhash: {userhash} / xsts userhash: {xsts_userhash}") userhash,
})); xsts_userhash,
));
} }
let mc_token = login_to_mc(&xsts_token, &userhash).await?; let mc_token = login_to_mc(&xsts_token, &userhash).await?;
@ -80,16 +143,20 @@ async fn request_msa_token() -> Result<String> {
let client = BasicClient::new( let client = BasicClient::new(
ClientId::new(CLIENT_ID.to_string()), ClientId::new(CLIENT_ID.to_string()),
None, None,
AuthUrl::new(MSA_AUTHORIZE_URL.to_string())?, AuthUrl::new(MSA_AUTHORIZE_URL.to_string()).map_err(Report::from)?,
Some(TokenUrl::new(MSA_TOKEN_URL.to_string())?), 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 let details: StandardDeviceAuthorizationResponse = client
.exchange_device_code()? .exchange_device_code()
.map_err(Report::from)?
.add_scope(Scope::new("XboxLive.signin offline_access".to_string())) .add_scope(Scope::new("XboxLive.signin offline_access".to_string()))
.request_async(async_http_client) .request_async(async_http_client)
.await?; .await
.map_err(Report::from)?;
tracing::info!( tracing::info!(
"url: {} code: {}", "url: {} code: {}",
@ -104,7 +171,8 @@ async fn request_msa_token() -> Result<String> {
tokio::time::sleep, tokio::time::sleep,
Some(Duration::from_secs(300)), Some(Duration::from_secs(300)),
) )
.await?; .await
.map_err(Report::from)?;
tracing::info!("fetched msa account token"); tracing::info!("fetched msa account token");
@ -124,17 +192,20 @@ async fn auth_xbox_live(msa_token: &str) -> Result<(String, String)> {
"TokenType": "JWT" "TokenType": "JWT"
}); });
let xbox_res = crate::HTTP_CLIENT let response = crate::HTTP_CLIENT
.post(XBOX_USER_AUTH_URL) .post(XBOX_USER_AUTH_URL)
.json(&json) .json(&json)
.send() .send()
.await?
.json::<XboxLiveResponse>()
.await?; .await?;
if response.status() == StatusCode::OK {
let xbox_res = response.json::<XboxLiveResponse>().await?;
let userhash = xbox_res.get_userhash()?; 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)> { 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" "TokenType": "JWT"
}); });
let xsts_res = crate::HTTP_CLIENT let response = crate::HTTP_CLIENT
.post(XBOX_XSTS_AUTHORIZE_URL) .post(XBOX_XSTS_AUTHORIZE_URL)
.json(&json) .json(&json)
.send() .send()
.await?
.json::<XboxLiveResponse>()
.await?; .await?;
match response.status() {
StatusCode::OK => {
let xsts_res = response.json::<XboxLiveResponse>().await?;
let userhash = xsts_res.get_userhash()?; let userhash = xsts_res.get_userhash()?;
Ok((xsts_res.token, userhash)) Ok((xsts_res.token, userhash))
}
StatusCode::UNAUTHORIZED => {
let xsts_err = response.json::<XstsErrorResponse>().await?;
Err(XstsError::from(xsts_err).into())
}
_ => Err(AuthenticationError::from_response(response).await),
}
} }
async fn login_to_mc(xsts_token: &str, userhash: &str) -> Result<String> { async fn login_to_mc(xsts_token: &str, userhash: &str) -> Result<String> {
@ -169,15 +249,19 @@ async fn login_to_mc(xsts_token: &str, userhash: &str) -> Result<String> {
"identityToken": format!("XBL3.0 x={userhash};{xsts_token}") "identityToken": format!("XBL3.0 x={userhash};{xsts_token}")
}); });
let mc_res = crate::HTTP_CLIENT let response = crate::HTTP_CLIENT
.post(MINECRAFT_AUTH_URL) .post(MINECRAFT_AUTH_URL)
.json(&json) .json(&json)
.send() .send()
.await?
.json::<MinecraftLoginResponse>()
.await?; .await?;
if response.status() == StatusCode::OK {
let mc_res = response.json::<MinecraftLoginResponse>().await?;
Ok(mc_res.access_token) Ok(mc_res.access_token)
} else {
Err(AuthenticationError::from_response(response).await)
}
} }
async fn get_minecraft_profile(mc_token: &str) -> Result<MinecraftProfile> { async fn get_minecraft_profile(mc_token: &str) -> Result<MinecraftProfile> {
@ -190,5 +274,5 @@ async fn get_minecraft_profile(mc_token: &str) -> Result<MinecraftProfile> {
.await? .await?
.json::<MinecraftProfile>() .json::<MinecraftProfile>()
.await .await
.map_err(Report::from) .map_err(AuthenticationError::from)
} }