use serde::{Deserialize, Serialize}; use std::sync::Mutex; use uuid::Uuid; // --- Account Types --- // Helper to create a client with a custom User-Agent // This is critical because Microsoft's WAF often blocks requests without a valid UA fn get_client() -> reqwest::Client { reqwest::Client::builder() .user_agent("DropOut/1.0 (Linux)") .build() .unwrap_or_else(|_| get_client()) } #[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>, } impl AccountState { pub fn new() -> Self { Self { active_account: Mutex::new(None), } } } // --- Offline Utils --- pub fn generate_offline_uuid(username: &str) -> String { let namespace = Uuid::NAMESPACE_OID; Uuid::new_v3(&namespace, username.as_bytes()).to_string() } // --- Microsoft Auth Logic --- // Constants const CLIENT_ID: &str = "499546d9-bbfe-4b9b-a086-eb3d75afb78f"; 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 = get_client(); 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 = get_client(); 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 = get_client(); 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() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); return Err(format!("Xbox Live auth failed: {} - {}", status, text)); } 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 = get_client(); 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 = get_client(); 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() { let status = resp.status(); let text = resp.text().await.unwrap_or_else(|_| "No body".to_string()); return Err(format!( "Minecraft auth failed: {} - Body: {}", status, text )); } 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 = get_client(); 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() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); return Err(format!("Profile fetch failed: {} - {}", status, text)); } let profile: MinecraftProfile = resp.json().await.map_err(|e| e.to_string())?; Ok(profile) } // 7. Check Game Ownership #[derive(Debug, Serialize, Deserialize)] pub struct Entitlement { pub name: String, } #[derive(Debug, Serialize, Deserialize)] pub struct EntitlementsResponse { pub items: Vec, pub signature: Option, pub keyId: Option, } pub async fn check_ownership(mc_access_token: &str) -> Result { let client = get_client(); let url = "https://api.minecraftservices.com/entitlements/mcstore"; let resp = client .get(url) .bearer_auth(mc_access_token) .send() .await .map_err(|e| e.to_string())?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); return Err(format!("Entitlement check failed: {} - {}", status, text)); } let body: EntitlementsResponse = resp.json().await.map_err(|e| e.to_string())?; // We look for "product_minecraft" or "game_minecraft" let owns_game = body .items .iter() .any(|e| e.name == "product_minecraft" || e.name == "game_minecraft"); Ok(owns_game) }