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. /// /// Naming it here is the core stating an identity, not a display decision. It /// says nothing about whether this is a toast or a modal, which is the /// front-end's call. It exists because the login is driven by a mutation with /// no place to own a private bus. pub const MICROSOFT_LOGIN_PROGRESS: Uuid = Uuid::from_u128(0x4D53_4141_5554_4800_0000_0000_0000_0001); /// A Microsoft login that has been started but has not finished. /// /// The two halves have different owners once the flow is under way, which is /// the whole reason this is a struct: `finish_microsoft_login` takes the /// listener and runs with it, while the token stays here so a cancel arriving /// from the UI has something to flip. struct PendingLogin { /// The loopback listener, until the flow claims it. /// /// `None` once someone owns it, or once a cancel freed the port before any /// flow got that far. browser: Option, cancel: CancellationToken, } /// Owns everything the authentication flows need. /// /// Constructed once by the composition layer and passed down, so nothing here /// reaches for a global. No database; accounts live in `auth.json` beside the /// launcher data. pub struct AuthService { store: Mutex, /// Browser logins waiting on their redirect, keyed by CSRF state token. pending_logins: Mutex>, /// Serialises token renewal per account. /// /// Microsoft rotates the refresh token on every use, so two concurrent /// renewals of one account would race: the loser spends an already-consumed /// refresh token and the account gets signed out. Clusters can launch in /// parallel, so this is reachable. The guard makes the second caller wait and /// reuse the first caller's result instead of starting its own chain. /// /// A `std::sync::Mutex` because its critical section is one map lookup; the /// `tokio::Mutex` inside is what is held across the handshake. refresh_guards: StdMutex>>>, net: RequestClient, events: EventBus, } impl AuthService { /// Loads the credentials store from disk. A missing or unreadable file /// yields an empty store rather than failing: a corrupt `auth.json` should /// mean "sign in again", not "the launcher 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, } } // --- Microsoft login --------------------------------------------------- #[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 }) } /// Takes the loopback listener for `state_token`'s flow, plus the token that /// cancels it. 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); }, ); // Racing the token is what makes a cancel prompt: losing the race drops // `flow`, and with it the device-code poll, the loopback listener and // whichever request was mid-flight. There is nothing left to check // between polls, and nothing keeps running behind the closed dialog. 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, // so that an in-flight flow is the one to tear its own state down. self.pending_logins.lock().await.remove(&session.browser.state); // Drives the card to 100%, which is how a front-end knows the flow is // over and can clear whatever it was rendering. A cancelled flow needs // this just as much as a successful one, or the modal's progress line // would outlive the sign-in it belongs to. 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, and an account the user has // already walked away from must not reach the store or the toast. 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 } /// Stops an in-flight Microsoft login and leaves nothing of it behind. /// /// The entry is deliberately left in the map: `finish_microsoft_login` may /// not have claimed it yet, and it needs to find the cancelled token rather /// than a hole it would report as "this sign-in is no longer active". #[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; } // --- account management ------------------------------------------------ #[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() } // --- token renewal ----------------------------------------------------- fn refresh_guard(&self, id: Uuid) -> Arc> { Arc::clone( self.refresh_guards .lock() .expect("refresh guard registry poisoned") .entry(id) .or_default(), ) } /// Clones an account out of the store, so no caller holds the store lock /// past this call. async fn account_snapshot(&self, id: Uuid) -> AuthResult { self.store .lock() .await .get_account(id) .cloned() .ok_or(AuthError::AccountNotFound(id)) } /// Renews `id`'s access token if it has lapsed, returning a usable account. /// /// The store lock is not held across the Microsoft handshake, only around the /// reads and the final write. Holding it across those six sequential round /// trips stalls every other account read behind a multi-second network call. #[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) } /// An account with a token good enough to launch with, renewing if needed. #[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: the browser half is the one that binds a port, and it is /// the only half that needs no network to set up. 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() { // The flow starts on its own task, so a quick cancel can land before it // claims the login. Answering that with "this sign-in is no longer // active" would show the user an error for working the dialog correctly. 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 // that is running, so it has to survive the claim. 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 )) )); } }