feat(auth): proper error reporting
This commit is contained in:
parent
4249195d0f
commit
406eec9a1b
3 changed files with 121 additions and 35 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -1591,6 +1591,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
|
|
@ -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"] }
|
||||
|
|
154
src/auth.rs
154
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<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 !!!!
|
||||
pub async fn login() -> Result<MinecraftProfile> {
|
||||
// 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<String> {
|
|||
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<String> {
|
|||
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::<XboxLiveResponse>()
|
||||
.await?;
|
||||
|
||||
let userhash = xbox_res.get_userhash()?;
|
||||
if response.status() == StatusCode::OK {
|
||||
let xbox_res = response.json::<XboxLiveResponse>().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::<XboxLiveResponse>()
|
||||
.await?;
|
||||
|
||||
let userhash = xsts_res.get_userhash()?;
|
||||
match response.status() {
|
||||
StatusCode::OK => {
|
||||
let xsts_res = response.json::<XboxLiveResponse>().await?;
|
||||
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> {
|
||||
|
@ -169,15 +249,19 @@ async fn login_to_mc(xsts_token: &str, userhash: &str) -> Result<String> {
|
|||
"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::<MinecraftLoginResponse>()
|
||||
.await?;
|
||||
|
||||
Ok(mc_res.access_token)
|
||||
if response.status() == StatusCode::OK {
|
||||
let mc_res = response.json::<MinecraftLoginResponse>().await?;
|
||||
|
||||
Ok(mc_res.access_token)
|
||||
} else {
|
||||
Err(AuthenticationError::from_response(response).await)
|
||||
}
|
||||
}
|
||||
|
||||
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?
|
||||
.json::<MinecraftProfile>()
|
||||
.await
|
||||
.map_err(Report::from)
|
||||
.map_err(AuthenticationError::from)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue