use std::collections::HashMap; use std::sync::{Arc, Mutex as StdMutex}; use oneclient_events::EventBus; use oneclient_net::RequestClient; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; use uuid::Uuid; use crate::data::{MicrosoftLoginSession, MinecraftAccount}; use crate::error::{AuthError, AuthResult, MinecraftAuthError}; use crate::msa::{self, PendingBrowserLogin}; use crate::store::{self, CredentialsStore}; /// Stable id for the Microsoft login's progress so a front-end can recognise /// it among everything else on the bus pub const MICROSOFT_LOGIN_PROGRESS: Uuid = Uuid::from_u128(0x4D53_4141_5554_4800_0000_0000_0000_0001); /// `finish_microsoft_login` takes the listener and runs with it while the /// token stays here so a cancel from the UI has something to flip struct PendingLogin { /// `None` once a flow owns the listener or once a cancel freed the port /// before any flow got that far browser: Option, cancel: CancellationToken, } pub struct AuthService { store: Mutex, /// Keyed by CSRF state token pending_logins: Mutex>, /// Serialises token renewal per account Microsoft rotates the refresh token /// on every use so concurrent renewals would sign the account out refresh_guards: StdMutex>>>, net: RequestClient, events: EventBus, } impl AuthService { /// A missing or unreadable `auth.json` yields an empty store rather than /// failing so a corrupt file means "sign in again" not "will not start" pub async fn load(net: RequestClient, events: EventBus) -> AuthResult { Ok(Self::with_store( CredentialsStore::load().await?, net, events, )) } #[must_use] pub fn with_store(store: CredentialsStore, net: RequestClient, events: EventBus) -> Self { Self { store: Mutex::new(store), pending_logins: Mutex::new(HashMap::new()), refresh_guards: StdMutex::new(HashMap::new()), net, events, } } #[tracing::instrument(skip_all)] pub async fn begin_microsoft_login(&self) -> AuthResult { tracing::info!("beginning Microsoft login"); let client = self.net.http(); let (browser, pending) = msa::begin_browser_login().await?; let device = msa::begin_device_login(client).await?; let mut logins = self.pending_logins.lock().await; // A login cancelled before its flow ever started has nobody left to remove // it so the next login sweeps it up logins.retain(|_, login| !login.cancel.is_cancelled()); logins.insert( browser.state.clone(), PendingLogin { browser: Some(pending), cancel: CancellationToken::new(), }, ); Ok(MicrosoftLoginSession { browser, device }) } /// Only one caller can ever claim a login async fn claim_pending_login( &self, state_token: &str, ) -> AuthResult<(PendingBrowserLogin, CancellationToken)> { let mut logins = self.pending_logins.lock().await; // Cancelled between `begin` and here the listener is already gone and // there is nothing to run if logins .get(state_token) .is_some_and(|login| login.cancel.is_cancelled()) { logins.remove(state_token); return Err(AuthError::LoginCancelled); } let login = logins.get_mut(state_token).ok_or(AuthError::Minecraft( MinecraftAuthError::BrowserLoginNotFound, ))?; let cancel = login.cancel.clone(); let browser = login.browser.take().ok_or(AuthError::Minecraft( MinecraftAuthError::BrowserLoginNotFound, ))?; Ok((browser, cancel)) } #[tracing::instrument(skip_all)] pub async fn finish_microsoft_login( &self, session: MicrosoftLoginSession, ) -> AuthResult { tracing::info!("finishing Microsoft login"); let (pending, cancel) = self.claim_pending_login(&session.browser.state).await?; let events = self.events.clone(); let flow = msa::finish_dual_login( self.net.http(), pending, &session.device, |label, current, total| { events.progress(MICROSOFT_LOGIN_PROGRESS, label, current, total); }, ); // Losing the race drops `flow` and with it the device-code poll the // loopback listener and whichever request was mid-flight let result = tokio::select! { biased; () = cancel.cancelled() => Err(AuthError::LoginCancelled), res = flow => res.map_err(AuthError::from), }; // Whoever runs the flow owns the cleanup a cancel only flips the token self.pending_logins .lock() .await .remove(&session.browser.state); // Drives the card to 100% so the front-end can clear it a cancelled flow // needs this as much as a successful one self.events .progress(MICROSOFT_LOGIN_PROGRESS, "Signed in", 1, 1); // Re-read rather than trusting `result` the last handshake step can // complete in the same breath as a cancel if cancel.is_cancelled() { tracing::info!("Microsoft login cancelled by the user"); return Err(AuthError::LoginCancelled); } if let Err(err) = &result { let mut chain = String::new(); let mut source = std::error::Error::source(err); while let Some(cause) = source { chain.push_str(&format!("\n caused by: {cause}")); source = cause.source(); } tracing::warn!("Microsoft login failed: {err}{chain}"); } let account = result?; tracing::info!(username = %account.username, "Microsoft login succeeded"); self.store .lock() .await .commit_account(account, &self.events) .await } /// The entry is deliberately left in the map `finish_microsoft_login` may /// not have claimed it yet and must find the cancelled token not a hole #[tracing::instrument(level = "debug", skip_all)] pub async fn cancel_microsoft_login(&self, state_token: &str) { let mut logins = self.pending_logins.lock().await; let Some(login) = logins.get_mut(state_token) else { return; }; login.cancel.cancel(); // Frees the port now if no flow ever took the listener once one has the // flow drops it as it unwinds login.browser = None; } #[tracing::instrument(skip(self), fields(username = %username))] pub async fn add_offline_account(&self, username: String) -> AuthResult { self.store .lock() .await .add_offline_account_and_save(username) .await } pub async fn list_accounts(&self) -> Vec { self.store.lock().await.list_accounts() } pub async fn get_account(&self, id: Uuid) -> Option { self.store.lock().await.get_account(id).cloned() } #[tracing::instrument(level = "debug", skip_all)] pub async fn default_account(&self) -> AuthResult> { self.store.lock().await.default_account().await } #[tracing::instrument(level = "debug", skip(self), fields(?id))] pub async fn set_default_account(&self, id: Option) -> AuthResult<()> { self.store.lock().await.set_default_user(id).await } #[tracing::instrument(skip(self), fields(%id))] pub async fn remove_account(&self, id: Uuid) -> AuthResult<()> { self.store.lock().await.remove_account(id).await?; Ok(()) } pub async fn has_microsoft_account(&self) -> bool { self.store.lock().await.has_microsoft_account() } fn refresh_guard(&self, id: Uuid) -> Arc> { Arc::clone( self.refresh_guards .lock() .expect("refresh guard registry poisoned") .entry(id) .or_default(), ) } async fn account_snapshot(&self, id: Uuid) -> AuthResult { self.store .lock() .await .get_account(id) .cloned() .ok_or(AuthError::AccountNotFound(id)) } /// The store lock is never held across the Microsoft handshake only around /// the reads and the final write #[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.is_microsoft() || (!force && !existing.is_expired()) { return Ok(existing); } let guard = self.refresh_guard(id); let _serialised = guard.lock().await; // Re-read under the guard whoever held it may have just refreshed let existing = self.account_snapshot(id).await?; if !existing.is_microsoft() || (!force && !existing.is_expired()) { return Ok(existing); } tracing::info!(username = %existing.username, "renewing Microsoft access token"); match msa::refresh_microsoft_account(self.net.http(), &existing).await { Ok(refreshed) => { self.store .lock() .await .commit_refreshed_account(refreshed.clone()) .await?; Ok(refreshed) } Err(err) => { let err = AuthError::from(err); if store::is_transient_auth_error(&err) { tracing::warn!("keeping existing token after transient renewal failure: {err}"); Ok(existing) } else { Err(err) } } } } #[tracing::instrument(level = "debug", skip(self), fields(%id))] pub async fn refresh_account(&self, id: Uuid) -> AuthResult { self.renew_token(id, true).await } #[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?); } Ok(refreshed) } #[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?; if account.is_offline() && !self.has_microsoft_account().await { return Err(AuthError::OfflineRequiresMicrosoft); } Ok(account) } #[tracing::instrument(level = "debug", skip_all)] pub async fn default_account_for_launch(&self) -> AuthResult> { let Some(id) = self.store.lock().await.resolve_default_id().await? else { return Ok(None); }; Ok(Some(self.account_for_launch(id).await?)) } } #[cfg(test)] mod tests { use oneclient_net::NetConfig; use super::*; use crate::data::DeviceCodeLogin; fn service(events: EventBus) -> AuthService { let net = RequestClient::new(NetConfig::default()).expect("net client"); AuthService::with_store(CredentialsStore::default(), net, events) } /// Registers a login the way `begin_microsoft_login` does minus the device /// code request which is the only half that needs the network async fn register_login(service: &AuthService) -> MicrosoftLoginSession { let (browser, pending) = msa::begin_browser_login().await.expect("loopback bind"); service.pending_logins.lock().await.insert( browser.state.clone(), PendingLogin { browser: Some(pending), cancel: CancellationToken::new(), }, ); MicrosoftLoginSession { browser, device: DeviceCodeLogin { user_code: "ABCD-EFGH".to_string(), device_code: "device-code".to_string(), verification_uri: "https://microsoft.com/link".to_string(), expires_in: 900, interval: 5, message: String::new(), }, } } #[tokio::test] async fn a_cancel_that_beats_the_flow_is_still_a_cancel() { // A quick cancel can land before the flow claims the login let (events, _rx) = EventBus::channel(); let service = service(events); let session = register_login(&service).await; service.cancel_microsoft_login(session.dedupe_key()).await; let err = service.finish_microsoft_login(session).await.unwrap_err(); assert!(matches!(err, AuthError::LoginCancelled), "{err}"); assert!(service.pending_logins.lock().await.is_empty()); assert!(service.store.lock().await.list_accounts().is_empty()); } #[tokio::test] async fn a_cancel_frees_the_loopback_port_it_was_holding() { let (events, _rx) = EventBus::channel(); let service = service(events); let session = register_login(&service).await; service.cancel_microsoft_login(session.dedupe_key()).await; let logins = service.pending_logins.lock().await; let login = logins .get(session.dedupe_key()) .expect("the entry is what a flow starting late reads the cancel from"); assert!(login.cancel.is_cancelled()); assert!( login.browser.is_none(), "the listener should be dropped rather than left bound" ); } #[tokio::test] async fn a_cancel_reaches_a_flow_that_has_already_claimed_the_login() { // Once claimed the entry is the only channel a cancel has to the flow let (events, _rx) = EventBus::channel(); let service = service(events); let session = register_login(&service).await; let (_listener, cancel) = service .claim_pending_login(session.dedupe_key()) .await .expect("claim"); service.cancel_microsoft_login(session.dedupe_key()).await; assert!(cancel.is_cancelled()); } #[tokio::test] async fn a_login_can_only_be_claimed_once() { let (events, _rx) = EventBus::channel(); let service = service(events); let session = register_login(&service).await; service .claim_pending_login(session.dedupe_key()) .await .expect("claim"); let claimed_again = service.claim_pending_login(session.dedupe_key()).await; assert!(matches!( claimed_again, Err(AuthError::Minecraft( MinecraftAuthError::BrowserLoginNotFound )) )); } }