261 lines
8.4 KiB
Rust
261 lines
8.4 KiB
Rust
mod error;
|
|
mod msa;
|
|
mod offline;
|
|
mod store;
|
|
mod data;
|
|
|
|
pub use error::{AuthError, MinecraftAuthError, MinecraftAuthStep};
|
|
pub use offline::{offline_account, offline_uuid, validate_offline_username};
|
|
pub use store::CredentialsStore;
|
|
pub use data::{
|
|
AccountKind, BrowserLogin, DeviceCodeLogin, MicrosoftLoginSession, MinecraftAccount,
|
|
};
|
|
pub use msa::PendingBrowserLogin;
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
|
|
|
|
use tokio::sync::Mutex;
|
|
use uuid::Uuid;
|
|
|
|
use crate::LauncherResult;
|
|
use crate::notification::MicrosoftLoginStatus;
|
|
use crate::state::LauncherState;
|
|
|
|
#[tracing::instrument(skip_all)]
|
|
pub async fn begin_microsoft_login() -> LauncherResult<MicrosoftLoginSession> {
|
|
tracing::info!("beginning Microsoft login");
|
|
let state = LauncherState::get()?;
|
|
let client = state.services.requester.http();
|
|
|
|
let (browser, pending) = msa::begin_browser_login().await.map_err(AuthError::from)?;
|
|
let device = msa::begin_device_login(client).await.map_err(AuthError::from)?;
|
|
|
|
state
|
|
.microsoft_logins
|
|
.lock()
|
|
.await
|
|
.insert(browser.state.clone(), pending);
|
|
|
|
Ok(MicrosoftLoginSession { browser, device })
|
|
}
|
|
|
|
#[tracing::instrument(skip_all)]
|
|
pub async fn finish_microsoft_login(
|
|
session: MicrosoftLoginSession,
|
|
) -> LauncherResult<MinecraftAccount> {
|
|
tracing::info!("finishing Microsoft login");
|
|
let state = LauncherState::get()?;
|
|
|
|
let pending = state
|
|
.microsoft_logins
|
|
.lock()
|
|
.await
|
|
.remove(&session.browser.state)
|
|
.ok_or(AuthError::Minecraft(
|
|
MinecraftAuthError::BrowserLoginNotFound,
|
|
))?;
|
|
|
|
let client = state.services.requester.http();
|
|
let notifier = state.services.notifier.clone();
|
|
|
|
let result = msa::finish_dual_login(
|
|
client,
|
|
pending,
|
|
&session.device,
|
|
|label, current, total| {
|
|
notifier.microsoft_login_status(Some(MicrosoftLoginStatus {
|
|
label: label.to_string(),
|
|
current,
|
|
total,
|
|
}));
|
|
},
|
|
)
|
|
.await
|
|
.map_err(AuthError::from);
|
|
|
|
state.services.notifier.microsoft_login_status(None);
|
|
|
|
if let Err(err) = &result {
|
|
tracing::warn!("Microsoft login failed: {err}");
|
|
}
|
|
|
|
let account = result?;
|
|
tracing::info!(username = %account.username, "Microsoft login succeeded");
|
|
let mut auth = state.auth.lock().await;
|
|
auth.commit_account(account, &state.services).await
|
|
}
|
|
|
|
#[tracing::instrument(level = "debug", skip_all)]
|
|
pub async fn cancel_microsoft_login(state_token: &str) {
|
|
if let Ok(state) = LauncherState::get() {
|
|
state.microsoft_logins.lock().await.remove(state_token);
|
|
}
|
|
}
|
|
|
|
#[tracing::instrument(skip_all, fields(username = %username))]
|
|
pub async fn add_offline_account(username: String) -> LauncherResult<MinecraftAccount> {
|
|
let state = LauncherState::get()?;
|
|
let mut auth = state.auth.lock().await;
|
|
auth.add_offline_account_and_save(username).await
|
|
}
|
|
|
|
pub async fn list_accounts() -> LauncherResult<Vec<MinecraftAccount>> {
|
|
let state = LauncherState::get()?;
|
|
let auth = state.auth.lock().await;
|
|
Ok(auth.list_accounts())
|
|
}
|
|
|
|
pub async fn get_account(id: Uuid) -> LauncherResult<Option<MinecraftAccount>> {
|
|
let state = LauncherState::get()?;
|
|
let auth = state.auth.lock().await;
|
|
Ok(auth.get_account(id).cloned())
|
|
}
|
|
|
|
#[tracing::instrument(level = "debug", skip_all)]
|
|
pub async fn get_default_account() -> LauncherResult<Option<MinecraftAccount>> {
|
|
let state = LauncherState::get()?;
|
|
let mut auth = state.auth.lock().await;
|
|
auth.default_account().await
|
|
}
|
|
|
|
#[tracing::instrument(level = "debug", skip_all, fields(?id))]
|
|
pub async fn set_default_account(id: Option<Uuid>) -> LauncherResult<()> {
|
|
let state = LauncherState::get()?;
|
|
let mut auth = state.auth.lock().await;
|
|
auth.set_default_user(id).await
|
|
}
|
|
|
|
#[tracing::instrument(skip_all, fields(%id))]
|
|
pub async fn remove_account(id: Uuid) -> LauncherResult<()> {
|
|
let state = LauncherState::get()?;
|
|
let mut auth = state.auth.lock().await;
|
|
auth.remove_account(id).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// 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.
|
|
static REFRESH_GUARDS: LazyLock<StdMutex<HashMap<Uuid, Arc<Mutex<()>>>>> =
|
|
LazyLock::new(|| StdMutex::new(HashMap::new()));
|
|
|
|
fn refresh_guard(id: Uuid) -> Arc<Mutex<()>> {
|
|
Arc::clone(
|
|
REFRESH_GUARDS
|
|
.lock()
|
|
.expect("refresh guard registry poisoned")
|
|
.entry(id)
|
|
.or_default(),
|
|
)
|
|
}
|
|
|
|
/// Clones an account out of the store, returning an owned value so no caller
|
|
/// keeps the credentials-store lock alive past this call.
|
|
async fn account_snapshot(
|
|
state: &Arc<LauncherState>,
|
|
id: Uuid,
|
|
) -> LauncherResult<MinecraftAccount> {
|
|
let auth = state.auth.lock().await;
|
|
auth.get_account(id)
|
|
.cloned()
|
|
.ok_or_else(|| AuthError::AccountNotFound(id).into())
|
|
}
|
|
|
|
/// Renews `id`'s access token if it has lapsed, returning a usable account.
|
|
///
|
|
/// The credentials-store lock is deliberately not held across the Microsoft
|
|
/// handshake, only around the reads and the final write. Holding it across
|
|
/// those six sequential round trips is what used to stall every other account
|
|
/// read behind a multi-second network call.
|
|
#[tracing::instrument(level = "debug", skip(state), fields(%id))]
|
|
async fn renew_token(
|
|
state: &Arc<LauncherState>,
|
|
id: Uuid,
|
|
force: bool,
|
|
) -> LauncherResult<MinecraftAccount> {
|
|
let existing = account_snapshot(state, id).await?;
|
|
if !existing.is_microsoft() || (!force && !existing.is_expired()) {
|
|
return Ok(existing);
|
|
}
|
|
|
|
let guard = refresh_guard(id);
|
|
let _serialised = guard.lock().await;
|
|
|
|
let existing = account_snapshot(state, 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(state.services.requester.http(), &existing).await {
|
|
Ok(refreshed) => {
|
|
state
|
|
.auth
|
|
.lock()
|
|
.await
|
|
.commit_refreshed_account(refreshed.clone())
|
|
.await?;
|
|
Ok(refreshed)
|
|
}
|
|
Err(err) => {
|
|
let err = crate::LauncherError::from(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_all, fields(%id))]
|
|
pub async fn refresh_account(id: Uuid) -> LauncherResult<MinecraftAccount> {
|
|
let state = LauncherState::get()?;
|
|
renew_token(&state, id, true).await
|
|
}
|
|
|
|
#[tracing::instrument(level = "debug", skip_all)]
|
|
pub async fn refresh_all_accounts() -> LauncherResult<Vec<MinecraftAccount>> {
|
|
let state = LauncherState::get()?;
|
|
let ids: Vec<Uuid> = state.auth.lock().await.users.keys().copied().collect();
|
|
let mut refreshed = Vec::with_capacity(ids.len());
|
|
|
|
for id in ids {
|
|
refreshed.push(renew_token(&state, id, true).await?);
|
|
}
|
|
|
|
Ok(refreshed)
|
|
}
|
|
|
|
#[tracing::instrument(level = "debug", skip_all, fields(%id))]
|
|
pub async fn account_for_launch(id: Uuid) -> LauncherResult<MinecraftAccount> {
|
|
let state = LauncherState::get()?;
|
|
let account = renew_token(&state, id, false).await?;
|
|
|
|
if account.is_offline() && !state.auth.lock().await.has_microsoft_account() {
|
|
return Err(AuthError::OfflineRequiresMicrosoft.into());
|
|
}
|
|
|
|
Ok(account)
|
|
}
|
|
|
|
#[tracing::instrument(level = "debug", skip_all)]
|
|
pub async fn default_account_for_launch() -> LauncherResult<Option<MinecraftAccount>> {
|
|
let state = LauncherState::get()?;
|
|
let Some(id) = state.auth.lock().await.resolve_default_id().await? else {
|
|
return Ok(None);
|
|
};
|
|
Ok(Some(account_for_launch(id).await?))
|
|
}
|
|
|
|
pub async fn has_microsoft_account() -> LauncherResult<bool> {
|
|
let state = LauncherState::get()?;
|
|
let auth = state.auth.lock().await;
|
|
Ok(auth.has_microsoft_account())
|
|
}
|