224 lines
7 KiB
Rust
224 lines
7 KiB
Rust
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<Uuid, MinecraftAccount>,
|
|
pub default_user: Option<Uuid>,
|
|
}
|
|
|
|
impl CredentialsStore {
|
|
#[tracing::instrument(level = "debug")]
|
|
pub async fn load() -> AuthResult<Self> {
|
|
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<MinecraftAccount> {
|
|
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<MinecraftAccount> {
|
|
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<MinecraftAccount> {
|
|
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<MinecraftAccount> {
|
|
let account = self.insert_offline_account(username)?;
|
|
self.save().await?;
|
|
Ok(account)
|
|
}
|
|
|
|
fn insert_offline_account(&mut self, username: String) -> AuthResult<MinecraftAccount> {
|
|
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<Option<MinecraftAccount>> {
|
|
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<Uuid>) -> 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<Option<Uuid>> {
|
|
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<Option<MinecraftAccount>> {
|
|
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()
|
|
)
|
|
}
|