diff options
| -rw-r--r-- | src-tauri/Cargo.toml | 1 | ||||
| -rw-r--r-- | src-tauri/src/core/auth.rs | 282 | ||||
| -rw-r--r-- | src-tauri/src/main.rs | 59 | ||||
| -rw-r--r-- | ui/src/App.svelte | 170 |
4 files changed, 482 insertions, 30 deletions
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<String>, + pub expires_at: i64, +} + +// --- State --- + pub struct AccountState { - pub active_account: Mutex<Option<OfflineAccount>>, + pub active_account: Mutex<Option<Account>>, } 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<String>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + pub refresh_token: Option<String>, + 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<serde_json::Value>, // 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<DeviceCodeResponse, String> { + 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::<DeviceCodeResponse>().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<TokenResponse, String> { + 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::<TokenResponse>(&text) { + return Ok(token_resp); + } + + // Try parse error + if let Ok(err_resp) = serde_json::from_str::<TokenError>(&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<String, String> { + 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<String, String> { + 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<MinecraftProfile, String> { + 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<Vec<core::manifest::Version>, String> { async fn login_offline( state: State<'_, core::auth::AccountState>, username: String, -) -> Result<core::auth::OfflineAccount, String> { +) -> Result<core::auth::Account, String> { 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<Option<core::auth::OfflineAccount>, String> { +) -> Result<Option<core::auth::Account>, 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::DeviceCodeResponse, String> { + core::auth::start_device_flow().await +} + +#[tauri::command] +async fn complete_microsoft_login( + state: State<'_, core::auth::AccountState>, + device_code: String, +) -> Result<core::auth::Account, String> { + // 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"); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index d9dd7d0..8ec0da1 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -25,11 +25,19 @@ releaseTime: string; } - interface OfflineAccount { + interface Account { + type: "Offline" | "Microsoft"; username: string; uuid: string; } + interface DeviceCodeResponse { + user_code: string; + device_code: string; + verification_uri: string; + message?: string; + } + interface LauncherConfig { min_memory: number; max_memory: number; @@ -40,7 +48,7 @@ let versions: Version[] = []; let selectedVersion = ""; - let currentAccount: OfflineAccount | null = null; + let currentAccount: Account | null = null; let settings: LauncherConfig = { min_memory: 1024, max_memory: 2048, @@ -49,6 +57,13 @@ height: 480, }; + // Login UI State + let isLoginModalOpen = false; + let loginMode: 'select' | 'offline' | 'microsoft' = 'select'; + let offlineUsername = ""; + let deviceCodeData: DeviceCodeResponse | null = null; + let msLoginLoading = false; + onMount(async () => { checkAccount(); loadSettings(); @@ -68,7 +83,7 @@ async function checkAccount() { try { const acc = await invoke("get_active_account"); - currentAccount = acc as OfflineAccount | null; + currentAccount = acc as Account | null; } catch (e) { console.error("Failed to check account:", e); } @@ -92,32 +107,75 @@ } } - async function login() { + // --- Auth Functions --- + + function openLoginModal() { if (currentAccount) { if (confirm("Logout " + currentAccount.username + "?")) { - try { - await invoke("logout"); - currentAccount = null; - } catch (e) { - console.error("Logout failed:", e); - } + invoke("logout").then(() => currentAccount = null); } return; } - const username = prompt("Enter username for offline login:"); - if (username) { - try { - currentAccount = await invoke("login_offline", { username }); - } catch (e) { - alert("Login failed: " + e); - } + // Reset state + isLoginModalOpen = true; + loginMode = 'select'; + offlineUsername = ""; + deviceCodeData = null; + msLoginLoading = false; + } + + function closeLoginModal() { + isLoginModalOpen = false; + } + + async function performOfflineLogin() { + if (!offlineUsername) return; + try { + currentAccount = await invoke("login_offline", { username: offlineUsername }) as Account; + isLoginModalOpen = false; + } catch (e) { + alert("Login failed: " + e); + } + } + + async function startMicrosoftLogin() { + loginMode = 'microsoft'; + msLoginLoading = true; + try { + deviceCodeData = await invoke("start_microsoft_login") as DeviceCodeResponse; + } catch(e) { + alert("Failed to start Microsoft login: " + e); + loginMode = 'select'; // Go back + } finally { + msLoginLoading = false; } } + + async function completeMicrosoftLogin() { + if(!deviceCodeData) return; + msLoginLoading = true; + try { + currentAccount = await invoke("complete_microsoft_login", { deviceCode: deviceCodeData.device_code }) as Account; + isLoginModalOpen = false; + } catch(e) { + alert("Login failed: " + e + "\n\nMake sure you authorized the app in your browser."); + } finally { + msLoginLoading = false; + } + } + + function openLink(url: string) { + // Use tauri open if possible, or window.open + invoke('start_microsoft_login').catch(() => window.open(url, '_blank')); + // Wait, we invoke 'start_microsoft_login' above already. + // Just use window.open for the verification URI + window.open(url, '_blank'); + } async function startGame() { if (!currentAccount) { alert("Please login first!"); - login(); + openLoginModal(); return; } @@ -359,10 +417,10 @@ <div class="flex items-center gap-4"> <div class="flex items-center gap-4 cursor-pointer hover:opacity-80 transition-opacity" - onclick={login} + onclick={openLoginModal} role="button" tabindex="0" - onkeydown={(e) => e.key === "Enter" && login()} + onkeydown={(e) => e.key === "Enter" && openLoginModal()} > <div class="w-12 h-12 rounded bg-gradient-to-tr from-indigo-500 to-purple-500 shadow-lg flex items-center justify-center text-white font-bold text-xl overflow-hidden" @@ -435,6 +493,78 @@ </div> </main> + <!-- Login Modal --> + {#if isLoginModalOpen} + <div class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"> + <div class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-full max-w-md animate-in fade-in zoom-in-0 duration-200"> + + <div class="flex justify-between items-center mb-6"> + <h2 class="text-2xl font-bold text-white">Login</h2> + <button onclick={closeLoginModal} class="text-zinc-500 hover:text-white transition group"> + ✕ + </button> + </div> + + {#if loginMode === 'select'} + <div class="space-y-4"> + <button onclick={startMicrosoftLogin} class="w-full flex items-center justify-center gap-3 bg-[#2F2F2F] hover:bg-[#3F3F3F] text-white p-4 rounded-lg font-bold border border-transparent hover:border-zinc-500 transition-all group"> + <!-- Microsoft Logo SVG --> + <svg class="w-5 h-5" viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="#f35325" d="M1 1h10v10H1z"/><path fill="#81bc06" d="M12 1h10v10H12z"/><path fill="#05a6f0" d="M1 12h10v10H1z"/><path fill="#ffba08" d="M12 12h10v10H12z"/></svg> + Microsoft Account + </button> + + <div class="relative py-2"> + <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-zinc-700"></div></div> + <div class="relative flex justify-center text-xs uppercase"><span class="bg-zinc-900 px-2 text-zinc-500">OR</span></div> + </div> + + <div class="space-y-2"> + <input type="text" bind:value={offlineUsername} placeholder="Offline Username" class="w-full bg-zinc-950 border border-zinc-700 rounded p-3 text-white focus:border-indigo-500 outline-none" + onkeydown={(e) => e.key === 'Enter' && performOfflineLogin()} /> + <button onclick={performOfflineLogin} class="w-full bg-zinc-800 hover:bg-zinc-700 text-zinc-300 p-3 rounded font-medium transition-colors"> + Offline Login + </button> + </div> + </div> + + {:else if loginMode === 'microsoft'} + <div class="text-center"> + {#if msLoginLoading && !deviceCodeData} + <div class="py-8 text-zinc-400 animate-pulse">Starting login flow...</div> + {:else if deviceCodeData} + <div class="space-y-4"> + <p class="text-sm text-zinc-400">1. Go to this URL:</p> + <button onclick={() => deviceCodeData && openLink(deviceCodeData.verification_uri)} class="text-indigo-400 hover:text-indigo-300 underline break-all font-mono text-sm"> + {deviceCodeData.verification_uri} + </button> + + <p class="text-sm text-zinc-400 mt-2">2. Enter this code:</p> + <div class="bg-zinc-950 p-4 rounded border border-zinc-700 font-mono text-2xl tracking-widest text-center select-all cursor-pointer hover:border-indigo-500 transition-colors" onclick={() => navigator.clipboard.writeText(deviceCodeData?.user_code || '')}> + {deviceCodeData.user_code} + </div> + <p class="text-xs text-zinc-500">Click code to copy</p> + + <div class="pt-4"> + {#if msLoginLoading} + <button disabled class="w-full bg-indigo-600/50 text-white/50 p-3 rounded font-bold cursor-not-allowed"> + Checking... + </button> + {:else} + <button onclick={completeMicrosoftLogin} class="w-full bg-indigo-600 hover:bg-indigo-500 text-white p-3 rounded font-bold transition-transform active:scale-95 shadow-lg shadow-indigo-500/20"> + I Have Authenticated + </button> + {/if} + </div> + + <button onclick={() => loginMode = 'select'} class="text-xs text-zinc-500 hover:text-zinc-300 mt-4 underline">Cancel</button> + </div> + {/if} + </div> + {/if} + </div> + </div> + {/if} + <!-- Overlay Status (Toast) --> {#if status !== "Ready"} <div |