//! Real Microsoft account login: device-code OAuth -> Xbox Live -> XSTS -> //! Minecraft Services -> game-ownership check. This is what makes the //! launcher only playable by people who actually own Minecraft Java //! Edition; nothing here is optional or bypassable by a manifest. //! //! CORRECTION (2026-09-06): an earlier version of this module assumed a //! public, no-registration-needed client ID existed for this flow. That //! was wrong — verified live against `login.microsoftonline.com`, which //! rejects it (`AADSTS700016`, app not found). Microsoft requires every //! app to have its own Azure AD (Entra ID) "public client" registration //! (no client secret needed for the device code grant — see //! ), *and* new registrations must be //! separately approved for Minecraft/Xbox API access via //! before Xbox Live/Minecraft Services //! will accept their tokens. `MSA_CLIENT_ID` below is a placeholder until //! ShaCraft completes that registration; `start_device_code` refuses to //! run while it's still the placeholder rather than fail confusingly //! against Microsoft. Every endpoint below is otherwise a hardcoded HTTPS //! constant, matching the trust-domain pattern used for Mojang/NeoForge //! elsewhere in this crate — only the client ID is deployment-specific. use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use std::{ fmt, fs, io, path::Path, thread, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; /// ShaCraft's own Azure AD application (client) ID, registered as a public /// client with device-code flow allowed and approved for Minecraft API /// access. Replace this before shipping login — see the module doc above. const MSA_CLIENT_ID: &str = "00000000-0000-0000-0000-000000000000"; pub fn is_configured() -> bool { MSA_CLIENT_ID != "00000000-0000-0000-0000-000000000000" } const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; const XBOX_USER_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate"; const XSTS_AUTHORIZE_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize"; const MINECRAFT_LOGIN_URL: &str = "https://api.minecraftservices.com/authentication/login_with_xbox"; const MINECRAFT_PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile"; const ACCOUNT_FILE: &str = "account.json"; pub fn http_client() -> Result { crate::trusted_http::client( &[ "login.microsoftonline.com", "user.auth.xboxlive.com", "xsts.auth.xboxlive.com", "api.minecraftservices.com", ], Duration::from_secs(30), ) } #[derive(Debug)] pub enum MsaError { NotConfigured, Network(reqwest::Error), HttpStatus(reqwest::StatusCode), AuthorizationDeclined, AuthorizationExpired, NoXboxAccount, DoesNotOwnMinecraft, Io(io::Error), UnexpectedResponse(String), } impl fmt::Display for MsaError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::NotConfigured => formatter.write_str( "ShaCraft has not configured Microsoft login yet (MSA_CLIENT_ID is a placeholder) — \ register an Azure AD app at https://aka.ms/AppRegistrations and get it approved for \ the Minecraft API at https://aka.ms/mce-reviewappid, then set MSA_CLIENT_ID in msa.rs", ), Self::Network(error) => write!(formatter, "network error: {error}"), Self::HttpStatus(status) => write!(formatter, "unexpected response: {status}"), Self::AuthorizationDeclined => formatter.write_str("Login was declined"), Self::AuthorizationExpired => formatter.write_str("Login code expired before it was used"), Self::NoXboxAccount => formatter.write_str("This Microsoft account has no Xbox profile"), Self::DoesNotOwnMinecraft => formatter.write_str("This Microsoft account does not own Minecraft: Java Edition"), Self::Io(error) => write!(formatter, "I/O error: {error}"), Self::UnexpectedResponse(message) => write!(formatter, "unexpected response: {message}"), } } } impl From for MsaError { fn from(error: io::Error) -> Self { Self::Io(error) } } // --------------------------------------------------------------------- // Device code flow // --------------------------------------------------------------------- pub struct DeviceCodeStart { pub verification_uri: String, pub user_code: String, pub expires_in_seconds: u64, device_code: String, interval_seconds: u64, } #[derive(Deserialize)] struct DeviceCodeResponse { device_code: String, user_code: String, verification_uri: String, expires_in: u64, interval: u64, } pub fn start_device_code(client: &Client) -> Result { if !is_configured() { return Err(MsaError::NotConfigured); } let response = client .post(DEVICE_CODE_URL) .form(&[ ("client_id", MSA_CLIENT_ID), ("scope", "XboxLive.signin offline_access"), ]) .send() .map_err(MsaError::Network)?; if !response.status().is_success() { return Err(MsaError::HttpStatus(response.status())); } let body: DeviceCodeResponse = response.json().map_err(MsaError::Network)?; Ok(DeviceCodeStart { verification_uri: body.verification_uri, user_code: body.user_code, expires_in_seconds: body.expires_in, device_code: body.device_code, interval_seconds: body.interval.max(5), }) } pub struct MicrosoftTokens { pub access_token: String, pub refresh_token: String, } #[derive(Deserialize)] struct TokenResponse { access_token: Option, refresh_token: Option, error: Option, } /// Blocks, polling on `start.interval_seconds`, until the user finishes /// signing in at `start.verification_uri`, the code expires, or they /// decline. This is the slow step in the whole login flow — the caller /// should already have shown `verification_uri`/`user_code` to the user /// before calling this (see `start_device_code`). pub fn poll_device_code( client: &Client, start: &DeviceCodeStart, ) -> Result { let deadline = Instant::now() + Duration::from_secs(start.expires_in_seconds); let mut interval = Duration::from_secs(start.interval_seconds); loop { if Instant::now() >= deadline { return Err(MsaError::AuthorizationExpired); } thread::sleep(interval); let response = client .post(TOKEN_URL) .form(&[ ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), ("client_id", MSA_CLIENT_ID), ("device_code", &start.device_code), ]) .send() .map_err(MsaError::Network)?; let status = response.status(); let body: TokenResponse = response.json().map_err(MsaError::Network)?; if status.is_success() { let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token) else { return Err(MsaError::UnexpectedResponse( "token response missing access_token/refresh_token".into(), )); }; return Ok(MicrosoftTokens { access_token, refresh_token, }); } match body.error.as_deref() { Some("authorization_pending") => continue, Some("slow_down") => { interval += Duration::from_secs(5); continue; } Some("authorization_declined") => return Err(MsaError::AuthorizationDeclined), Some("expired_token") => return Err(MsaError::AuthorizationExpired), other => { return Err(MsaError::UnexpectedResponse( other.unwrap_or("unknown device code error").into(), )) } } } } pub fn refresh_microsoft_tokens( client: &Client, refresh_token: &str, ) -> Result { if !is_configured() { return Err(MsaError::NotConfigured); } let response = client .post(TOKEN_URL) .form(&[ ("grant_type", "refresh_token"), ("client_id", MSA_CLIENT_ID), ("refresh_token", refresh_token), ("scope", "XboxLive.signin offline_access"), ]) .send() .map_err(MsaError::Network)?; if !response.status().is_success() { return Err(MsaError::HttpStatus(response.status())); } let body: TokenResponse = response.json().map_err(MsaError::Network)?; let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token) else { return Err(MsaError::UnexpectedResponse( "refresh response missing access_token/refresh_token".into(), )); }; Ok(MicrosoftTokens { access_token, refresh_token, }) } // --------------------------------------------------------------------- // Xbox Live -> XSTS -> Minecraft Services // --------------------------------------------------------------------- #[derive(Serialize)] struct XboxUserAuthRequest<'a> { #[serde(rename = "Properties")] properties: XboxUserAuthProperties<'a>, #[serde(rename = "RelyingParty")] relying_party: &'a str, #[serde(rename = "TokenType")] token_type: &'a str, } #[derive(Serialize)] struct XboxUserAuthProperties<'a> { #[serde(rename = "AuthMethod")] auth_method: &'a str, #[serde(rename = "SiteName")] site_name: &'a str, #[serde(rename = "RpsTicket")] rps_ticket: String, } #[derive(Serialize)] struct XstsRequest<'a> { #[serde(rename = "Properties")] properties: XstsProperties<'a>, #[serde(rename = "RelyingParty")] relying_party: &'a str, #[serde(rename = "TokenType")] token_type: &'a str, } #[derive(Serialize)] struct XstsProperties<'a> { #[serde(rename = "SandboxId")] sandbox_id: &'a str, #[serde(rename = "UserTokens")] user_tokens: [&'a str; 1], } #[derive(Deserialize)] struct XboxTokenResponse { #[serde(rename = "Token")] token: String, #[serde(rename = "DisplayClaims")] display_claims: XboxDisplayClaims, } #[derive(Deserialize)] struct XboxDisplayClaims { xui: Vec, } #[derive(Deserialize)] struct XboxUserHash { uhs: String, /// Xbox User ID, used for the game's `${auth_xuid}` launch argument. /// Absent for some account states; not required to play. #[serde(default)] xid: Option, } fn xbox_live_user_token( client: &Client, microsoft_access_token: &str, ) -> Result<(String, String), MsaError> { let request = XboxUserAuthRequest { properties: XboxUserAuthProperties { auth_method: "RPS", site_name: "user.auth.xboxlive.com", rps_ticket: format!("d={microsoft_access_token}"), }, relying_party: "http://auth.xboxlive.com", token_type: "JWT", }; let response = client .post(XBOX_USER_AUTH_URL) .json(&request) .send() .map_err(MsaError::Network)?; if !response.status().is_success() { return Err(MsaError::HttpStatus(response.status())); } let body: XboxTokenResponse = response.json().map_err(MsaError::Network)?; let uhs = body .display_claims .xui .into_iter() .next() .map(|claim| claim.uhs) .ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?; Ok((body.token, uhs)) } fn xsts_authorize( client: &Client, xbox_live_token: &str, ) -> Result<(String, String, Option), MsaError> { let request = XstsRequest { properties: XstsProperties { sandbox_id: "RETAIL", user_tokens: [xbox_live_token], }, relying_party: "rp://api.minecraftservices.com/", token_type: "JWT", }; let response = client .post(XSTS_AUTHORIZE_URL) .json(&request) .send() .map_err(MsaError::Network)?; let status = response.status(); if status.as_u16() == 401 { // XErr 2148916233 means the account has no Xbox profile at all // (common for brand-new Microsoft accounts); other 401 causes // (family/child accounts, regional restrictions) surface the same // way for now, kept as one clear error rather than guessing. return Err(MsaError::NoXboxAccount); } if !status.is_success() { return Err(MsaError::HttpStatus(status)); } let body: XboxTokenResponse = response.json().map_err(MsaError::Network)?; let claim = body .display_claims .xui .into_iter() .next() .ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?; Ok((body.token, claim.uhs, claim.xid)) } #[derive(Serialize)] struct MinecraftLoginRequest { #[serde(rename = "identityToken")] identity_token: String, } #[derive(Deserialize)] struct MinecraftLoginResponse { access_token: String, } fn minecraft_login(client: &Client, user_hash: &str, xsts_token: &str) -> Result { let request = MinecraftLoginRequest { identity_token: format!("XBL3.0 x={user_hash};{xsts_token}"), }; let response = client .post(MINECRAFT_LOGIN_URL) .json(&request) .send() .map_err(MsaError::Network)?; if !response.status().is_success() { return Err(MsaError::HttpStatus(response.status())); } let body: MinecraftLoginResponse = response.json().map_err(MsaError::Network)?; Ok(body.access_token) } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MinecraftProfile { pub id: String, pub name: String, } /// Confirms game ownership. A 404 here means the account has no Java /// Edition profile — i.e. doesn't own the game — and nothing should /// install or launch. fn fetch_minecraft_profile( client: &Client, minecraft_access_token: &str, ) -> Result { let response = client .get(MINECRAFT_PROFILE_URL) .bearer_auth(minecraft_access_token) .send() .map_err(MsaError::Network)?; if response.status().as_u16() == 404 { return Err(MsaError::DoesNotOwnMinecraft); } if !response.status().is_success() { return Err(MsaError::HttpStatus(response.status())); } response.json().map_err(MsaError::Network) } pub struct LoginResult { pub minecraft_access_token: String, pub profile: MinecraftProfile, pub refresh_token: String, /// Xbox User ID for the `${auth_xuid}` launch argument. Not every /// account state returns one; the game works fine with an empty value. pub xuid: Option, } fn complete_login(client: &Client, tokens: MicrosoftTokens) -> Result { let (xbox_live_token, _uhs) = xbox_live_user_token(client, &tokens.access_token)?; let (xsts_token, user_hash, xuid) = xsts_authorize(client, &xbox_live_token)?; let minecraft_access_token = minecraft_login(client, &user_hash, &xsts_token)?; let profile = fetch_minecraft_profile(client, &minecraft_access_token)?; Ok(LoginResult { minecraft_access_token, profile, refresh_token: tokens.refresh_token, xuid, }) } pub fn login_with_device_code( client: &Client, start: &DeviceCodeStart, ) -> Result { let tokens = poll_device_code(client, start)?; complete_login(client, tokens) } pub fn login_with_refresh_token( client: &Client, refresh_token: &str, ) -> Result { let tokens = refresh_microsoft_tokens(client, refresh_token)?; complete_login(client, tokens) } // --------------------------------------------------------------------- // Persistence // --------------------------------------------------------------------- #[derive(Serialize, Deserialize)] struct StoredAccount { refresh_token: String, saved_at_unix: u64, } pub fn save_refresh_token(data_dir: &Path, refresh_token: &str) -> io::Result<()> { fs::create_dir_all(data_dir)?; let saved_at_unix = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); let contents = serde_json::to_vec_pretty(&StoredAccount { refresh_token: refresh_token.to_string(), saved_at_unix, }) .expect("StoredAccount is serializable"); let target = data_dir.join(ACCOUNT_FILE); crate::storage::write_atomic(&target, &contents) } pub fn load_refresh_token(data_dir: &Path) -> Option { let contents = fs::read_to_string(data_dir.join(ACCOUNT_FILE)).ok()?; let account: StoredAccount = serde_json::from_str(&contents).ok()?; Some(account.refresh_token) } pub fn clear_account(data_dir: &Path) -> io::Result<()> { match fs::remove_file(data_dir.join(ACCOUNT_FILE)) { Ok(()) => Ok(()), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), Err(error) => Err(error), } } #[cfg(test)] mod tests { use super::*; #[test] fn round_trips_stored_refresh_token() { let dir = std::env::temp_dir().join(format!("shacraft-msa-test-{}", std::process::id())); assert!(load_refresh_token(&dir).is_none()); save_refresh_token(&dir, "super-secret-refresh-token").unwrap(); assert_eq!( load_refresh_token(&dir).as_deref(), Some("super-secret-refresh-token") ); clear_account(&dir).unwrap(); assert!(load_refresh_token(&dir).is_none()); fs::remove_dir_all(&dir).ok(); } #[cfg(unix)] #[test] fn stored_account_file_is_not_world_or_group_readable() { use std::os::unix::fs::PermissionsExt; let dir = std::env::temp_dir().join(format!("shacraft-msa-perm-test-{}", std::process::id())); save_refresh_token(&dir, "secret").unwrap(); let mode = fs::metadata(dir.join(ACCOUNT_FILE)) .unwrap() .permissions() .mode() & 0o777; assert_eq!(mode, 0o600); fs::remove_dir_all(&dir).ok(); } #[test] fn refuses_to_run_with_placeholder_client_id() { assert!(!is_configured()); let client = Client::builder().build().unwrap(); assert!(matches!( start_device_code(&client), Err(MsaError::NotConfigured) )); } /// Live smoke test: requests a real device code from Microsoft and /// checks the shape of the response. Does not (and cannot, without a /// human) complete the actual sign-in. Needs `MSA_CLIENT_ID` set to a /// real, approved Azure app id first — see the module doc comment. /// Run with `cargo test -- --ignored live_requests_device_code`. #[test] #[ignore] fn live_requests_device_code() { let client = Client::builder().build().unwrap(); let start = start_device_code(&client).unwrap(); assert!(!start.user_code.is_empty()); assert!(start.verification_uri.starts_with("https://")); assert!(start.expires_in_seconds > 0); } }