From f49685f30fe93f105ad4bde61d639f6e0a5c2c0f Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Tue, 13 Jan 2026 18:22:49 +0800 Subject: feat: implement Microsoft account login flow and refactor account handling --- src-tauri/Cargo.toml | 1 + src-tauri/src/core/auth.rs | 282 ++++++++++++++++++++++++++++++++++++++++++++- src-tauri/src/main.rs | 59 ++++++++-- 3 files changed, 332 insertions(+), 10 deletions(-) (limited to 'src-tauri') diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2573c6a..5a9383e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ futures = "0.3" sha1 = "0.10" hex = "0.4" zip = "2.2.2" +serde_urlencoded = "0.7.1" [build-dependencies] tauri-build = { version = "2.0", features = [] } diff --git a/src-tauri/src/core/auth.rs b/src-tauri/src/core/auth.rs index 39c4ce0..18e51ee 100644 --- a/src-tauri/src/core/auth.rs +++ b/src-tauri/src/core/auth.rs @@ -2,14 +2,57 @@ use serde::{Deserialize, Serialize}; use std::sync::Mutex; use uuid::Uuid; +// --- Account Types --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum Account { + Offline(OfflineAccount), + Microsoft(MicrosoftAccount), +} + +impl Account { + pub fn username(&self) -> String { + match self { + Account::Offline(a) => a.username.clone(), + Account::Microsoft(a) => a.username.clone(), + } + } + + pub fn uuid(&self) -> String { + match self { + Account::Offline(a) => a.uuid.clone(), + Account::Microsoft(a) => a.uuid.clone(), + } + } + + pub fn access_token(&self) -> String { + match self { + Account::Offline(_) => "null".to_string(), + Account::Microsoft(a) => a.access_token.clone(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OfflineAccount { pub username: String, pub uuid: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MicrosoftAccount { + pub username: String, + pub uuid: String, + pub access_token: String, + pub refresh_token: Option, + pub expires_at: i64, +} + +// --- State --- + pub struct AccountState { - pub active_account: Mutex>, + pub active_account: Mutex>, } impl AccountState { @@ -20,9 +63,242 @@ impl AccountState { } } +// --- Offline Utils --- + pub fn generate_offline_uuid(username: &str) -> String { - // Generate a UUID v3 (MD5-based) using the username as the name - // This provides a consistent UUID for the same username let namespace = Uuid::NAMESPACE_OID; Uuid::new_v3(&namespace, username.as_bytes()).to_string() } + +// --- Microsoft Auth Logic --- + +// Constants +// Using a common public client ID for Minecraft Launchers +const CLIENT_ID: &str = "00000000-402b-802d-0000-000000000000"; +const SCOPE: &str = "XboxLive.Signin offline_access openid profile email"; + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeviceCodeResponse { + pub user_code: String, + pub device_code: String, + pub verification_uri: String, + pub expires_in: u64, + pub interval: u64, + pub message: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + pub refresh_token: Option, + pub expires_in: u64, +} + +// Error response from token endpoint +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenError { + pub error: String, +} + +// Xbox Live Auth +#[derive(Debug, Serialize, Deserialize)] +pub struct XboxLiveResponse { + #[serde(rename = "Token")] + pub token: String, + #[serde(rename = "DisplayClaims")] + pub display_claims: DisplayClaims, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DisplayClaims { + pub xui: Vec, // We need "uhs" from this +} + +// Minecraft Auth +#[derive(Debug, Serialize, Deserialize)] +pub struct MinecraftAuthResponse { + pub access_token: String, + pub expires_in: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MinecraftProfile { + pub id: String, + pub name: String, +} + + +// 1. Start Device Flow +pub async fn start_device_flow() -> Result { + let client = reqwest::Client::new(); + let url = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; + + let params = [ + ("client_id", CLIENT_ID), + ("scope", SCOPE), + ]; + + let resp = client.post(url) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(serde_urlencoded::to_string(¶ms).map_err(|e| e.to_string())?) + .send() + .await + .map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_else(|_| "No body".to_string()); + return Err(format!("Device code request failed: {} - Body: {}", status, text)); + } + + let body = resp.json::().await.map_err(|e| e.to_string())?; + Ok(body) +} + +// 2. Poll for Token (Simplified: User calls this repeatedly or we loop inside a command) +// We'll implement a function that tries ONCE, consuming the device_code. +pub async fn exchange_code_for_token(device_code: &str) -> Result { + let client = reqwest::Client::new(); + let url = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; + + let params = [ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("client_id", CLIENT_ID), + ("device_code", device_code), + ]; + + let resp = client.post(url) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(serde_urlencoded::to_string(¶ms).map_err(|e| e.to_string())?) + .send() + .await + .map_err(|e| e.to_string())?; + + // Check application level error (e.g. "authorization_pending") + let text = resp.text().await.map_err(|e| e.to_string())?; + + // Try parse success + if let Ok(token_resp) = serde_json::from_str::(&text) { + return Ok(token_resp); + } + + // Try parse error + if let Ok(err_resp) = serde_json::from_str::(&text) { + return Err(err_resp.error); // "authorization_pending", "expired_token", "access_denied" + } + + Err(format!("Unknown response: {}", text)) +} + + +// 3. Authenticate with Xbox Live +pub async fn method_xbox_live(ms_access_token: &str) -> Result<(String, String), String> { + let client = reqwest::Client::new(); + let url = "https://user.auth.xboxlive.com/user/authenticate"; + + let payload = serde_json::json!({ + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": format!("d={}", ms_access_token) + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT" + }); + + let resp = client.post(url) + .json(&payload) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .send() + .await + .map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + return Err(format!("Xbox Live auth failed: {}", resp.status())); + } + + let xbl_resp: XboxLiveResponse = resp.json().await.map_err(|e| e.to_string())?; + + // Extract UHS (User Hash) + let uhs = xbl_resp.display_claims.xui.first() + .and_then(|x| x.get("uhs")) + .and_then(|s| s.as_str()) + .ok_or("Failed to find UHS code")? + .to_string(); + + Ok((xbl_resp.token, uhs)) +} + +// 4. Authenticate with XSTS +pub async fn method_xsts(xbl_token: &str) -> Result { + let client = reqwest::Client::new(); + let url = "https://xsts.auth.xboxlive.com/xsts/authorize"; + + let payload = serde_json::json!({ + "Properties": { + "SandboxId": "RETAIL", + "UserTokens": [xbl_token] + }, + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT" + }); + + let resp = client.post(url) + .json(&payload) + .send() + .await + .map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + // Should handle specific errors like "Account not verified", "Age restriction" + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(format!("XSTS auth failed: {} - {}", status, text)); + } + + let xsts_resp: XboxLiveResponse = resp.json().await.map_err(|e| e.to_string())?; + Ok(xsts_resp.token) +} + +// 5. Authenticate with Minecraft +pub async fn login_minecraft(xsts_token: &str, uhs: &str) -> Result { + let client = reqwest::Client::new(); + let url = "https://api.minecraftservices.com/authentication/login_with_xbox"; + + let payload = serde_json::json!({ + "identityToken": format!("XBL3.0 x={};{}", uhs, xsts_token) + }); + + let resp = client.post(url) + .json(&payload) + .send() + .await + .map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + return Err(format!("Minecraft auth failed: {}", resp.status())); + } + + let mc_resp: MinecraftAuthResponse = resp.json().await.map_err(|e| e.to_string())?; + Ok(mc_resp.access_token) +} + +// 6. Get Profile +pub async fn fetch_profile(mc_access_token: &str) -> Result { + let client = reqwest::Client::new(); + let url = "https://api.minecraftservices.com/minecraft/profile"; + + let resp = client.get(url) + .bearer_auth(mc_access_token) + .send() + .await + .map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + return Err(format!("Profile fetch failed: {}", resp.status())); + } + + let profile: MinecraftProfile = resp.json().await.map_err(|e| e.to_string())?; + Ok(profile) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 02a2b18..d7f5b20 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -296,7 +296,7 @@ async fn start_game( // 7c. Game Arguments // Replacements map let mut replacements = std::collections::HashMap::new(); - replacements.insert("${auth_player_name}", account.username.clone()); + replacements.insert("${auth_player_name}", account.username()); replacements.insert("${version_name}", version_id.clone()); replacements.insert("${game_directory}", game_dir.to_string_lossy().to_string()); replacements.insert("${assets_root}", assets_dir.to_string_lossy().to_string()); @@ -304,8 +304,8 @@ async fn start_game( "${assets_index_name}", version_details.asset_index.id.clone(), ); - replacements.insert("${auth_uuid}", account.uuid.clone()); - replacements.insert("${auth_access_token}", "null".to_string()); // Offline + replacements.insert("${auth_uuid}", account.uuid()); + replacements.insert("${auth_access_token}", account.access_token()); replacements.insert("${user_type}", "mojang".to_string()); replacements.insert("${version_type}", "release".to_string()); replacements.insert("${user_properties}", "{}".to_string()); // Correctly pass empty JSON object for user properties @@ -431,9 +431,9 @@ async fn get_versions() -> Result, String> { async fn login_offline( state: State<'_, core::auth::AccountState>, username: String, -) -> Result { +) -> Result { let uuid = core::auth::generate_offline_uuid(&username); - let account = core::auth::OfflineAccount { username, uuid }; + let account = core::auth::Account::Offline(core::auth::OfflineAccount { username, uuid }); *state.active_account.lock().unwrap() = Some(account.clone()); Ok(account) @@ -442,7 +442,7 @@ async fn login_offline( #[tauri::command] async fn get_active_account( state: State<'_, core::auth::AccountState>, -) -> Result, String> { +) -> Result, String> { Ok(state.active_account.lock().unwrap().clone()) } @@ -469,6 +469,49 @@ async fn save_settings( Ok(()) } +#[tauri::command] +async fn start_microsoft_login() -> Result { + core::auth::start_device_flow().await +} + +#[tauri::command] +async fn complete_microsoft_login( + state: State<'_, core::auth::AccountState>, + device_code: String, +) -> Result { + // 1. Poll (once) for token + let token_resp = core::auth::exchange_code_for_token(&device_code).await?; + + // 2. Xbox Live Auth + let (xbl_token, uhs) = core::auth::method_xbox_live(&token_resp.access_token).await?; + + // 3. XSTS Auth + let xsts_token = core::auth::method_xsts(&xbl_token).await?; + + // 4. Minecraft Auth + let mc_token = core::auth::login_minecraft(&xsts_token, &uhs).await?; + + // 5. Get Profile + let profile = core::auth::fetch_profile(&mc_token).await?; + + // 6. Create Account + let account = core::auth::Account::Microsoft(core::auth::MicrosoftAccount { + username: profile.name, + uuid: profile.id, + access_token: mc_token, // This is the MC Access Token + refresh_token: token_resp.refresh_token, + expires_at: (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + token_resp.expires_in) as i64, + }); + + // 7. Save to state + *state.active_account.lock().unwrap() = Some(account.clone()); + + Ok(account) +} + fn main() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) @@ -485,7 +528,9 @@ fn main() { get_active_account, logout, get_settings, - save_settings + save_settings, + start_microsoft_login, + complete_microsoft_login ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); -- cgit v1.2.3-70-g09d2