aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src-tauri/src
diff options
context:
space:
mode:
authorHsiangNianian <i@jyunko.cn>2026-01-13 18:22:49 +0800
committerHsiangNianian <i@jyunko.cn>2026-01-13 18:22:49 +0800
commitf49685f30fe93f105ad4bde61d639f6e0a5c2c0f (patch)
treec93d16ab29b3317eca2b75911613b3b1c1797266 /src-tauri/src
parentd821c824de70b537d78bac4f9567b81673f53e0e (diff)
downloadDropOut-f49685f30fe93f105ad4bde61d639f6e0a5c2c0f.tar.gz
DropOut-f49685f30fe93f105ad4bde61d639f6e0a5c2c0f.zip
feat: implement Microsoft account login flow and refactor account handling
Diffstat (limited to 'src-tauri/src')
-rw-r--r--src-tauri/src/core/auth.rs282
-rw-r--r--src-tauri/src/main.rs59
2 files changed, 331 insertions, 10 deletions
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(&params).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(&params).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");