use std::collections::HashMap; use uuid::Uuid; use oneclient_common::paths; use oneclient_events::EventBus; use crate::data::{AccountKind, MinecraftAccount}; use crate::error::{AuthError, AuthResult}; use crate::offline::{offline_account, validate_offline_username}; #[derive(Debug, serde::Serialize, serde::Deserialize, Default)] pub struct CredentialsStore { pub users: HashMap, pub default_user: Option, } impl CredentialsStore { #[tracing::instrument(level = "debug")] pub async fn load() -> AuthResult { let path = paths::auth_file()?; if !path.exists() { return Ok(Self::default()); } match polyio::read_json(&path).await { Ok(store) => Ok(store), Err(err) => { tracing::warn!("failed to read auth file: {err}"); Ok(Self::default()) } } } #[tracing::instrument(level = "debug", skip_all)] pub async fn save(&self) -> AuthResult<()> { let path = paths::auth_file()?; polyio::write_json_atomic(&path, self).await?; Ok(()) } pub fn has_microsoft_account(&self) -> bool { self.users .values() .any(|account| account.kind == AccountKind::Microsoft) } pub fn list_accounts(&self) -> Vec { self.users.values().cloned().collect() } pub fn get_account(&self, id: Uuid) -> Option<&MinecraftAccount> { self.users.get(&id) } #[tracing::instrument(level = "debug", skip_all, fields(username = %account.username))] pub async fn commit_account( &mut self, account: MinecraftAccount, events: &EventBus, ) -> AuthResult { self.users.insert(account.id, account.clone()); if self.default_user.is_none() { self.default_user = Some(account.id); } self.save().await?; tracing::debug!("committed account to credentials store"); events .notify("Account added") .body(format!("Signed in as {}", account.username)) .send(); Ok(account) } pub fn add_offline_account(&mut self, username: String) -> AuthResult { let account = self.insert_offline_account(username)?; Ok(account) } #[tracing::instrument(level = "debug", skip(self), fields(username = %username))] pub async fn add_offline_account_and_save( &mut self, username: String, ) -> AuthResult { let account = self.insert_offline_account(username)?; self.save().await?; Ok(account) } fn insert_offline_account(&mut self, username: String) -> AuthResult { if !self.has_microsoft_account() { return Err(AuthError::OfflineRequiresMicrosoft); } validate_offline_username(&username)?; if self .users .values() .any(|u| u.username.eq_ignore_ascii_case(&username)) { return Err(AuthError::DuplicateUsername { username }); } let account = offline_account(username); self.users.insert(account.id, account.clone()); Ok(account) } #[tracing::instrument(level = "debug", skip_all, fields(%account.id))] pub async fn commit_refreshed_account(&mut self, account: MinecraftAccount) -> AuthResult<()> { self.users.insert(account.id, account); self.save().await?; tracing::debug!("stored refreshed Microsoft account"); 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); if self.default_user == Some(id) { self.default_user = self.users.keys().copied().next(); } self.save().await?; Ok(removed) } #[tracing::instrument(level = "debug", skip(self), fields(?id))] pub async fn set_default_user(&mut self, id: Option) -> AuthResult<()> { if let Some(id) = id && !self.users.contains_key(&id) { return Err(AuthError::AccountNotFound(id)); } self.default_user = id; self.save().await?; Ok(()) } #[tracing::instrument(level = "debug", skip_all)] pub async fn resolve_default_id(&mut self) -> AuthResult> { let id = self .default_user .or_else(|| self.users.keys().copied().next()); let Some(id) = id else { return Ok(None); }; if !self.users.contains_key(&id) { return Ok(None); } if self.default_user != Some(id) { self.default_user = Some(id); self.save().await?; } Ok(Some(id)) } /// Never touches the network callers that need a usable token go through /// [`crate::AuthService::account_for_launch`] #[tracing::instrument(level = "debug", skip_all)] pub async fn default_account(&mut self) -> AuthResult> { let Some(id) = self.resolve_default_id().await? else { return Ok(None); }; let Some(account) = self.users.get(&id).cloned() else { return Ok(None); }; if account.is_offline() && !self.has_microsoft_account() { return Err(AuthError::OfflineRequiresMicrosoft); } Ok(Some(account)) } } /// A transient failure must keep the existing token discarding it because /// Wi-Fi dropped would sign the user out of a working account pub(crate) fn is_transient_auth_error(err: &AuthError) -> bool { matches!( err, AuthError::Minecraft(crate::error::MinecraftAuthError::RequestError { source, .. }) if source.is_connect() || source.is_timeout() ) }