From ae9727f42c7059dd1ae751411dded1819c6c0a11 Mon Sep 17 00:00:00 2001 From: bigfoot547 Date: Thu, 13 Feb 2025 14:54:31 -0600 Subject: overhaul auth errors --- ozone-cli/src/main.rs | 64 +++++++++++++-------- ozone/src/auth.rs | 106 ++++++++-------------------------- ozone/src/auth/error.rs | 133 +++++++++++++++++++++++++++++++++++++++++++ ozone/src/auth/mcservices.rs | 19 ++++--- ozone/src/auth/msa.rs | 24 ++++---- ozone/src/auth/types.rs | 20 +++++-- 6 files changed, 234 insertions(+), 132 deletions(-) create mode 100644 ozone/src/auth/error.rs diff --git a/ozone-cli/src/main.rs b/ozone-cli/src/main.rs index c53eade..4a8f368 100644 --- a/ozone-cli/src/main.rs +++ b/ozone-cli/src/main.rs @@ -1,6 +1,7 @@ mod cli; use std::error::Error; +use std::path::Path; use std::process::ExitCode; use log::{error, info, trace, LevelFilter}; use clap::Parser; @@ -10,12 +11,42 @@ use uuid::Uuid; use ozone::auth::{Account, AuthenticationDatabase}; use crate::cli::{Cli, InstanceCommand, RootCommand}; -fn find_account(auth: &AuthenticationDatabase, input: &str) -> Option { - for user in auth.users.iter() { - +fn find_account<'a>(auth: &'a AuthenticationDatabase, input: &str) -> Vec<&'a Account> { + if let Ok(uuid) = input.parse::() { + todo!() + } + + let input = input.to_ascii_lowercase(); + + auth.users.iter().filter(|a| match *a { + Account::Dummy(profile) => profile.name.to_ascii_lowercase().starts_with(&input), + Account::MSA(account) => + account.player_profile.as_ref().is_some_and(|p| p.name.to_ascii_lowercase().starts_with(&input)) || + account.gamertag.as_ref().is_some_and(|p| p.to_ascii_lowercase().starts_with(&input)) + }).collect() +} + +fn display_instance(instance: &Instance, id: Uuid, home: impl AsRef, selected: bool, verbose: bool) { + println!("Instance `{}':{}", instance.name, if selected { " (selected)" } else { "" }); + println!(" UUID: {}", id); + println!(" Version: {}", instance.game_version); + println!(" Location: {}", home.as_ref().join(Settings::get_instance_path(id)).display()); + + if !verbose { return; } + + if let Some(ref args) = instance.jvm_arguments { + println!(" JVM arguments: <{} argument{}>", args.len(), if args.len() == 1 { "" } else { "s" }) + } + + if let Some(res) = instance.resolution { + println!(" Resolution: {}x{}", res.width, res.height); + } + + match &instance.java_runtime { + Some(JavaRuntimeSetting::Component(c)) => println!(" Java runtime component: {c}"), + Some(JavaRuntimeSetting::Path(p)) => println!(" Java runtime path: {}", p.display()), + _ => () } - - todo!() } async fn main_inner(cli: Cli) -> Result> { @@ -45,12 +76,8 @@ async fn main_inner(cli: Cli) -> Result> { first = false; let cur_id = *cur_id; - let sel = if settings.selected_instance.is_some_and(|id| id == cur_id) { " (selected)" } else { "" }; - - println!("Instance `{}'{sel}:", instance.name); - println!(" UUID: {}", cur_id); - println!(" Version: {}", instance.game_version); - println!(" Location: {}", home.join(Settings::get_instance_path(cur_id)).display()); + let sel = settings.selected_instance.is_some_and(|id| id == cur_id); + display_instance(instance, cur_id, &home, sel, false); } }, InstanceCommand::Create(args) => { @@ -158,18 +185,7 @@ async fn main_inner(cli: Cli) -> Result> { return Ok(ExitCode::FAILURE); }; - println!("Information for selected profile `{}' ({}):", inst.name, settings.selected_instance.unwrap()); - println!(" Version: {}", inst.game_version); - - match &inst.java_runtime { - Some(JavaRuntimeSetting::Component(c)) => println!(" Java runtime component: {c}"), - Some(JavaRuntimeSetting::Path(p)) => println!(" Java runtime path: {}", p.display()), - _ => () - } - - if let Some(res) = inst.resolution { - println!(" Game resolution: {}x{}", res.width, res.height); - } + display_instance(inst, settings.selected_instance.unwrap(), &home, false, true); }, InstanceCommand::Rename { name } => { if name.is_empty() { @@ -182,7 +198,7 @@ async fn main_inner(cli: Cli) -> Result> { return Ok(ExitCode::FAILURE); }; - inst.name.replace_range(.., &name); + inst.name.replace_range(.., name); settings.save().await?; } }, diff --git a/ozone/src/auth.rs b/ozone/src/auth.rs index 0ca96ad..cb905af 100644 --- a/ozone/src/auth.rs +++ b/ozone/src/auth.rs @@ -1,75 +1,21 @@ mod types; mod msa; mod mcservices; +pub mod error; -use std::error::Error; -use std::fmt::{Display, Formatter}; use std::future::Future; -use std::time::{Duration, SystemTime}; +use std::time::Duration; use chrono::{DateTime, TimeDelta, Utc}; use log::debug; -use oauth2::{AccessToken, DeviceAuthorizationUrl, DeviceCodeErrorResponse, EndpointNotSet, EndpointSet, HttpClientError, RequestTokenError, Scope, StandardDeviceAuthorizationResponse, StandardRevocableToken, TokenResponse, TokenUrl}; +use oauth2::{AccessToken, DeviceAuthorizationUrl, EndpointNotSet, EndpointSet, RequestTokenError, Scope, StandardDeviceAuthorizationResponse, StandardRevocableToken, TokenResponse, TokenUrl}; use oauth2::basic::{BasicErrorResponse, BasicErrorResponseType, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse, BasicTokenResponse}; use reqwest::{IntoUrl, Method, RequestBuilder}; + pub use types::*; +use crate::auth::error::{AuthError, AuthErrorKind}; use self::msa::{XSTS_RP_MINECRAFT_SERVICES, XSTS_RP_XBOX_LIVE}; use crate::util; -#[derive(Debug)] -pub enum AuthError { - // An unexpected error happened while performing a request - Request { what: &'static str, error: reqwest::Error }, - OAuthRequestToken { what: &'static str, error: RequestTokenError, BasicErrorResponse> }, - OAuthRequestDeviceCode { what: &'static str, error: RequestTokenError, DeviceCodeErrorResponse> }, - - // Some internal auth error (unrecoverable) - Internal(String), - - // Device code auth was cancelled - Cancel(Option>), - - // Device code auth timed out - Timeout, - - // Requires interactive authentication - RequireInteractive(&'static str), - - // XSTS error - AuthXError { what: &'static str, x_error: u64, message: Option }, - - // You don't own the game! - EntitlementError -} - -impl Display for AuthError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - AuthError::Request { what, error } => write!(f, "auth request error ({}): {}", what, error), - AuthError::OAuthRequestToken { what, error } => write!(f, "oauth error requesting token ({what}): {error}"), - AuthError::OAuthRequestDeviceCode { what, error } => write!(f, "oauth error with device code ({what}): {error}"), - AuthError::Internal(msg) => write!(f, "internal auth error: {}", msg), - AuthError::Cancel(Some(error)) => write!(f, "operation cancelled: {error}"), - AuthError::Cancel(None) => f.write_str("operation cancelled"), - AuthError::Timeout => f.write_str("interactive authentication timed out"), - AuthError::RequireInteractive(why) => write!(f, "user must log in interactively: {why}"), - AuthError::AuthXError { what, x_error, message } => write!(f, "XSTS error: {what} ({x_error} -> {})", message.as_ref().map_or("", |s| s.as_str())), - AuthError::EntitlementError => f.write_str("no minecraft entitlement (do you own the game?)") - } - } -} - -impl Error for AuthError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - AuthError::Request { error, .. } => Some(error), - AuthError::OAuthRequestToken { error, .. } => Some(error), - AuthError::OAuthRequestDeviceCode { error, .. } => Some(error), - AuthError::Cancel(Some(error)) => Some(error.as_ref()), - _ => None - } - } -} - impl Token { fn is_expired(&self, now: DateTime) -> bool { self.expire.is_some_and(|exp| now >= exp) @@ -110,9 +56,9 @@ impl MsaAccount { let to_scope = |f: &&str| Scope::new(String::from(*f)); if self.is_azure_client_id { - AZURE_LOGIN_SCOPES.iter().map(to_scope) + AZURE_LOGIN_SCOPES.into_iter().map(to_scope) } else { - NON_AZURE_LOGIN_SCOPES.iter().map(to_scope) + NON_AZURE_LOGIN_SCOPES.into_iter().map(to_scope) } } @@ -136,7 +82,14 @@ impl MsaAccount { .add_scopes(self.scopes_iter()) .add_extra_param("response_type", "device_code") .request_async(client) - .await.map_err(|e| AuthError::OAuthRequestToken { what: "refresh", error: e })?; + .await.map_err(|e| match e { + RequestTokenError::ServerResponse(res) + if match res.error() { + BasicErrorResponseType::Extension(s) if s == "interaction_required" || s == "consent_required" => true, + _ => false + } => AuthError::raw_msg(AuthErrorKind::InteractionRequired, "microsoft requested interactive login"), + _ => AuthError::internal_msg_src("refresh", e) + })?; self.refresh_token = tokenres.refresh_token().cloned(); @@ -154,14 +107,14 @@ impl MsaAccount { .add_scopes(self.scopes_iter()) .add_extra_param("response_type", "device_code") .request_async(client) - .await.map_err(|e| AuthError::OAuthRequestToken { what: "device code", error: e })?; + .await.map_err(|e| AuthError::internal_msg_src("device code", e))?; handle_device(device_auth.clone()).await; let tokenres = oauth_client.exchange_device_access_token(&device_auth) .set_max_backoff_interval(Duration::from_secs(20u64)) .request_async(client, tokio::time::sleep, None) - .await.map_err(|e| AuthError::OAuthRequestDeviceCode { what: "device access code", error: e })?; + .await.map_err(|e| AuthError::internal_msg_src("device access code", e))?; self.refresh_token = tokenres.refresh_token().cloned(); @@ -180,20 +133,11 @@ impl MsaAccount { } if self.refresh_token.is_none() { - return Err(AuthError::RequireInteractive("no refresh token")); + return Err(AuthError::raw_msg(AuthErrorKind::InteractionRequired, "refresh token missing")); } debug!("XBL token expired. Trying to refresh it."); - self.xbl_login_refresh(client).await - .map_err(|e| match &e { - AuthError::OAuthRequestToken { error: RequestTokenError::ServerResponse(res), .. } => match res.error() { - BasicErrorResponseType::Extension(s) if s == "interaction_required" || s == "consent_required" => { - AuthError::RequireInteractive("msa requested interactive logon") - }, - _ => e - }, - _ => e - })?; + self.xbl_login_refresh(client).await?; self.mc_token = None; @@ -215,7 +159,7 @@ impl MsaAccount { let (user_hash, mc_xsts_tok) = match msa::xsts_request(client, xbl_token, XSTS_RP_MINECRAFT_SERVICES).await? { msa::XSTSAuthResponse::Success(res) => { let user_hash = res.get_user_hash() - .map_or(Err(AuthError::Internal("malformed response: no user hash".into())), |h| Ok(h.to_owned()))?; + .map_or(Err(AuthError::internal_msg("malformed response: no user hash")), |h| Ok(h.to_owned()))?; (user_hash, res.into_token()) }, msa::XSTSAuthResponse::Error(e) => return Err(e.into()) @@ -237,7 +181,7 @@ impl MsaAccount { }; let Some(xuid) = res.get_xuid() else { - return Err(AuthError::Internal("missing xuid for user".into())); + return Err(AuthError::internal_msg("missing xuid for user")); }; self.xuid = Some(xuid.to_owned()); @@ -255,13 +199,13 @@ impl MsaAccount { debug!("Checking if you own the game..."); if !mcservices::owns_the_game(client, mc_token).await? { - return Err(AuthError::EntitlementError); + return Err(AuthErrorKind::NotEntitled.into()); } debug!("Getting your profile info..."); let player_info = mcservices::get_player_info(client, mc_token).await?; let player_profile = mcservices::get_player_profile(client, player_info.id).await - .map_err(|e| AuthError::Request { what: "looking up profile", error: e })?; + .map_err(|e| AuthError::internal_msg_src("looking up profile", e))?; self.player_info = Some(player_info); self.player_profile = Some(player_profile); @@ -315,8 +259,8 @@ mod test { loop { match user.log_in_silent(&client).await { Ok(_) => break, - Err(AuthError::RequireInteractive(s)) => { - debug!("Requires interactive auth: {s}") + Err(e) if e.kind() == AuthErrorKind::InteractionRequired => { + debug!("Requires interactive auth: {e}") }, Err(e) => { panic!("{}", e); diff --git a/ozone/src/auth/error.rs b/ozone/src/auth/error.rs new file mode 100644 index 0000000..da281c5 --- /dev/null +++ b/ozone/src/auth/error.rs @@ -0,0 +1,133 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthErrorKind { + Internal, // an internal error occurred + + Timeout, // interactive authentication timed out + Cancel, // interactive authentication cancelled by user code + InteractionRequired, // interactive authentication required + + NotOnXbox, // user is not on xbox + TooYoung, // user is too young and not in a family + NotEntitled, // user doesn't own Minecraft + NoProfile, // user owns the game but has not yet created a profile +} + +#[derive(Debug)] +pub struct AuthError { + pub(super) kind: AuthErrorKind, + pub(super) message: Option, + pub(super) source: Option> +} + +macro_rules! error_kind_messages { + ($ty:ident, $({ $name:ident, $value:literal } $(,)?)* $({ _, $def_value:literal })?) => { + impl Display for $ty { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match *self { + $($ty::$name => f.write_str($value)),+, + $(_ => f.write_str($def_value))? + } + } + } + } +} + +error_kind_messages!(AuthErrorKind, + { Internal, "internal authentication error" }, + + { Timeout, "interactive authentication timed out" }, + { Cancel, "interactive authentication cancelled by user code" }, + { InteractionRequired, "interactive authentication required" }, + + { NotOnXbox, "user is not on xbox" }, + { TooYoung, "user is too young" }, + { NotEntitled, "user does not own the game" }, + { NoProfile, "user has not created a profile" } +); + +impl AuthError { + pub fn internal_msg(message: impl Into) -> AuthError { + Self::raw_msg(AuthErrorKind::Internal, message.into()) + } + + pub fn internal_src>>(error: E) -> AuthError { + Self::raw_src(AuthErrorKind::Internal, error) + } + + pub fn internal_msg_src>>(message: impl Into, source: E) -> AuthError { + Self::raw_msg_src(AuthErrorKind::Internal, message, source) + } + + pub fn raw_msg_src(kind: AuthErrorKind, message: S, source: E) -> AuthError + where + E: Into>, + S: Into + { + AuthError { + kind, + message: Some(message.into()), + source: Some(source.into()) + } + } + + pub fn raw_src(kind: AuthErrorKind, source: E) -> AuthError + where + E: Into> + { + AuthError { + kind, + message: None, + source: Some(source.into()) + } + } + + pub fn raw_msg(kind: AuthErrorKind, message: S) -> AuthError + where + S: Into + { + AuthError { + kind, + message: Some(message.into()), + source: None + } + } + + pub fn kind(&self) -> AuthErrorKind { + self.kind + } + + pub fn message(&self) -> Option<&str> { + self.message.as_deref() + } +} + +impl From for AuthError { + fn from(kind: AuthErrorKind) -> Self { + Self { kind, message: None, source: None } + } +} + +impl Display for AuthError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if let Some(ref msg) = self.message { + write!(f, "{}: {msg}", self.kind)?; + } else { + write!(f, "{}", self.kind)?; + } + + if let Some(ref source) = self.source { + write!(f, " - caused by: {source}")?; + } + + Ok(()) + } +} + +impl Error for AuthError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source.as_deref() + } +} diff --git a/ozone/src/auth/mcservices.rs b/ozone/src/auth/mcservices.rs index 45ef795..588a833 100644 --- a/ozone/src/auth/mcservices.rs +++ b/ozone/src/auth/mcservices.rs @@ -1,8 +1,9 @@ use std::time::{Duration, SystemTime}; use chrono::{DateTime, Utc}; -use reqwest::{IntoUrl, Method, RequestBuilder}; +use reqwest::{IntoUrl, Method, RequestBuilder, StatusCode}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::auth::error::AuthErrorKind; use super::{AuthError, MinecraftPlayerInfo, PlayerProfile}; use super::types::Token; @@ -40,9 +41,9 @@ pub async fn login_with_xbox(client: &reqwest::Client, xsts_token: &str, user_ha let res: MinecraftXboxLoginResponse = super::build_json_request(client, MINECRAFT_LOGIN, Method::POST) .json(&req).send().await .and_then(|r| r.error_for_status()) - .map_err(|e| AuthError::Request { what: "minecraft xbox login", error: e })? + .map_err(|e| AuthError::internal_msg_src("minecraft xbox login", e))? .json().await - .map_err(|e| AuthError::Request { what: "minecraft xbox login (decode)", error: e })?; + .map_err(|e| AuthError::internal_msg_src("minecraft xbox login (decode)", e))?; let now: DateTime = SystemTime::now().into(); @@ -68,9 +69,9 @@ pub async fn owns_the_game(client: &reqwest::Client, token: &str) -> Result Result Result { diff --git a/ozone/src/auth/msa.rs b/ozone/src/auth/msa.rs index ac4a75b..0c84a7a 100644 --- a/ozone/src/auth/msa.rs +++ b/ozone/src/auth/msa.rs @@ -5,7 +5,7 @@ use log::debug; use oauth2::AccessToken; use reqwest::{Method}; use serde::{Deserialize, Serialize}; -use crate::auth::AuthError; +use crate::auth::error::{AuthError, AuthErrorKind}; use crate::auth::types::Token; const XBOX_LIVE_AUTH: &str = "https://user.auth.xboxlive.com/user/authenticate"; @@ -54,8 +54,8 @@ pub async fn xbox_live_login(client: &reqwest::Client, access_token: &AccessToke let res: XboxLiveAuthResponse = super::build_json_request(client, XBOX_LIVE_AUTH, Method::POST).json(&request).send().await .and_then(|r| r.error_for_status()) - .map_err(|e| AuthError::Request { what: "xbox live auth", error: e })? - .json().await.map_err(|e| AuthError::Request { what: "xbox live auth (decode)", error: e })?; + .map_err(|e| AuthError::internal_msg_src("xbox live authentication (connect)", e))? + .json().await.map_err(|e| AuthError::internal_msg_src("xbox live authentication (receive)", e))?; Ok(Token { value: res.token, @@ -130,15 +130,11 @@ impl XSTSAuthSuccessResponse { #[allow(clippy::from_over_into)] impl Into for XSTSAuthErrorResponse { fn into(self) -> AuthError { - AuthError::AuthXError { - // some well-known error values - what: match self.x_err { - 2148916238u64 => "Microsoft account held by a minor outside of a family.", - 2148916233u64 => "Account is not on Xbox.", - _ => "Unknown error." - }, - x_error: self.x_err, - message: self.message + match self.x_err { + 2148916238u64 => AuthErrorKind::TooYoung.into(), + 2148916233u64 => AuthErrorKind::NotOnXbox.into(), + _ => AuthError::raw_msg(AuthErrorKind::Internal, + format!("unknown xsts error: ({}) {}", self.x_err, self.message.as_deref().unwrap_or(""))) } } } @@ -161,9 +157,9 @@ pub async fn xsts_request(client: &reqwest::Client, xbl_token: &str, relying_par let res: XSTSAuthResponse = super::build_json_request(client, XBOX_LIVE_XSTS, Method::POST).json(&req).send().await .and_then(|r| r.error_for_status()) - .map_err(|e| AuthError::Request { what: "xsts", error: e })? + .map_err(|e| AuthError::internal_msg_src("xsts error (connect)", e))? .json().await - .map_err(|e| AuthError::Request { what: "xsts (decode)", error: e })?; + .map_err(|e| AuthError::internal_msg_src("xsts error (receive)", e))?; Ok(res) } diff --git a/ozone/src/auth/types.rs b/ozone/src/auth/types.rs index a4eeba1..fa73c45 100644 --- a/ozone/src/auth/types.rs +++ b/ozone/src/auth/types.rs @@ -1,7 +1,7 @@ pub mod property_map; pub use property_map::PropertyMap; -use std::fmt::{Debug, Formatter}; +use std::fmt::{Debug, Display, Formatter}; use chrono::{DateTime, Utc}; use multimap::MultiMap; use oauth2::RefreshToken; @@ -18,6 +18,7 @@ pub struct Property { } #[derive(Debug, Serialize, Deserialize)] +#[non_exhaustive] pub struct PlayerProfile { #[serde(with = "uuid::serde::simple")] pub id: Uuid, @@ -25,9 +26,6 @@ pub struct PlayerProfile { #[serde(default, skip_serializing_if = "MultiMap::is_empty", with = "property_map")] pub properties: PropertyMap, - - #[serde(skip)] - _private: () } impl PlayerProfile { @@ -38,8 +36,7 @@ impl PlayerProfile { PlayerProfile { id: uuid, name, - properties: Default::default(), - _private: () + properties: Default::default() } } } @@ -145,3 +142,14 @@ pub enum Account { pub struct AuthenticationDatabase { pub users: Vec } + +impl Display for Account { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Account::Dummy(profile) => write!(f, "[dummy] {}", profile.name), + Account::MSA(account) => write!(f, "[msa] {} ({})", + account.gamertag.as_deref().unwrap_or("???"), + account.player_profile.as_ref().map(|p| p.name.as_str()).unwrap_or("???")) + } + } +} -- cgit v1.2.3-70-g09d2