576 lines
19 KiB
Rust
576 lines
19 KiB
Rust
//! 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
|
|
//! <https://aka.ms/AppRegistrations>), *and* new registrations must be
|
|
//! separately approved for Minecraft/Xbox API access via
|
|
//! <https://aka.ms/mce-reviewappid> 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<Client, reqwest::Error> {
|
|
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<io::Error> 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<DeviceCodeStart, MsaError> {
|
|
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<String>,
|
|
refresh_token: Option<String>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
/// 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<MicrosoftTokens, MsaError> {
|
|
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<MicrosoftTokens, MsaError> {
|
|
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<XboxUserHash>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
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<String>), 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<String, MsaError> {
|
|
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<MinecraftProfile, MsaError> {
|
|
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<String>,
|
|
}
|
|
|
|
fn complete_login(client: &Client, tokens: MicrosoftTokens) -> Result<LoginResult, MsaError> {
|
|
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<LoginResult, MsaError> {
|
|
let tokens = poll_device_code(client, start)?;
|
|
complete_login(client, tokens)
|
|
}
|
|
|
|
pub fn login_with_refresh_token(
|
|
client: &Client,
|
|
refresh_token: &str,
|
|
) -> Result<LoginResult, MsaError> {
|
|
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<String> {
|
|
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);
|
|
}
|
|
}
|