From 6ee7bdb443e5d7cb67d8e22907c0616cbc21d4d8 Mon Sep 17 00:00:00 2001 From: Maciek <108873945+rozwader@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:02:39 +0200 Subject: [PATCH] feat: added status bar for auth states (#954) --- .changeset/invalid_refresh_token_prompt.md | 5 + packages/oneclient_app/src/components/mod.rs | 2 +- .../src/components/status_bar.rs | 111 +++++++++++--- packages/oneclient_app/src/hooks/actions.rs | 24 +++ packages/oneclient_app/src/hooks/mod.rs | 58 +++---- .../oneclient_app/src/hooks/queries/auth.rs | 16 +- .../oneclient_app/src/hooks/queries/mod.rs | 13 +- packages/oneclient_app/src/main.rs | 1 + packages/oneclient_app/src/view/app/debug.rs | 125 ++++++++++++++- .../src/view/app/settings/accounts.rs | 74 +++++++-- packages/oneclient_auth/src/data.rs | 8 + packages/oneclient_auth/src/error.rs | 8 + packages/oneclient_auth/src/msa.rs | 34 ++++- packages/oneclient_auth/src/offline.rs | 1 + packages/oneclient_auth/src/service.rs | 144 +++++++++++++++++- packages/oneclient_auth/src/store.rs | 29 ++++ packages/oneclient_auth/tests/offline.rs | 42 +++++ 17 files changed, 617 insertions(+), 78 deletions(-) create mode 100644 .changeset/invalid_refresh_token_prompt.md diff --git a/.changeset/invalid_refresh_token_prompt.md b/.changeset/invalid_refresh_token_prompt.md new file mode 100644 index 00000000..f4169895 --- /dev/null +++ b/.changeset/invalid_refresh_token_prompt.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +# Added status bar showing up if a player is logged out or he has a stale refresh token \ No newline at end of file diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 0467163e..9050938e 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -91,7 +91,7 @@ pub use markdown::{Markdown, MarkdownStyle}; pub use memory_field::memory_field; pub use microsoft_java_prompt::MicrosoftJavaPromptOverlay; pub(crate) use microsoft_login::login_dialog; -pub use microsoft_login::use_microsoft_login; +pub use microsoft_login::{MicrosoftLogin, use_microsoft_login}; pub use modpack_confirm_popup::ModpackConfirmPopup; pub(crate) use navbar::window_controls; pub use navbar::{AppNavbar, OnboardingNavbar}; diff --git a/packages/oneclient_app/src/components/status_bar.rs b/packages/oneclient_app/src/components/status_bar.rs index 58dd86c4..6e0dec8b 100644 --- a/packages/oneclient_app/src/components/status_bar.rs +++ b/packages/oneclient_app/src/components/status_bar.rs @@ -2,9 +2,12 @@ use std::collections::HashSet; use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation}; use freya::prelude::*; +use freya::router::RouterContext; use oneclient_net::status::{self, ServiceStatus}; use crate::components::{Icon, IconType}; +use crate::hooks::{try_default_account, use_current_account}; +use crate::routes::Route; use crate::theme::colors; const BAR_HEIGHT: f32 = 34.; @@ -18,32 +21,48 @@ const BAR_LAYER: u8 = 14; #[derive(Clone, Copy, PartialEq, Eq, Hash)] enum Issue { NoInternet, + SignedOut, McAuthDown, PolyfrostDown, } +/// What the bar reacts to the network probe plus whether the active account +/// lost its sign-in +#[derive(Clone, PartialEq)] +struct Conditions { + status: ServiceStatus, + signed_out_as: Option, +} + impl Issue { - fn is_active(self, s: &ServiceStatus) -> bool { + fn is_active(self, c: &Conditions) -> bool { + let s = &c.status; match self { Self::NoInternet => !s.online, + Self::SignedOut => c.signed_out_as.is_some(), Self::McAuthDown => s.online && !s.mc_auth_up, Self::PolyfrostDown => s.online && !s.polyfrost_up, } } - fn message(self) -> &'static str { + fn message(self, c: &Conditions) -> String { match self { - Self::NoInternet => "No internet connection.", + Self::NoInternet => "No internet connection.".to_string(), + Self::SignedOut => format!( + "You have been signed out of {}. Sign in again to keep playing.", + c.signed_out_as.as_deref().unwrap_or("your account") + ), Self::McAuthDown => { - "Minecraft authentication servers are unreachable. Logging in may fail." + "Minecraft authentication servers are unreachable. Logging in may fail.".to_string() } - Self::PolyfrostDown => "Polyfrost services are experiencing issues.", + Self::PolyfrostDown => "Polyfrost services are experiencing issues.".to_string(), } } fn icon(self) -> IconType { match self { Self::NoInternet => IconType::Globe01, + Self::SignedOut => IconType::Users01, Self::McAuthDown => IconType::AlertTriangle, Self::PolyfrostDown => IconType::AlertCircle, } @@ -51,7 +70,7 @@ impl Issue { fn background(self) -> Color { match self { - Self::NoInternet => colors::danger(), + Self::NoInternet | Self::SignedOut => colors::danger(), Self::McAuthDown | Self::PolyfrostDown => AMBER, } } @@ -59,13 +78,23 @@ impl Issue { fn closeable(self) -> bool { !matches!(self, Self::NoInternet) } + + /// Where pressing the message takes the user if anywhere + fn route(self) -> Option { + matches!(self, Self::SignedOut).then_some(Route::SettingsAccounts {}) + } } -fn active_issues(s: &ServiceStatus) -> Vec { - [Issue::NoInternet, Issue::McAuthDown, Issue::PolyfrostDown] - .into_iter() - .filter(|i| i.is_active(s)) - .collect() +fn active_issues(c: &Conditions) -> Vec { + [ + Issue::NoInternet, + Issue::SignedOut, + Issue::McAuthDown, + Issue::PolyfrostDown, + ] + .into_iter() + .filter(|i| i.is_active(c)) + .collect() } #[derive(PartialEq)] @@ -75,6 +104,7 @@ impl Component for StatusBar { fn render(&self) -> impl IntoElement { let mut status = use_state(status::current); let mut dismissed = use_state(HashSet::::new); + let current_account = use_current_account(); use_hook(move || { status::request_recheck(); @@ -86,21 +116,35 @@ impl Component for StatusBar { }); }); + let conditions = move || Conditions { + status: *status.read(), + signed_out_as: try_default_account(¤t_account) + .filter(|a| a.needs_sign_in()) + .map(|a| a.username), + }; + + // A dismissal lasts only while its issue does so the next sign-out + // shows the bar again use_side_effect(move || { - let s = *status.read(); + let c = conditions(); let cur = dismissed.peek().clone(); - let next: HashSet = cur.iter().copied().filter(|i| i.is_active(&s)).collect(); + let next: HashSet = cur.iter().copied().filter(|i| i.is_active(&c)).collect(); if next != cur { dismissed.set(next); } }); - let s = *status.read(); + let c = conditions(); let dset = dismissed.read(); - let visible = active_issues(&s).into_iter().find(|i| !dset.contains(i)); + let visible = active_issues(&c).into_iter().find(|i| !dset.contains(i)); match visible { - Some(issue) => StatusBanner { issue, dismissed }.into_element(), + Some(issue) => StatusBanner { + issue, + text: issue.message(&c), + dismissed, + } + .into_element(), None => rect().into_element(), } } @@ -109,6 +153,7 @@ impl Component for StatusBar { #[derive(PartialEq)] struct StatusBanner { issue: Issue, + text: String, dismissed: State>, } @@ -153,6 +198,35 @@ impl Component for StatusBanner { // to the top of the content box inside the bar's padding let leading = rect().width(Size::flex(1.0)).height(Size::fill()); + let mut action_hover = use_state(|| false); + + let action = issue.route().map(|route| { + rect() + .center() + .height(Size::px(22.)) + .padding(Gaps::new_symmetric(0., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(if *action_hover.read() { + Color::WHITE.with_a(46) + } else { + Color::WHITE.with_a(26) + }) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| action_hover.set(true)) + .on_pointer_leave(move |_| action_hover.set(false)) + .on_press(move |_| { + let _ = RouterContext::get().push(route.clone()); + }) + .child( + label() + .text("Sign in") + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(Color::WHITE), + ) + .into_element() + }); + let message = rect() .horizontal() .height(Size::fill()) @@ -162,12 +236,13 @@ impl Component for StatusBanner { .child(Icon::new(issue.icon()).size(15.).color(Color::WHITE)) .child( label() - .text(issue.message()) + .text(self.text.clone()) .font_size(12.) .font_weight(FontWeight::MEDIUM) .max_lines(1) .color(Color::WHITE), - ); + ) + .maybe_child(action); let trailing = rect() .width(Size::flex(1.0)) diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index eb0541c5..cfa8eddb 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -1503,6 +1503,24 @@ impl Actions { /// `syncing_bundles` gates every launch button so the readiness check must /// happen before the flag is raised not inside the task + /// Lets the signed-out bar show at startup instead of on the first launch + pub fn check_sessions() { + let state = match launcher::state() { + Ok(state) => state, + Err(err) => { + tracing::error!("session check skipped, launcher not ready: {err:#}"); + return; + } + }; + + spawn_forever(async move { + let signed_out = off_ui(async move { state.auth.check_expired_sessions().await }).await; + if signed_out { + super::invalidate_auth_queries(None).await; + } + }); + } + pub fn sync_bundles(&self) { let state = match launcher::state() { Ok(state) => state, @@ -2078,6 +2096,12 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) { return; }; + // The renewal may have just marked the account signed out the accounts + // page and the status bar must pick that up while the game still starts + if account.needs_sign_in() { + super::invalidate_auth_queries(None).await; + } + crate::microsoft_java::offer_for_pinned_cluster(actions, cluster_id).await; let content = state.services.content(); diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index f4fb35d2..c529c335 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -43,35 +43,35 @@ pub use queries::{ accounts_have_microsoft, available_bundles, bundle_overrides_map, bundles_with_status_items, category_list, changelog_entries, changelog_error, changelog_is_loading, cluster_art_url, cluster_content_items, content_type_for_slug, disable_warnings, game_versions, - has_migration_data, invalidate_cluster_content_queries, invalidate_cluster_queries, - invalidate_java_queries, invalidate_leftovers_queries, invalidate_logs_queries, - invalidate_profile_queries, invalidate_screenshots_queries, invalidate_storage_queries, - java_majors, java_runtimes, latest_changelog_version, loaded_image, loader_versions, - login_code_already_handled, migration_detection, mutation_error, mutation_is_pending, - mutation_is_running, mutation_ok, onboarding_bundles_items, package_meta_batch, - package_updates, pick_version_metadata, project_detail, provider_versions, query_error, - query_is_busy, query_is_loading, refresh_version_art_gallery, reset_login_code_dedup, - resolve_art_url, search_items, search_pending, search_total, settled_or_loading, stale_hashes, - terms_document, terms_error, terms_is_loading, try_account, try_accounts, - try_cluster_analytics, try_cluster_logs, try_cluster_screenshots, try_default_account, - try_game_profile, try_global_analytics, try_leftovers, try_log_content, try_storage_report, - use_account, use_accounts, use_add_microsoft_account, use_add_offline_account, - use_available_bundles, use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates, - use_bundles_with_status, use_cached_image, use_cancel_microsoft_login, use_changelog, - use_cluster, use_cluster_analytics, use_cluster_content, use_cluster_logs, - use_cluster_mutation, use_cluster_profile, use_cluster_screenshots, use_cluster_settings, - use_clusters, use_current_account, use_default_account, use_disable_warnings, - use_discard_leftovers, use_finish_microsoft_login, use_game_profile, use_game_versions, - use_global_analytics, use_java_majors, use_java_runtimes, use_leftovers, use_loader_versions, - use_local_image, use_log_action, use_log_content, use_migratable_routes, use_migration, - use_mods_folder_sync, use_named_profiles, use_onboarding_bundles, use_package_categories, - use_package_meta_batch, use_package_project, use_package_search, use_package_updates, - use_package_versions_when, use_player_profile, use_player_skin, use_provider_versions, - use_refresh_account, use_refresh_all_accounts, use_remove_account, use_screenshot_action, - use_screenshot_folder_watch, use_set_default_account, use_shadowed_mods, use_storage_action, - use_storage_report, use_terms, use_upload_log, use_version_art, use_version_art_gallery, - use_version_loaders, use_version_metadata, use_versions, version_list, versions_metadata, - versions_total, + has_migration_data, invalidate_auth_queries, invalidate_cluster_content_queries, + invalidate_cluster_queries, invalidate_java_queries, invalidate_leftovers_queries, + invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries, + invalidate_storage_queries, java_majors, java_runtimes, latest_changelog_version, loaded_image, + loader_versions, login_code_already_handled, migration_detection, mutation_error, + mutation_is_pending, mutation_is_running, mutation_ok, onboarding_bundles_items, + package_meta_batch, package_updates, pick_version_metadata, project_detail, provider_versions, + query_error, query_is_busy, query_is_loading, refresh_version_art_gallery, + reset_login_code_dedup, resolve_art_url, search_items, search_pending, search_total, + settled_or_loading, stale_hashes, terms_document, terms_error, terms_is_loading, try_account, + try_accounts, try_cluster_analytics, try_cluster_logs, try_cluster_screenshots, + try_default_account, try_game_profile, try_global_analytics, try_leftovers, try_log_content, + try_storage_report, use_account, use_accounts, use_add_microsoft_account, + use_add_offline_account, use_available_bundles, use_begin_microsoft_login, + use_bundle_overrides, use_bundle_updates, use_bundles_with_status, use_cached_image, + use_cancel_microsoft_login, use_changelog, use_cluster, use_cluster_analytics, + use_cluster_content, use_cluster_logs, use_cluster_mutation, use_cluster_profile, + use_cluster_screenshots, use_cluster_settings, use_clusters, use_current_account, + use_default_account, use_disable_warnings, use_discard_leftovers, use_finish_microsoft_login, + use_game_profile, use_game_versions, use_global_analytics, use_java_majors, use_java_runtimes, + use_leftovers, use_loader_versions, use_local_image, use_log_action, use_log_content, + use_migratable_routes, use_migration, use_mods_folder_sync, use_named_profiles, + use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project, + use_package_search, use_package_updates, use_package_versions_when, use_player_profile, + use_player_skin, use_provider_versions, use_refresh_account, use_refresh_all_accounts, + use_remove_account, use_screenshot_action, use_screenshot_folder_watch, + use_set_default_account, use_shadowed_mods, use_storage_action, use_storage_report, use_terms, + use_upload_log, use_version_art, use_version_art_gallery, use_version_loaders, + use_version_metadata, use_versions, version_list, versions_metadata, versions_total, }; pub use queries::{ add_world_datapacks, delete_world, delete_world_datapack, invalidate_world_contents, diff --git a/packages/oneclient_app/src/hooks/queries/auth.rs b/packages/oneclient_app/src/hooks/queries/auth.rs index d4739c0d..735005e3 100644 --- a/packages/oneclient_app/src/hooks/queries/auth.rs +++ b/packages/oneclient_app/src/hooks/queries/auth.rs @@ -113,7 +113,7 @@ pub fn try_account(query: &UseQuery) -> Option { super::state::settled_or_loading(query).flatten() } -async fn invalidate_auth_queries(account_id: Option) { +pub async fn invalidate_auth_queries(account_id: Option) { QueriesStorage::::invalidate_matching(ListAccountsKeys).await; for fallback in [false, true] { QueriesStorage::::invalidate_matching(DefaultAccountKeys { fallback }) @@ -307,10 +307,10 @@ impl MutationCapability for RefreshAccountMutation { .await?) } - async fn on_settled(&self, keys: &Self::Keys, result: &Result) { - if result.is_ok() { - invalidate_auth_queries(Some(keys.id)).await; - } + /// A failure can still change the account a rejected token marks it + /// signed out + async fn on_settled(&self, keys: &Self::Keys, _result: &Result) { + invalidate_auth_queries(Some(keys.id)).await; } } @@ -329,10 +329,8 @@ impl MutationCapability for RefreshAllAccountsMutation { .await?) } - async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { - if result.is_ok() { - invalidate_auth_queries(None).await; - } + async fn on_settled(&self, _keys: &Self::Keys, _result: &Result) { + invalidate_auth_queries(None).await; } } diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index c9eabba8..27e788f9 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -42,12 +42,13 @@ pub use auth::{ AddOfflineAccountKeys, BeginMicrosoftLoginMutation, CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, FinishMicrosoftLoginMutation, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys, UseRefreshAccount, UseRemoveAccount, - UseSetDefaultAccount, accounts_have_microsoft, login_code_already_handled, mutation_error, - mutation_is_pending, mutation_is_running, mutation_ok, reset_login_code_dedup, try_account, - try_accounts, try_default_account, use_account, use_accounts, use_add_microsoft_account, - use_add_offline_account, use_begin_microsoft_login, use_cancel_microsoft_login, - use_current_account, use_default_account, use_finish_microsoft_login, use_refresh_account, - use_refresh_all_accounts, use_remove_account, use_set_default_account, + UseSetDefaultAccount, accounts_have_microsoft, invalidate_auth_queries, + login_code_already_handled, mutation_error, mutation_is_pending, mutation_is_running, + mutation_ok, reset_login_code_dedup, try_account, try_accounts, try_default_account, + use_account, use_accounts, use_add_microsoft_account, use_add_offline_account, + use_begin_microsoft_login, use_cancel_microsoft_login, use_current_account, + use_default_account, use_finish_microsoft_login, use_refresh_account, use_refresh_all_accounts, + use_remove_account, use_set_default_account, }; pub use bundles::{ AvailableBundlesQuery, ClusterBundles, OnboardingBundlesQuery, available_bundles, diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs index ff32df5f..1fbf71b2 100644 --- a/packages/oneclient_app/src/main.rs +++ b/packages/oneclient_app/src/main.rs @@ -61,6 +61,7 @@ impl App for OneClientApp { // it early leaves `syncing_bundles` stuck disabling every launch button Ok(()) => { startup.sync_bundles(); + Actions::check_sessions(); microsoft_java::spawn_auto_install(); } Err(err) => { diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index 09687335..0cc91e84 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -9,7 +9,10 @@ use oneclient_net::status::{self, ServiceStatus}; use crate::Actions; use crate::components::{Button, Dropdown, Icon, IconType, TextInput, login_dialog, toggle}; -use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch}; +use crate::hooks::{ + invalidate_auth_queries, settled_or_loading, try_default_account, use_active_cluster_id, + use_clusters, use_current_account, use_dispatch, +}; use crate::notifications::{ ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind, OptionalModsGroup, @@ -97,6 +100,11 @@ impl Component for Debug { vec![AuthGuidancePreview.into_element()], )) .child(divider()) + .child(section( + "Session Expiry", + vec![SessionExpirySimulator.into_element()], + )) + .child(divider()) .child(section( "Corruption Simulator", vec![CorruptionSimulator.into_element()], @@ -936,6 +944,121 @@ impl Component for AuthGuidancePreview { } } +#[derive(PartialEq)] +struct SessionExpirySimulator; + +#[derive(Clone, Copy)] +enum ExpiryKind { + /// Skips the network and flips the account straight to signed out + SignedOut, + /// Leaves detection to Microsoft on the next launch + RejectedToken, +} + +impl Component for SessionExpirySimulator { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let current = use_current_account(); + + let account = try_default_account(¤t).filter(|a| a.is_microsoft()); + let target = account.as_ref().map(|a| (a.id, a.username.clone())); + let usable = account.as_ref().is_some_and(|a| !a.needs_sign_in()); + + let description = match &account { + Some(a) if a.needs_sign_in() => format!("{} is already signed out.", a.username), + Some(a) => format!("Acts on the active account, {}.", a.username), + None => "The active account must be a Microsoft account.".to_string(), + }; + + let button = |text: &'static str, icon: IconType, kind: ExpiryKind| { + let dispatch = dispatch.clone(); + let target = target.clone(); + Button::new() + .danger() + .enabled(usable) + .child(Icon::new(icon).size(16.)) + .text(text) + .on_press(move |_| { + if let Some((id, username)) = target.clone() { + run_expiry(&dispatch, kind, id, username); + } + }) + }; + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text(description) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(8.) + .child(button( + "Force signed out", + IconType::AlertTriangle, + ExpiryKind::SignedOut, + )) + .child(button( + "Invalidate refresh token", + IconType::RefreshCw01, + ExpiryKind::RejectedToken, + )), + ) + .child( + label() + .text("Invalidate keeps the account looking fine until the next launch, where Microsoft answers invalid_grant.") + .font_size(11.) + .color(colors::fg_secondary()), + ) + .into_element() + } +} + +fn run_expiry(dispatch: &crate::Actions, kind: ExpiryKind, id: uuid::Uuid, username: String) { + let dispatch = dispatch.clone(); + spawn(async move { + let result = match crate::launcher::state() { + Ok(state) => match kind { + ExpiryKind::SignedOut => state.auth.mark_signed_out(id).await, + ExpiryKind::RejectedToken => state.auth.corrupt_refresh_token(id).await, + } + .map_err(|err| err.to_string()), + Err(err) => Err(err.to_string()), + }; + invalidate_auth_queries(Some(id)).await; + + match result { + Ok(()) => { + let body = match kind { + ExpiryKind::SignedOut => format!("{username} is now signed out"), + ExpiryKind::RejectedToken => { + format!("{username} will be rejected on the next launch") + } + }; + dispatch + .notify("Session expiry simulated") + .body(body) + .icon(IconType::AlertTriangle) + .send(); + } + Err(err) => { + dispatch + .notify("Simulation failed") + .body(err) + .error() + .send(); + } + } + }); +} + /// Presets are grouped by which layer should catch them same-length corruption only the hashing pass finds truncation the launch-time size check #[derive(PartialEq)] struct CorruptionSimulator; diff --git a/packages/oneclient_app/src/view/app/settings/accounts.rs b/packages/oneclient_app/src/view/app/settings/accounts.rs index 5f75a4f6..3434360d 100644 --- a/packages/oneclient_app/src/view/app/settings/accounts.rs +++ b/packages/oneclient_app/src/view/app/settings/accounts.rs @@ -7,7 +7,8 @@ use uuid::Uuid; use super::{section_header, settings_page}; use crate::components::{ - Avatar, Button, Icon, IconType, OverlayPopup, PlayerModel, TextInput, use_microsoft_login, + Avatar, Button, Icon, IconType, MicrosoftLogin, OverlayPopup, PlayerModel, TextInput, + use_microsoft_login, }; use crate::hooks::{ AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys, @@ -87,10 +88,12 @@ impl Component for SettingsAccounts { username: account.username.clone(), kind: account.kind, expires: account.expires, + signed_out: account.needs_sign_in(), is_default: Some(account.id) == default_id, set_default, remove, refresh, + sign_in: msa.clone(), } .into_element() }) @@ -136,6 +139,10 @@ fn hero( on_add_microsoft: impl FnMut(Event) + 'static, ) -> impl IntoElement { let (name, subtitle) = match &account { + Some(account) if account.needs_sign_in() => ( + account.username.clone(), + "Signed out. Sign in again to play", + ), Some(account) => (account.username.clone(), kind_label(account.kind)), None => ( "No active account".to_string(), @@ -396,15 +403,20 @@ const REFRESH_SPIN_TIME: u64 = 800; const REFRESHING_OPACITY: f32 = 0.7; +/// Only the identity half fades the sign-in and remove buttons stay crisp +const SIGNED_OUT_OPACITY: f32 = 0.45; + struct AccountRow { id: Uuid, username: String, kind: AccountKind, expires: DateTime, + signed_out: bool, is_default: bool, set_default: crate::hooks::UseSetDefaultAccount, remove: crate::hooks::UseRemoveAccount, refresh: crate::hooks::UseRefreshAccount, + sign_in: MicrosoftLogin, } impl PartialEq for AccountRow { @@ -413,7 +425,9 @@ impl PartialEq for AccountRow { && self.username == other.username && self.kind == other.kind && self.expires == other.expires + && self.signed_out == other.signed_out && self.is_default == other.is_default + && self.sign_in.pending == other.sign_in.pending } } @@ -430,7 +444,11 @@ impl Component for AccountRow { let refresh = self.refresh; let is_microsoft = self.kind == AccountKind::Microsoft; - let expired = is_microsoft && self.expires <= Utc::now(); + let signed_out = is_microsoft && self.signed_out; + // A signed-out entry has no token left to call expired + let expired = is_microsoft && !signed_out && self.expires <= Utc::now(); + let identity_opacity = if signed_out { SIGNED_OUT_OPACITY } else { 1. }; + let sign_in = self.sign_in.clone(); let mut refreshing = use_state(|| false); let is_refreshing = *refreshing.read(); @@ -457,7 +475,9 @@ impl Component for AccountRow { 0. }; - let border_color = if expired { + let border_color = if signed_out { + colors::component_border() + } else if expired { colors::danger() } else if is_default { colors::brand() @@ -481,19 +501,22 @@ impl Component for AccountRow { 1. }) .a11y_role(AccessibilityRole::Button) - .maybe(!is_default, |el| { + .maybe(!is_default && !signed_out, |el| { el.on_press(move |_| set_default.mutate(SetDefaultAccountKeys { id: Some(id) })) }) .child( - Avatar::new(id.to_string()) - .width(Size::px(AVATAR_SIZE_PX)) - .height(Size::px(AVATAR_SIZE_PX)), + rect().opacity(identity_opacity).child( + Avatar::new(id.to_string()) + .width(Size::px(AVATAR_SIZE_PX)) + .height(Size::px(AVATAR_SIZE_PX)), + ), ) .child( rect() .vertical() .width(Size::flex(1.0)) .spacing(4.) + .opacity(identity_opacity) .child( rect() .horizontal() @@ -508,18 +531,37 @@ impl Component for AccountRow { .color(colors::fg_primary()), ) .maybe_child(is_default.then(default_badge)) - .maybe_child(expired.then(expired_badge)), + .maybe_child(expired.then(expired_badge)) + .maybe_child(signed_out.then(signed_out_badge)), ) // The kind rides with the id a third badge and a full uuid do not both fit .child( label() - .text(format!("{} · {id}", kind_label(self.kind))) + .text(if signed_out { + "Session expired. Sign in again to use this account".to_string() + } else { + format!("{} · {id}", kind_label(self.kind)) + }) .font_size(11.) .max_lines(1) .color(colors::fg_secondary()), ), ) - .maybe_child(is_microsoft.then(|| { + .maybe_child(signed_out.then(|| { + let pending = sign_in.pending; + Button::new() + .secondary() + .enabled(!pending) + .tooltip("Sign in to this Microsoft account again") + .on_press(move |e: Event| { + e.stop_propagation(); + sign_in.start(); + }) + .child(Icon::new(IconType::Globe01).size(16.)) + .text(if pending { "Signing in..." } else { "Sign in" }) + .into_element() + })) + .maybe_child((is_microsoft && !signed_out).then(|| { Button::new() .ghost() .icon() @@ -610,6 +652,18 @@ fn expired_badge() -> impl IntoElement { ) } +fn signed_out_badge() -> impl IntoElement { + badge( + Icon::new(IconType::AlertTriangle) + .size(12.) + .color(colors::fg_secondary()) + .into_element(), + "Signed out".to_string(), + colors::component_border(), + colors::fg_secondary(), + ) +} + fn badge(icon: impl IntoElement, text: String, border: Color, fg: Color) -> impl IntoElement { rect() .horizontal() diff --git a/packages/oneclient_auth/src/data.rs b/packages/oneclient_auth/src/data.rs index 63beb128..4b37d66e 100644 --- a/packages/oneclient_auth/src/data.rs +++ b/packages/oneclient_auth/src/data.rs @@ -18,6 +18,10 @@ pub struct MinecraftAccount { pub expires: DateTime, #[serde(default = "default_account_kind")] pub kind: AccountKind, + /// Microsoft rejected the refresh token the entry stays listed but cannot + /// launch until the user signs in to it again + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub signed_out: bool, } fn default_account_kind() -> AccountKind { @@ -33,6 +37,10 @@ impl MinecraftAccount { self.kind == AccountKind::Offline } + pub fn needs_sign_in(&self) -> bool { + self.is_microsoft() && self.signed_out + } + pub fn is_expired(&self) -> bool { self.expires <= Utc::now() + chrono::TimeDelta::seconds(60) } diff --git a/packages/oneclient_auth/src/error.rs b/packages/oneclient_auth/src/error.rs index a3014884..1ebf4d76 100644 --- a/packages/oneclient_auth/src/error.rs +++ b/packages/oneclient_auth/src/error.rs @@ -69,6 +69,9 @@ pub enum MinecraftAuthError { message: String, redirect: Option, }, + /// Retrying cannot fix this only a fresh interactive sign-in can + #[error("Microsoft rejected the saved sign-in ({error})")] + RefreshTokenRevoked { error: String }, #[error( "The sign-in service returned an error (HTTP {status_code}) during step {step:?}. Please wait a moment and try again." )] @@ -131,6 +134,11 @@ pub enum AuthError { #[error("invalid offline username: {reason}")] InvalidOfflineUsername { reason: String }, + #[error( + "Your Microsoft sign-in for {username} has expired. Sign in to it again in Settings > Accounts." + )] + SessionExpired { username: String }, + #[error("account {0} is not registered")] AccountNotFound(uuid::Uuid), diff --git a/packages/oneclient_auth/src/msa.rs b/packages/oneclient_auth/src/msa.rs index b94a721e..5c72b793 100644 --- a/packages/oneclient_auth/src/msa.rs +++ b/packages/oneclient_auth/src/msa.rs @@ -482,9 +482,30 @@ async fn refresh_msa_token( source, })?; - parse_json_response(res, MinecraftAuthStep::RefreshToken) + let status = res.status(); + let text = res + .text() .await - .map(MsaToken::from_response) + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::RefreshToken, + source, + })?; + + if status.is_client_error() + && let Ok(err) = serde_json::from_str::(&text) + && is_revoked_refresh_error(&err.error) + { + tracing::warn!(error = %err.error, description = ?err.error_description, "Microsoft rejected the refresh token"); + return Err(MinecraftAuthError::RefreshTokenRevoked { error: err.error }); + } + + parse_json_text(status, text, MinecraftAuthStep::RefreshToken).map(MsaToken::from_response) +} + +/// `invalid_grant` covers expired revoked and password-changed tokens +/// `interaction_required` is MFA or consent neither can be answered silently +fn is_revoked_refresh_error(error: &str) -> bool { + matches!(error, "invalid_grant" | "interaction_required") } struct MsaToken { @@ -541,6 +562,7 @@ async fn account_from_msa_token( #[allow(clippy::cast_possible_wrap)] expires: msa.obtained_at + chrono::TimeDelta::seconds(msa.expires_in as i64), kind: AccountKind::Microsoft, + signed_out: false, }) } @@ -779,6 +801,14 @@ async fn parse_json_response Deserialize<'de>>( .await .map_err(|source| MinecraftAuthError::RequestError { step, source })?; + parse_json_text(status, text, step) +} + +fn parse_json_text Deserialize<'de>>( + status: reqwest::StatusCode, + text: String, + step: MinecraftAuthStep, +) -> Result { if !status.is_success() { tracing::error!(sentry = false, ?step, %status, body = %text, "MSA endpoint returned a non-success status"); } diff --git a/packages/oneclient_auth/src/offline.rs b/packages/oneclient_auth/src/offline.rs index fe292200..331b57db 100644 --- a/packages/oneclient_auth/src/offline.rs +++ b/packages/oneclient_auth/src/offline.rs @@ -48,5 +48,6 @@ pub fn offline_account(username: String) -> MinecraftAccount { refresh_token: String::new(), expires: Utc::now() + Duration::days(3650), kind: AccountKind::Offline, + signed_out: false, } } diff --git a/packages/oneclient_auth/src/service.rs b/packages/oneclient_auth/src/service.rs index e6502821..2ce183a2 100644 --- a/packages/oneclient_auth/src/service.rs +++ b/packages/oneclient_auth/src/service.rs @@ -222,6 +222,23 @@ impl AuthService { Ok(()) } + /// Debug aid puts the account straight into the state a rejected refresh + /// token leaves behind + #[tracing::instrument(skip(self), fields(%id))] + pub async fn mark_signed_out(&self, id: Uuid) -> AuthResult<()> { + let guard = self.refresh_guard(id); + let _serialised = guard.lock().await; + self.store.lock().await.mark_signed_out(id).await + } + + /// Debug aid see [`CredentialsStore::corrupt_refresh_token`] + #[tracing::instrument(skip(self), fields(%id))] + pub async fn corrupt_refresh_token(&self, id: Uuid) -> AuthResult<()> { + let guard = self.refresh_guard(id); + let _serialised = guard.lock().await; + self.store.lock().await.corrupt_refresh_token(id).await + } + pub async fn has_microsoft_account(&self) -> bool { self.store.lock().await.has_microsoft_account() } @@ -250,6 +267,11 @@ impl AuthService { #[tracing::instrument(level = "debug", skip(self), fields(%id))] async fn renew_token(&self, id: Uuid, force: bool) -> AuthResult { let existing = self.account_snapshot(id).await?; + if existing.needs_sign_in() { + return Err(AuthError::SessionExpired { + username: existing.username, + }); + } if !existing.is_microsoft() || (!force && !existing.is_expired()) { return Ok(existing); } @@ -259,6 +281,11 @@ impl AuthService { // Re-read under the guard whoever held it may have just refreshed let existing = self.account_snapshot(id).await?; + if existing.needs_sign_in() { + return Err(AuthError::SessionExpired { + username: existing.username, + }); + } if !existing.is_microsoft() || (!force && !existing.is_expired()) { return Ok(existing); } @@ -273,6 +300,13 @@ impl AuthService { .await?; Ok(refreshed) } + Err(MinecraftAuthError::RefreshTokenRevoked { .. }) => { + tracing::warn!(username = %existing.username, "refresh token rejected, marking account signed out"); + self.store.lock().await.mark_signed_out(id).await?; + Err(AuthError::SessionExpired { + username: existing.username, + }) + } Err(err) => { let err = AuthError::from(err); if store::is_transient_auth_error(&err) { @@ -290,21 +324,64 @@ impl AuthService { self.renew_token(id, true).await } + /// A signed-out account is skipped rather than failing the whole batch it + /// already shows as needing a sign-in #[tracing::instrument(level = "debug", skip_all)] pub async fn refresh_all_accounts(&self) -> AuthResult> { let ids: Vec = self.store.lock().await.users.keys().copied().collect(); let mut refreshed = Vec::with_capacity(ids.len()); for id in ids { - refreshed.push(self.renew_token(id, true).await?); + match self.renew_token(id, true).await { + Ok(account) => refreshed.push(account), + Err(AuthError::SessionExpired { .. }) => {} + Err(err) => return Err(err), + } } Ok(refreshed) } + /// A revoked refresh token is only discovered by trying it doing so at + /// startup surfaces the sign-out before the user presses play + /// Only lapsed tokens are tried a live one needs no renewal yet + /// Returns whether any account was newly marked signed out + #[tracing::instrument(level = "debug", skip_all)] + pub async fn check_expired_sessions(&self) -> bool { + let ids: Vec = self + .store + .lock() + .await + .users + .values() + .filter(|a| a.is_microsoft() && !a.needs_sign_in() && a.is_expired()) + .map(|a| a.id) + .collect(); + + let mut signed_out = false; + for id in ids { + match self.renew_token(id, false).await { + Ok(_) => {} + Err(AuthError::SessionExpired { .. }) => signed_out = true, + Err(err) => tracing::warn!(%id, "startup session check failed: {err}"), + } + } + signed_out + } + #[tracing::instrument(level = "debug", skip(self), fields(%id))] pub async fn account_for_launch(&self, id: Uuid) -> AuthResult { - let account = self.renew_token(id, false).await?; + let account = match self.renew_token(id, false).await { + Ok(account) => account, + // A signed-out account still plays under its saved name and uuid + // with an empty token like an offline account singleplayer works + // and the UI keeps asking for a sign-in + Err(AuthError::SessionExpired { username }) => { + tracing::info!(%username, "launching signed-out account without a token"); + self.account_snapshot(id).await? + } + Err(err) => return Err(err), + }; if account.is_offline() && !self.has_microsoft_account().await { return Err(AuthError::OfflineRequiresMicrosoft); @@ -429,4 +506,67 @@ mod tests { )) )); } + + fn signed_out_service() -> (AuthService, Uuid) { + let mut account = crate::offline::offline_account("MsaUser".to_string()); + account.kind = crate::data::AccountKind::Microsoft; + account.signed_out = true; + let id = account.id; + + let mut store = CredentialsStore::default(); + store.users.insert(id, account); + store.default_user = Some(id); + + let (events, _rx) = EventBus::channel(); + let net = RequestClient::new(NetConfig::default()).expect("net client"); + (AuthService::with_store(store, net, events), id) + } + + #[tokio::test] + async fn a_signed_out_account_still_launches_without_touching_the_network() { + let (service, id) = signed_out_service(); + + let account = service + .default_account_for_launch() + .await + .expect("a signed-out account must still launch") + .expect("the only account is the default"); + assert_eq!(account.id, id); + assert!(account.needs_sign_in()); + assert!(account.access_token.is_empty()); + } + + #[tokio::test] + async fn refresh_all_skips_signed_out_accounts() { + let (service, id) = signed_out_service(); + + let refreshed = service.refresh_all_accounts().await.unwrap(); + assert!(refreshed.is_empty()); + assert!( + service.get_account(id).await.is_some(), + "the entry must stay listed" + ); + } + + #[tokio::test] + async fn the_startup_check_leaves_live_and_signed_out_accounts_alone() { + let (service, signed_out_id) = signed_out_service(); + + let mut live = crate::offline::offline_account("LiveUser".to_string()); + live.kind = crate::data::AccountKind::Microsoft; + live.expires = chrono::Utc::now() + chrono::TimeDelta::hours(12); + let live_id = live.id; + service.store.lock().await.users.insert(live_id, live); + + // Neither account qualifies so this must return without a request + assert!(!service.check_expired_sessions().await); + assert!(!service.get_account(live_id).await.unwrap().needs_sign_in()); + assert!( + service + .get_account(signed_out_id) + .await + .unwrap() + .needs_sign_in() + ); + } } diff --git a/packages/oneclient_auth/src/store.rs b/packages/oneclient_auth/src/store.rs index c6951a16..65f4f10e 100644 --- a/packages/oneclient_auth/src/store.rs +++ b/packages/oneclient_auth/src/store.rs @@ -118,6 +118,35 @@ impl CredentialsStore { Ok(()) } + /// Drops the dead tokens but keeps the entry and its default slot so the UI + /// can offer a sign-in for this exact account + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + pub async fn mark_signed_out(&mut self, id: Uuid) -> AuthResult<()> { + let Some(account) = self.users.get_mut(&id) else { + return Err(AuthError::AccountNotFound(id)); + }; + + account.signed_out = true; + account.access_token.clear(); + account.refresh_token.clear(); + self.save().await?; + Ok(()) + } + + /// Debug aid swaps in a token Microsoft will reject and backdates expiry so + /// the next launch walks the real `invalid_grant` path + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + pub async fn corrupt_refresh_token(&mut self, id: Uuid) -> AuthResult<()> { + let Some(account) = self.users.get_mut(&id) else { + return Err(AuthError::AccountNotFound(id)); + }; + + account.refresh_token = "oneclient-debug-invalid-refresh-token".to_string(); + account.expires = chrono::DateTime::UNIX_EPOCH; + self.save().await?; + Ok(()) + } + #[tracing::instrument(level = "debug", skip(self), fields(%id))] pub async fn remove_account(&mut self, id: Uuid) -> AuthResult> { let removed = self.users.remove(&id); diff --git a/packages/oneclient_auth/tests/offline.rs b/packages/oneclient_auth/tests/offline.rs index 21325eec..c5b2bf98 100644 --- a/packages/oneclient_auth/tests/offline.rs +++ b/packages/oneclient_auth/tests/offline.rs @@ -105,3 +105,45 @@ async fn default_account_returns_expired_token_without_refreshing() { assert_eq!(account.id, msa_id); assert!(account.is_expired()); } + +#[tokio::test] +async fn signed_out_account_stays_listed_and_default() { + isolate_launcher_dir(); + + let mut store = CredentialsStore::default(); + let msa = fake_microsoft_account("MsaUser"); + let msa_id = msa.id; + store.users.insert(msa_id, msa); + store.default_user = Some(msa_id); + + store.mark_signed_out(msa_id).await.unwrap(); + + let reloaded = CredentialsStore::load().await.unwrap(); + let account = reloaded.get_account(msa_id).expect("entry must survive"); + assert!(account.needs_sign_in()); + assert!( + account.refresh_token.is_empty(), + "the dead token must not be kept" + ); + assert!(account.access_token.is_empty()); + assert_eq!(reloaded.default_user, Some(msa_id)); +} + +#[test] +fn accounts_saved_before_the_flag_existed_load_as_signed_in() { + let account: oneclient_auth::MinecraftAccount = serde_json::from_str(&format!( + r#"{{"id":"{}","username":"MsaUser","access_token":"a","refresh_token":"r","expires":"2026-01-01T00:00:00Z","kind":"microsoft"}}"#, + Uuid::new_v4() + )) + .unwrap(); + + assert!(!account.needs_sign_in()); +} + +#[test] +fn offline_accounts_never_need_sign_in() { + let mut account = offline_account("Steve".into()); + account.signed_out = true; + + assert!(!account.needs_sign_in()); +}