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",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|
|
@ -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"] }
|
||||||
|
|
142
src/auth.rs
142
src/auth.rs
|
@ -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,
|
||||||
|
/// ```json
|
||||||
/// {"xui": [{"uhs": "userhash"}]}
|
/// {"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,18 +220,27 @@ 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> {
|
||||||
tracing::info!("logging in to minecraft");
|
tracing::info!("logging in to minecraft");
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue