Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+377
View File
@@ -0,0 +1,377 @@
//! 微软五步链:Live token → **XBL** → **XSTS** → **login_with_xbox** → **entitlements** → **profile**。
//!
//! 逐条承 `analyse/04` §5.2(报文级别),四处容易抄错的地方都在注释里点名:
//! 1. XBL 的 `RpsTicket` 必须带 **`d=` 前缀**(MSA 消费版票据标记);
//! 2. `identityToken` 是 **`XBL3.0 x=<uhs>;<xsts>`**(分号分隔,`x` 后紧跟 `=`);
//! 3. XSTS 在「无 Xbox 档案/儿童账号/地区不支持」时回 **401**(不是 200+JSON),
//! 必须读 body 里的 `XErr` 才能给出准确原因;
//! 4. `/minecraft/profile` 的 **404 二义**:没买游戏 vs 买了没建档案,
//! 要用 `entitlements/license` 复判一次才分得清。
//!
//! **XSTS/MC 令牌不缓存**:刷新就是整链重跑(`analyse/04` §5.1),所以本模块只做无状态串联。
use reqwest::Client;
use serde_json::json;
use crate::auth::body;
use crate::auth::error::AuthError;
use crate::auth::secret::{redactor_for, Secret};
use super::endpoints::MicrosoftEndpoints;
use super::flow::OAuthTokens;
use super::session::MicrosoftSession;
use crate::auth::http::{get_json, post_json, Reply};
/// Xbox Live 用户票据 + 用户哈希。
#[derive(Debug, Clone)]
pub(crate) struct XboxTicket {
/// JWT 票据(凭据形状)。
pub token: Secret,
/// `uhs`(`XBL3.0 x=<uhs>;...` 的 userhash;本身不是凭据但只在本链内部用)。
pub uhs: String,
}
/// MC 侧换到的令牌。
#[derive(Debug, Clone)]
pub(crate) struct MinecraftToken {
/// MC access token(落盘)。
pub access_token: Secret,
/// 有效期(秒)。
pub expires_in: u64,
}
/// Minecraft 档案(不可变 id + 可变 name)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GameProfile {
/// 32 位无连字符 UUID。
pub id: String,
/// 玩家名。
pub name: String,
}
/// 第 1 步:Xbox Live User Authenticate。
pub(crate) async fn xbl_authenticate(
client: &Client,
endpoints: &MicrosoftEndpoints,
live_access_token: &Secret,
) -> Result<XboxTicket, AuthError> {
let body = json!({
"Properties": {
"AuthMethod": "RPS",
"SiteName": "user.auth.xboxlive.com",
"RpsTicket": format!("d={}", live_access_token.expose()),
},
"RelyingParty": "http://auth.xboxlive.com",
"TokenType": "JWT",
});
let reply = post_json(client, &endpoints.xbl_authenticate, &body, None).await?;
if let Some(code) = xerr(&reply) {
return Err(AuthError::Xbox { code });
}
if !reply.is_success() {
// M38:XErr 优先(业务码才是真原因),没有 XErr 时把正文诊断补进文案。
return Err(body::response(
"xbl",
&reply,
"Xbox Live 用户认证被拒",
&redactor_for([live_access_token]),
));
}
let token = reply
.text("Token")
.ok_or_else(|| AuthError::Malformed("XBL 响应缺 Token".to_string()))?;
let uhs = uhs_of(&reply)
.ok_or_else(|| AuthError::Malformed("XBL 响应缺 DisplayClaims.xui[0].uhs".to_string()))?;
Ok(XboxTicket {
token: Secret::new(token),
uhs,
})
}
/// 第 2 步:XSTS Authorize(401 也读 body,`XErr` 才是真原因)。
pub(crate) async fn xsts_authorize(
client: &Client,
endpoints: &MicrosoftEndpoints,
xbl: &XboxTicket,
) -> Result<XboxTicket, AuthError> {
let body = json!({
"Properties": { "SandboxId": "RETAIL", "UserTokens": [xbl.token.expose()] },
"RelyingParty": "rp://api.minecraftservices.com/",
"TokenType": "JWT",
});
let reply = post_json(client, &endpoints.xsts_authorize, &body, None).await?;
if let Some(code) = xerr(&reply) {
return Err(AuthError::Xbox { code });
}
if !reply.is_success() {
return Err(body::response(
"xsts",
&reply,
"XSTS 授权被拒",
&redactor_for([&xbl.token]),
));
}
let token = reply
.text("Token")
.ok_or_else(|| AuthError::Malformed("XSTS 响应缺 Token".to_string()))?;
let uhs = uhs_of(&reply).ok_or_else(|| AuthError::Malformed("XSTS 响应缺 uhs".to_string()))?;
// 一致性断言:同一账号在 XBL 与 XSTS 两次返回的 userhash 必须相同。
if uhs != xbl.uhs {
return Err(AuthError::Malformed(
"XBL 与 XSTS 返回的 userhash 不一致".to_string(),
));
}
Ok(XboxTicket {
token: Secret::new(token),
uhs,
})
}
/// 第 3 步:`login_with_xbox` 换 Minecraft 令牌。
pub(crate) async fn login_with_xbox(
client: &Client,
endpoints: &MicrosoftEndpoints,
xsts: &XboxTicket,
) -> Result<MinecraftToken, AuthError> {
let body = json!({
"identityToken": format!("XBL3.0 x={};{}", xsts.uhs, xsts.token.expose()),
});
let reply = post_json(client, &endpoints.login_with_xbox, &body, None).await?;
if !reply.is_success() {
// M38 的核心一行:403 到底是「拦截页」还是「服务端拒绝」,文案要分开引导。
return Err(body::response(
"login_with_xbox",
&reply,
"Minecraft 登录被拒",
&redactor_for([&xsts.token]),
));
}
let access_token = reply
.text("access_token")
.ok_or_else(|| AuthError::Malformed("login_with_xbox 缺 access_token".to_string()))?;
Ok(MinecraftToken {
access_token: Secret::new(access_token),
expires_in: reply.number("expires_in").unwrap_or(0),
})
}
/// 第 4 步:拥有权校验(必须 200,否则后面启动时也会被服务端踢)。
pub(crate) async fn check_entitlements(
client: &Client,
endpoints: &MicrosoftEndpoints,
mc_token: &Secret,
) -> Result<(), AuthError> {
let reply = get_json(client, &endpoints.entitlements_mcstore, mc_token).await?;
if reply.is_success() {
return Ok(());
}
let detail = body::rejection(
"entitlements",
&reply,
"拥有权校验未通过",
&redactor_for([mc_token]),
);
// 拦截页(HTML/纯文本)与非 404 的空体先当网络问题报,别让用户去查许可;
// JSON 拒绝与 404 沿用 M31 的 `no_entitlement`(稳定码不改)。
let interception = detail.kind.is_interception()
|| (detail.kind == body::BodyKind::Empty && reply.status != 404);
if interception {
return Err(AuthError::Response(Box::new(detail)));
}
let what = if reply.status == 404 {
"该账号没有 Minecraft Java 版拥有权"
} else {
detail.what.as_str()
};
Err(AuthError::Denied {
code: "no_entitlement".to_string(),
description: format!(
"{what}(entitlements/mcstore 返回 HTTP {}){}",
reply.status,
detail.note()
),
})
}
/// 第 5 步:拉档案(404 用 license 复判二义)。
pub(crate) async fn fetch_profile(
client: &Client,
endpoints: &MicrosoftEndpoints,
mc_token: &Secret,
) -> Result<GameProfile, AuthError> {
let reply = get_json(client, &endpoints.profile, mc_token).await?;
// 只有「像服务端应答」的 404 才走二义复判:HTML/纯文本的 404 是网关页,
// 拿它去复判 license 会得出错误的「没买游戏」结论。
if reply.status == 404 && !body::classify(&reply).is_interception() {
return Err(profile_missing(client, endpoints, mc_token).await);
}
if !reply.is_success() {
return Err(body::response(
"profile",
&reply,
"拉取 Minecraft 档案被拒",
&redactor_for([mc_token]),
));
}
let id = reply
.text("id")
.ok_or_else(|| AuthError::Malformed("档案响应缺 id".to_string()))?;
let name = reply
.text("name")
.ok_or_else(|| AuthError::Malformed("档案响应缺 name".to_string()))?;
Ok(GameProfile {
id: id.to_string(),
name: name.to_string(),
})
}
/// 404 之后的二义消解:没买游戏 vs 买了没建档案。
async fn profile_missing(
client: &Client,
endpoints: &MicrosoftEndpoints,
mc_token: &Secret,
) -> AuthError {
let reply = match get_json(client, &endpoints.entitlements_license, mc_token).await {
Ok(reply) => reply,
// 复判本身失败时,退回「档案不存在」这个更保守的说法。
Err(_) => {
return AuthError::Denied {
code: "no_profile".to_string(),
description: "该账号没有 Minecraft 档案(复判 license 也失败)".to_string(),
}
}
};
if has_minecraft_license(&reply) {
AuthError::Denied {
code: "no_profile".to_string(),
description: "账号已拥有 Minecraft,但还没有创建档案(去 minecraft.net 起个名字)"
.to_string(),
}
} else {
AuthError::Denied {
code: "no_license".to_string(),
description: "该账号没有购买 Minecraft Java 版".to_string(),
}
}
}
/// `entitlements/license` 响应里是否有 `game_minecraft`。
fn has_minecraft_license(reply: &Reply) -> bool {
reply
.body
.as_ref()
.and_then(|body| body.get("items"))
.and_then(|items| items.as_array())
.is_some_and(|items| {
items.iter().any(|item| {
item.get("name")
.and_then(|name| name.as_str())
.is_some_and(|name| name == "game_minecraft")
})
})
}
/// 整链:Live token → [`MicrosoftSession`](刷新与新登录共用这一段)。
pub(crate) async fn complete_chain(
client: &Client,
endpoints: &MicrosoftEndpoints,
tokens: OAuthTokens,
now_ms: u64,
) -> Result<MicrosoftSession, AuthError> {
let xbl = xbl_authenticate(client, endpoints, &tokens.access_token).await?;
let xsts = xsts_authorize(client, endpoints, &xbl).await?;
let mc = login_with_xbox(client, endpoints, &xsts).await?;
check_entitlements(client, endpoints, &mc.access_token).await?;
let profile = fetch_profile(client, endpoints, &mc.access_token).await?;
Ok(MicrosoftSession::new(
profile.id,
profile.name,
mc.access_token,
tokens.refresh_token,
mc.expires_in,
now_ms,
None,
))
}
/// 从响应里取 `XErr`(XSTS 的 401 业务码)。
fn xerr(reply: &Reply) -> Option<u32> {
let body = reply.body.as_ref()?;
for key in ["XErr", "errorCode"] {
if let Some(code) = body.get(key).and_then(|value| value.as_u64()) {
if code != 0 {
return Some(code as u32);
}
}
}
None
}
fn uhs_of(reply: &Reply) -> Option<String> {
reply
.body
.as_ref()?
.get("DisplayClaims")?
.get("xui")?
.as_array()?
.first()?
.get("uhs")?
.as_str()
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::{has_minecraft_license, uhs_of, xerr, Reply};
use serde_json::{json, Value};
fn reply(status: u16, body: Option<Value>) -> Reply {
Reply {
status,
body,
content_type: Some("application/json".to_string()),
raw: None,
}
}
#[test]
fn xerr_is_read_from_401_body() {
let sample = reply(
401,
Some(json!({ "Identity": "0", "XErr": 2_148_916_238_u32, "Message": "" })),
);
assert_eq!(xerr(&sample), Some(2_148_916_238));
assert_eq!(xerr(&reply(200, Some(json!({ "XErr": 0 })))), None);
assert_eq!(xerr(&reply(200, None)), None);
}
#[test]
fn uhs_comes_from_first_xui_entry() {
let sample = reply(
200,
Some(json!({ "DisplayClaims": { "xui": [{ "uhs": "1234" }] } })),
);
assert_eq!(uhs_of(&sample).as_deref(), Some("1234"));
assert_eq!(
uhs_of(&reply(200, Some(json!({ "DisplayClaims": {} })))),
None
);
}
#[test]
fn license_detection() {
let with = reply(
200,
Some(
json!({ "items": [{ "name": "product_minecraft" }, { "name": "game_minecraft" }] }),
),
);
let without = reply(
200,
Some(json!({ "items": [{ "name": "product_minecraft" }] })),
);
assert!(has_minecraft_license(&with));
assert!(!has_minecraft_license(&without));
assert!(!has_minecraft_license(&reply(500, None)));
}
}
+118
View File
@@ -0,0 +1,118 @@
//! 微软端点表:**直连**(不走镜像,承 mission Context 与 `analyse/04` §4 的端点族区分)。
//!
//! 两个端点族不能混用(`analyse/04` §4 的「第一个坑」):
//! - 设备码流:`login.microsoftonline.com/consumers/oauth2/v2.0/*`;
//! - HMCL 的浏览器授权码流走 `login.live.com`(本期不实现,故不在此表)。
//!
//! 端点集中成一张表是为了**测试可替换**:整表指向一个进程内 mock 服务器即可覆盖全链,
//! 生产默认走 [`MicrosoftEndpoints::consumers`]。
/// 微软账户 OAuth 与 Xbox / Minecraft 服务端点。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MicrosoftEndpoints {
/// 设备码申请(`POST`,表单 `client_id` + `scope`)。
pub device_code: String,
/// 换 token / 刷新 token(`POST`,表单)。
pub token: String,
/// Xbox Live 用户认证(`POST` JSON)。
pub xbl_authenticate: String,
/// XSTS 授权(`POST` JSON;无 Xbox 档案时回 **401** 带 `XErr`)。
pub xsts_authorize: String,
/// `login_with_xbox`(`POST` JSON,`identityToken = "XBL3.0 x=<uhs>;<xsts>"`)。
pub login_with_xbox: String,
/// 拥有权校验 `entitlements/mcstore`(`GET`,必须 200)。
pub entitlements_mcstore: String,
/// 拥有权细分 `entitlements/license`(`GET`,`/minecraft/profile` 404 时复判用)。
pub entitlements_license: String,
/// 档案 `minecraft/profile`(`GET`,带 Bearer)。
pub profile: String,
}
impl MicrosoftEndpoints {
/// 生产端点(微软消费版账户,直连)。
#[must_use]
pub fn consumers() -> Self {
Self {
device_code: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
.to_string(),
token: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token".to_string(),
xbl_authenticate: "https://user.auth.xboxlive.com/user/authenticate".to_string(),
xsts_authorize: "https://xsts.auth.xboxlive.com/xsts/authorize".to_string(),
login_with_xbox: "https://api.minecraftservices.com/authentication/login_with_xbox"
.to_string(),
entitlements_mcstore: "https://api.minecraftservices.com/entitlements/mcstore"
.to_string(),
entitlements_license: "https://api.minecraftservices.com/entitlements/license"
.to_string(),
profile: "https://api.minecraftservices.com/minecraft/profile".to_string(),
}
}
/// 把整表指向同一个基址(测试用;各端点用自己的路径后缀区分)。
#[must_use]
pub fn all_on(base: &str) -> Self {
let join = |path: &str| format!("{}/{path}", base.trim_end_matches('/'));
Self {
device_code: join("devicecode"),
token: join("token"),
xbl_authenticate: join("xbl"),
xsts_authorize: join("xsts"),
login_with_xbox: join("login_with_xbox"),
entitlements_mcstore: join("entitlements/mcstore"),
entitlements_license: join("entitlements/license"),
profile: join("profile"),
}
}
/// 所有端点(调试/诊断用)。
#[must_use]
pub fn all(&self) -> [&str; 8] {
[
&self.device_code,
&self.token,
&self.xbl_authenticate,
&self.xsts_authorize,
&self.login_with_xbox,
&self.entitlements_mcstore,
&self.entitlements_license,
&self.profile,
]
}
}
impl Default for MicrosoftEndpoints {
fn default() -> Self {
Self::consumers()
}
}
#[cfg(test)]
mod tests {
use super::MicrosoftEndpoints;
#[test]
fn consumers_endpoints_stay_on_two_families() {
let endpoints = MicrosoftEndpoints::consumers();
assert!(endpoints.device_code.contains("login.microsoftonline.com"));
assert!(endpoints.token.contains("/consumers/oauth2/v2.0/token"));
assert!(endpoints
.xbl_authenticate
.contains("user.auth.xboxlive.com"));
assert!(endpoints.xsts_authorize.contains("xsts.auth.xboxlive.com"));
// 授权码流的 login.live.com 不在这张表里(本期不实现)。
assert!(endpoints
.all()
.iter()
.all(|url| !url.contains("login.live.com")));
}
#[test]
fn all_on_keeps_paths_distinct() {
let endpoints = MicrosoftEndpoints::all_on("http://127.0.0.1:1234/");
assert_eq!(endpoints.device_code, "http://127.0.0.1:1234/devicecode");
assert_eq!(
endpoints.entitlements_license,
"http://127.0.0.1:1234/entitlements/license"
);
}
}
+313
View File
@@ -0,0 +1,313 @@
//! device-code 流:发起(拿 `user_code` + `verification_uri`)→ 轮询换 token →(刷新同构)。
//!
//! 逐条承 `analyse/04` §4.3 / §4.4 / §4.5:
//! - **硬超时**:只等 `min(expires_in, 900s)`,防止后台任务永久挂起;
//! - **`slow_down` 是流程内状态**,不是失败:轮询间隔 +5 秒继续;
//! - **每次轮询前先 sleep**(别密集打服务端);
//! - **refresh 同时返回新 refresh_token**,调用方必须整体覆盖(轮换);
//! - 收紧 HMCL 的观察项 1:**只要 `error` 非空就失败**,不再「描述为空就静默放行」。
//!
//! 打印 `user_code`/URL 不在这里:core 不做 UI,宿主拿 [`DeviceCodePrompt`] 自己决定
//! (CLI 打到 stderr,GUI 弹对话框 + 二维码)。
use std::time::{Duration, Instant};
use reqwest::Client;
use crate::auth::error::{AuthError, AuthResult};
use crate::auth::secret::Secret;
use super::endpoints::MicrosoftEndpoints;
use crate::auth::http::{post_form, Reply};
/// 申请的 scope(`XboxLive.signin` + 长期刷新)。
pub const SCOPE: &str = "XboxLive.signin offline_access";
/// 设备码流程的硬上限:无论服务端给多长,最多等这么久。
pub const DEVICE_CODE_MAX_WAIT: Duration = Duration::from_secs(900);
/// 默认轮询间隔下限(服务端 `interval` 更小时按下限走)。
pub const DEFAULT_MIN_INTERVAL: Duration = Duration::from_secs(1);
/// `slow_down` 的退避步长(承 HMCL:+5 秒)。
pub const DEFAULT_SLOW_DOWN_STEP: Duration = Duration::from_secs(5);
/// device_code grant 类型。
pub const DEVICE_CODE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
/// 轮询策略(生产用 [`PollPolicy::default`],测试用小间隔跑同一套状态机)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PollPolicy {
/// 轮询间隔下限。
pub min_interval: Duration,
/// `slow_down` 的退避步长。
pub slow_down_step: Duration,
/// 整个流程最多等多久(还会与 `expires_in` 取小)。
pub max_wait: Duration,
}
impl Default for PollPolicy {
fn default() -> Self {
Self {
min_interval: DEFAULT_MIN_INTERVAL,
slow_down_step: DEFAULT_SLOW_DOWN_STEP,
max_wait: DEVICE_CODE_MAX_WAIT,
}
}
}
/// 要展示给用户的信息(URL + 用户码)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeviceCodePrompt {
/// 用户在验证页输入的短码(如 `ABCD1234`)。**不是凭据**,可打印。
pub user_code: String,
/// 验证地址(`https://www.microsoft.com/link` 等)。
pub verification_uri: String,
/// 服务端给的有效期。
pub expires_in: Duration,
/// 服务端建议的轮询间隔。
pub interval: Duration,
}
impl DeviceCodePrompt {
/// 人读两行(CLI 打到 stderr,GUI 直接展示)。
#[must_use]
pub fn lines(&self) -> (String, String) {
(
format!("请在任意设备打开 {} 并输入代码:", self.verification_uri),
format!(
"用户码 {}({} 分钟内有效)",
self.user_code,
self.expires_in.as_secs() / 60
),
)
}
}
/// 设备码申请的中间态(`device_code` 是凭据形状,不进日志)。
#[derive(Debug, Clone)]
pub(crate) struct DeviceCodeGrant {
/// 展示信息。
pub prompt: DeviceCodePrompt,
/// 轮询用的长码(脱敏 Debug,永不打印)。
pub device_code: Secret,
}
/// OAuth 换到的 token 对。
#[derive(Debug, Clone)]
pub(crate) struct OAuthTokens {
/// Live access token(只在链内用,不落盘)。
pub access_token: Secret,
/// Live refresh token(落盘、轮换)。
pub refresh_token: Secret,
}
/// 申请设备码。
pub(crate) async fn request_device_code(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
) -> AuthResult<DeviceCodeGrant> {
let reply = post_form(
client,
&endpoints.device_code,
vec![
("client_id", client_id.to_string()),
("scope", SCOPE.to_string()),
],
)
.await?;
if !reply.is_success() {
return Err(oauth_error(&reply, &endpoints.device_code));
}
let user_code = reply
.text("user_code")
.ok_or_else(|| AuthError::Malformed("设备码响应缺 user_code".to_string()))?;
let device_code = reply
.text("device_code")
.ok_or_else(|| AuthError::Malformed("设备码响应缺 device_code".to_string()))?;
let verification_uri = reply
.text("verification_uri")
.or_else(|| reply.text("verification_url"))
.ok_or_else(|| AuthError::Malformed("设备码响应缺 verification_uri".to_string()))?;
let expires_in = reply.number("expires_in").unwrap_or(900).max(1);
// 服务端间隔原样收下,下限交给 [`PollPolicy::min_interval`](生产 1 秒,测试可压到毫秒)。
let interval = reply.number("interval").unwrap_or(5);
Ok(DeviceCodeGrant {
prompt: DeviceCodePrompt {
user_code: user_code.to_string(),
verification_uri: verification_uri.to_string(),
expires_in: Duration::from_secs(expires_in),
interval: Duration::from_secs(interval),
},
device_code: Secret::new(device_code),
})
}
/// 轮询直到用户完成授权(或超时/被拒/取消)。
pub(crate) async fn poll_device_token(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
grant: &DeviceCodeGrant,
policy: PollPolicy,
) -> AuthResult<OAuthTokens> {
let limit = grant.prompt.expires_in.min(policy.max_wait);
let mut interval = grant.prompt.interval.max(policy.min_interval);
let started = Instant::now();
loop {
if started.elapsed() >= limit {
return Err(AuthError::Timeout {
what: format!("等待设备码授权(上限 {} 秒)", limit.as_secs()),
});
}
tokio::time::sleep(interval).await;
let reply = post_form(
client,
&endpoints.token,
vec![
("client_id", client_id.to_string()),
("grant_type", DEVICE_CODE_GRANT.to_string()),
("code", grant.device_code.expose().to_string()),
],
)
.await?;
if let Some(error) = reply.text("error") {
match error {
"authorization_pending" => continue,
// 服务端压力反馈:不是失败,退避后继续(承 HMCL)。
"slow_down" => {
interval += policy.slow_down_step;
continue;
}
"expired_token" => {
return Err(AuthError::Timeout {
what: "设备码已过期(需重新发起登录)".to_string(),
})
}
"access_denied" | "authorization_declined" => return Err(AuthError::Canceled),
_ => return Err(oauth_error(&reply, &endpoints.token)),
}
}
return tokens_from(&reply, &endpoints.token);
}
}
/// 用 refresh_token 换新的 token 对(**刷新也会返回新 refresh_token**)。
pub(crate) async fn refresh_tokens(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
refresh_token: &Secret,
) -> AuthResult<OAuthTokens> {
let reply = post_form(
client,
&endpoints.token,
vec![
("client_id", client_id.to_string()),
("grant_type", "refresh_token".to_string()),
("refresh_token", refresh_token.expose().to_string()),
],
)
.await?;
if let Some(error) = reply.text("error") {
// `invalid_grant` + `AADSTS70000` = 授权被撤销/过期 → 唯一该重新登录的信号。
if error == "invalid_grant" {
let description = reply.text("error_description").unwrap_or("");
if description.contains("AADSTS70000") {
return Err(AuthError::CredentialExpired);
}
}
return Err(oauth_error(&reply, &endpoints.token));
}
if !reply.is_success() {
return Err(oauth_error(&reply, &endpoints.token));
}
tokens_from(&reply, &endpoints.token)
}
fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
let access_token = reply
.text("access_token")
.ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 access_token")))?;
let refresh_token = reply
.text("refresh_token")
.ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 refresh_token")))?;
Ok(OAuthTokens {
access_token: Secret::new(access_token),
refresh_token: Secret::new(refresh_token),
})
}
/// OAuth 错误归一:**`error` 非空即失败**(HMCL 只在两个字段都非空时处理,我们收紧)。
fn oauth_error(reply: &Reply, url: &str) -> AuthError {
let Some(code) = reply.text("error") else {
return AuthError::network(
url.split('?').next().unwrap_or(url),
format!("HTTP {}(响应不是 OAuth 错误体)", reply.status),
);
};
let description = reply
.text("error_description")
.unwrap_or("服务端未给描述")
.to_string();
if code == "invalid_grant" && description.contains("AADSTS70000") {
return AuthError::CredentialExpired;
}
AuthError::Denied {
code: code.to_string(),
description,
}
}
#[cfg(test)]
mod tests {
use super::{oauth_error, PollPolicy, DEFAULT_MIN_INTERVAL, DEVICE_CODE_MAX_WAIT};
use crate::auth::error::AuthError;
use crate::auth::http::Reply;
use serde_json::json;
#[test]
fn default_policy_matches_hmcl_bounds() {
let policy = PollPolicy::default();
assert_eq!(policy.min_interval, DEFAULT_MIN_INTERVAL);
assert_eq!(policy.max_wait, DEVICE_CODE_MAX_WAIT);
assert_eq!(policy.max_wait.as_secs(), 900);
}
#[test]
fn error_without_description_still_fails() {
let reply = reply(400, Some(json!({ "error": "invalid_request" })));
match oauth_error(&reply, "https://example.invalid/token") {
AuthError::Denied { code, .. } => assert_eq!(code, "invalid_request"),
other => panic!("应是 Denied,实际 {other:?}"),
}
}
#[test]
fn aadsts70000_becomes_credential_expired() {
let reply = reply(
400,
Some(json!({
"error": "invalid_grant",
"error_description": "AADSTS70000: The grant is expired or revoked"
})),
);
assert!(oauth_error(&reply, "https://example.invalid/token").is_credential_expired());
}
#[test]
fn non_oauth_body_is_network_error() {
let reply = reply(500, None);
assert_eq!(
oauth_error(&reply, "https://example.invalid/token").code(),
"network"
);
}
fn reply(status: u16, body: Option<serde_json::Value>) -> Reply {
Reply {
status,
body,
content_type: Some("application/json".to_string()),
raw: None,
}
}
}
+332
View File
@@ -0,0 +1,332 @@
//! 微软轨:device-code 全流程 + 会话/刷新语义(`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5)。
//!
//! 分四块,各管一件事:
//! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测);
//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock);
//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层);
//! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。
//!
//! # client id 从哪来
//!
//! device-code 流必须带一个**已在 Azure 注册的公共客户端 id**。HMCL 把它烘进 jar 的
//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由
//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`] 并说清要配什么——
//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。
pub mod chain;
pub mod endpoints;
pub mod flow;
pub mod session;
pub use endpoints::MicrosoftEndpoints;
pub use flow::{DeviceCodePrompt, PollPolicy};
pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA};
use std::time::{SystemTime, UNIX_EPOCH};
use reqwest::Client;
use crate::auth::error::{AuthError, AuthResult};
use crate::auth::journal::{AuthFailureEvent, AuthJournal};
use crate::download::config::EngineConfig;
use crate::download::http::build_client;
/// 运行期环境变量名(配 client id)。
pub const CLIENT_ID_ENV: &str = "ULL_MSA_CLIENT_ID";
/// 构建期可注入的 client id(`ULL_MSA_CLIENT_ID=... cargo build`)。
pub const CLIENT_ID_BUILD: Option<&str> = option_env!("ULL_MSA_CLIENT_ID");
/// 微软账户登录/刷新。
#[derive(Debug, Clone)]
pub struct MicrosoftAuth {
client: Client,
client_id: String,
endpoints: MicrosoftEndpoints,
poll: PollPolicy,
journal: Option<AuthJournal>,
}
impl MicrosoftAuth {
/// 按显式 client id 构造(HTTP 基座复用 `core::download` 的客户端构造)。
///
/// # Errors
///
/// HTTP 客户端建不起来时返回 [`AuthError::Network`]。
pub fn new(client_id: impl Into<String>) -> AuthResult<Self> {
let client = build_client(&EngineConfig::default())
.map_err(|error| AuthError::network("http-client", error.to_string()))?;
Ok(Self {
client,
client_id: client_id.into(),
endpoints: MicrosoftEndpoints::consumers(),
poll: PollPolicy::default(),
journal: None,
})
}
/// 从环境/构建期取 client id(CLI 与 GUI 的默认入口)。
///
/// 与 [`Self::new`] 的唯一差别:这条**生产入口**默认挂上便携数据根的失败日志
/// (`.ULL/logs/auth.jsonl`,见 [`AuthJournal::portable_default`]),
/// 交互式命令失败时 403 的状态码/正文摘要不会再随终端滚走。
///
/// # Errors
///
/// 两处都没配时返回 [`AuthError::Unsupported`]。
pub fn from_env() -> AuthResult<Self> {
let client_id = std::env::var(CLIENT_ID_ENV)
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| CLIENT_ID_BUILD.map(str::to_string))
.ok_or_else(|| {
AuthError::Unsupported(format!(
"微软登录需要 Azure 公共客户端 id:设置环境变量 {CLIENT_ID_ENV}(或构建时注入)"
))
})?;
let mut auth = Self::new(client_id)?;
auth.journal = AuthJournal::portable_default();
Ok(auth)
}
/// 换失败日志落点(`None` = 不落盘;CI/单测用)。
#[must_use]
pub fn with_journal(mut self, journal: Option<AuthJournal>) -> Self {
self.journal = journal;
self
}
/// 关掉失败日志。
#[must_use]
pub fn without_journal(mut self) -> Self {
self.journal = None;
self
}
/// 失败日志落点(测试断言/宿主展示用)。
#[must_use]
pub fn journal(&self) -> Option<&AuthJournal> {
self.journal.as_ref()
}
/// 换 HTTP 客户端(测试注入 / 复用宿主连接池)。
#[must_use]
pub fn with_client(mut self, client: Client) -> Self {
self.client = client;
self
}
/// 换端点表(测试整表指向 mock 服务器)。
#[must_use]
pub fn with_endpoints(mut self, endpoints: MicrosoftEndpoints) -> Self {
self.endpoints = endpoints;
self
}
/// 换轮询策略(测试用小间隔)。
#[must_use]
pub fn with_poll_policy(mut self, poll: PollPolicy) -> Self {
self.poll = poll;
self
}
/// 当前端点表。
#[must_use]
pub fn endpoints(&self) -> &MicrosoftEndpoints {
&self.endpoints
}
/// device-code 登录全流程。
///
/// `present` 在拿到用户码后**立刻**被调用(CLI 打到 stderr、GUI 弹框/二维码),
/// 然后才开始轮询——用户有整整 `expires_in` 的时间去另一台设备上输码。
///
/// 失败时除返回错误外,还会往失败日志(若配了)追加一条结构化记录。
///
/// # Errors
///
/// 网络/超时/取消/被拒/无拥有权/无档案,见 [`AuthError`]。
pub async fn device_code_login<F>(&self, mut present: F) -> AuthResult<MicrosoftSession>
where
F: FnMut(&DeviceCodePrompt),
{
let result = self.login_and_chain(&mut present).await;
if let Err(error) = &result {
self.record_failure("device_code_login", error);
}
result
}
/// 登录主体(`device_code_login` 的失败日志由外层统一记)。
async fn login_and_chain(
&self,
present: &mut impl FnMut(&DeviceCodePrompt),
) -> AuthResult<MicrosoftSession> {
let grant =
flow::request_device_code(&self.client, &self.endpoints, &self.client_id).await?;
present(&grant.prompt);
let tokens = flow::poll_device_token(
&self.client,
&self.endpoints,
&self.client_id,
&grant,
self.poll,
)
.await?;
chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await
}
/// 刷新:Live refresh → 整链重跑(**原样覆盖 refresh_token**)。
///
/// # Errors
///
/// 凭据被撤销时返回 [`AuthError::CredentialExpired`](唯一该重新交互的信号)。
pub async fn refresh(&self, session: &MicrosoftSession) -> AuthResult<MicrosoftSession> {
let result = self.refresh_and_chain(session).await;
if let Err(error) = &result {
self.record_failure("refresh", error);
}
result
}
/// 刷新主体(失败日志由外层统一记)。
async fn refresh_and_chain(&self, session: &MicrosoftSession) -> AuthResult<MicrosoftSession> {
let tokens = flow::refresh_tokens(
&self.client,
&self.endpoints,
&self.client_id,
&session.refresh_token,
)
.await?;
let refreshed =
chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await?;
if !session.same_profile(&refreshed) {
// 防「刷出来是另一个账号」(承 HMCL WrongAccountException)。
return Err(AuthError::Malformed(format!(
"刷新后档案变了({} → {})",
session.profile_id, refreshed.profile_id
)));
}
Ok(refreshed)
}
/// 尽力而为地记一条失败事件(写日志失败绝不影响登录本身的结论)。
fn record_failure(&self, scope: &str, error: &AuthError) {
if let Some(journal) = &self.journal {
let _ = journal.record(&AuthFailureEvent::of(scope, error));
}
}
/// 网络侧校验:`GET /minecraft/profile` 是否还认这个 token。
///
/// 404 的二义在这里**不吞**:档案被删是真实错误,不该被当成「token 失效」静默刷新
/// (`analyse/04` §11 观察项 3 那个隐式约定,我们写明)。
///
/// # Errors
///
/// 网络失败、无拥有权、无档案等。
pub async fn validate(&self, session: &MicrosoftSession) -> AuthResult<bool> {
if session.is_expired(now_ms()) {
return Ok(false);
}
match chain::fetch_profile(&self.client, &self.endpoints, &session.access_token).await {
Ok(_) => Ok(true),
// 「档案被删 / 没买游戏」是真实错误,穿透上抛(别静默当成 token 失效去刷新)。
Err(error @ AuthError::Denied { .. }) => {
let fatal = matches!(
&error,
AuthError::Denied { code, .. } if code == "no_profile" || code == "no_license"
);
if fatal {
Err(error)
} else {
Ok(false)
}
}
// 链上非 2xx:拦截页(HTML/纯文本)说明网络有问题,别伪装成「token 失效」;
// 服务端的 JSON 拒绝 / 空体仍按「token 不被接受」处理(与 M31 语义一致)。
Err(error @ AuthError::Response(_)) => {
let interception = matches!(
&error,
AuthError::Response(detail) if detail.is_interception()
);
if interception {
Err(error)
} else {
Ok(false)
}
}
Err(other) => Err(other),
}
}
/// 需要就刷新(本地判定,不打网络),返回 `(会话, 是否刷新过)`。
///
/// 这是 GUI/CLI 的「静默续期」入口:`Reuse` 时零网络,`Refresh` 时整链重跑。
///
/// # Errors
///
/// 刷新失败时原样上抛(含 [`AuthError::CredentialExpired`])。
pub async fn ensure_fresh(
&self,
session: &MicrosoftSession,
) -> AuthResult<(MicrosoftSession, Option<RefreshReason>)> {
match session.refresh_decision(now_ms()) {
RefreshDecision::Reuse => Ok((session.clone(), None)),
RefreshDecision::Refresh(reason) => {
let refreshed = self.refresh(session).await?;
Ok((refreshed, Some(reason)))
}
}
}
}
/// 当前 Unix 毫秒。
#[must_use]
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::{now_ms, MicrosoftAuth, CLIENT_ID_ENV};
use crate::auth::error::AuthError;
#[test]
fn explicit_client_id_builds() {
let auth = MicrosoftAuth::new("0000-test-client").expect("构造");
assert!(auth
.endpoints()
.profile
.contains("api.minecraftservices.com"));
}
#[test]
fn missing_client_id_is_explained() {
// 测试进程里也可能被外部配了环境变量,先摘掉再验。
let saved = std::env::var(CLIENT_ID_ENV).ok();
std::env::remove_var(CLIENT_ID_ENV);
let result = MicrosoftAuth::from_env();
if let Some(value) = saved {
std::env::set_var(CLIENT_ID_ENV, value);
}
match result {
Err(AuthError::Unsupported(message)) => assert!(message.contains(CLIENT_ID_ENV)),
// 构建期注入过 client id 时唯一合法,跳过断言。
Ok(_) => assert!(super::CLIENT_ID_BUILD.is_some()),
Err(other) => panic!("应是 Unsupported,实际 {other:?}"),
}
}
#[test]
fn now_ms_is_sane() {
// 2020-01-01 之后、2100 之前(防止本地时钟/单位抄错)。
let now = now_ms();
assert!(now > 1_577_836_800_000);
assert!(now < 4_102_444_800_000);
}
}
+231
View File
@@ -0,0 +1,231 @@
//! 微软会话与刷新语义:**HMCL 三层判定的类型化版本**(`analyse/04` §5.3 / §3.5 / §8.4)。
//!
//! 三层判定原样平移:
//! 1. `has_profile_name()`:没有玩家名就没法启动(`--username` 不能为空);
//! 2. `not_after`:本地时间戳过期即视为未登录(省一次网络);
//! 3. 进程内 `authenticated` 标记归宿主(GUI/CLI)持有,不在本类型里。
//!
//! 关键语义(照 HMCL,别自己发明):
//! - 存下来的 `refresh_token` 是 **Live(MSA)** 的,`access_token` 是 **Minecraft** 的;
//! - 刷新 = 整条链重跑(Live refresh → XBL → XSTS → login_with_xbox → entitlements → profile),
//! 不存在「只刷 MC token」的捷径;
//! - `profile_name` 属于**可变的私有缓存**(2022 年后允许改名),元数据里只留不可变的 profileID。
use serde::{Deserialize, Serialize};
use crate::auth::error::{AuthError, AuthResult};
use crate::auth::secret::Secret;
use crate::launch::request::AuthPlaceholders;
/// 微软账户的 launch 用户类型(`AuthInfo.USER_TYPE_MSA`)。
pub const USER_TYPE_MSA: &str = "msa";
/// 一次成功登录后的会话(= 该账户的凭据载荷)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MicrosoftSession {
/// Minecraft 档案 UUID(32 位无连字符)。**不可变**。
pub profile_id: String,
/// 玩家名(可空串:磁盘恢复允许「有档案但名字未知」,登录时补齐)。
#[serde(default)]
pub profile_name: String,
/// MC token 类型(`Bearer`)。
pub token_type: String,
/// Minecraft access token。
pub access_token: Secret,
/// Live refresh token(轮换:每次刷新都会被新值整体覆盖)。
pub refresh_token: Secret,
/// MC token 过期时刻(Unix 毫秒;0 = 未知 → 强制刷新)。
pub not_after_ms: u64,
/// Xbox user id(`XUID`,诊断/排障用,非凭据)。
pub user_id: Option<String>,
}
/// 过期判定的结论。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshDecision {
/// 本地认为可用(有玩家名且未过期):直接复用,不打网络。
Reuse,
/// 需要整链重跑。
Refresh(RefreshReason),
}
/// 为什么要刷新。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshReason {
/// 玩家名缺失(改名或历史档案)——启动参数拿不到 `--username`。
MissingProfileName,
/// 本地时间戳已过期。
Expired,
/// 名字缺失**且**过期(两条理由都成立)。
MissingProfileNameAndExpired,
}
impl RefreshReason {
/// 人读一行。
#[must_use]
pub const fn message(self) -> &'static str {
match self {
Self::MissingProfileName => "会话缺少玩家名,需要刷新",
Self::Expired => "Minecraft 令牌已过期,需要刷新",
Self::MissingProfileNameAndExpired => "会话缺少玩家名且令牌已过期,需要刷新",
}
}
}
impl MicrosoftSession {
/// 构造(`expires_in` 是秒,`now_ms` 是当前 Unix 毫秒)。
#[must_use]
pub fn new(
profile_id: impl Into<String>,
profile_name: impl Into<String>,
access_token: Secret,
refresh_token: Secret,
expires_in_secs: u64,
now_ms: u64,
user_id: Option<String>,
) -> Self {
Self {
profile_id: profile_id.into(),
profile_name: profile_name.into(),
token_type: "Bearer".to_string(),
access_token,
refresh_token,
not_after_ms: now_ms.saturating_add(expires_in_secs.saturating_mul(1000)),
user_id,
}
}
/// 有没有玩家名(空串/全空白都算没有)。
#[must_use]
pub fn has_profile_name(&self) -> bool {
!self.profile_name.trim().is_empty()
}
/// 本地是否过期(`not_after_ms == 0` 视为过期,承 HMCL「坏数据强制刷新」)。
#[must_use]
pub fn is_expired(&self, now_ms: u64) -> bool {
self.not_after_ms == 0 || now_ms >= self.not_after_ms
}
/// 刷新判定(纯函数,可单测;不打网络)。
#[must_use]
pub fn refresh_decision(&self, now_ms: u64) -> RefreshDecision {
match (self.has_profile_name(), self.is_expired(now_ms)) {
(true, false) => RefreshDecision::Reuse,
(false, false) => RefreshDecision::Refresh(RefreshReason::MissingProfileName),
(true, true) => RefreshDecision::Refresh(RefreshReason::Expired),
(false, true) => RefreshDecision::Refresh(RefreshReason::MissingProfileNameAndExpired),
}
}
/// 该会话所有凭据值(武装脱敏器用)。
#[must_use]
pub fn secrets(&self) -> Vec<&Secret> {
vec![&self.access_token, &self.refresh_token]
}
/// 转成 launch 的占位符集合(`user_properties` 为 `{}`,承 HMCL)。
///
/// # Errors
///
/// 会话缺少玩家名/UUID 时返回 [`AuthError::CredentialExpired`]——此时必须刷新,
/// 不能拿空名字去拼命令行(`--username` 空串会导致游戏直接退出)。
pub fn to_placeholders(&self) -> AuthResult<AuthPlaceholders> {
if !self.has_profile_name() {
return Err(AuthError::CredentialExpired);
}
if self.profile_id.is_empty() {
return Err(AuthError::Malformed("会话缺少 profileID".to_string()));
}
Ok(AuthPlaceholders {
player_name: self.profile_name.clone(),
uuid: self.profile_id.clone(),
access_token: self.access_token.expose().to_string(),
user_type: USER_TYPE_MSA.to_string(),
user_properties: "{}".to_string(),
})
}
/// 刷新后守住两条不变量(`analyse/04` §5.3 的 `WrongAccountException` / `hasProfileName`)。
///
/// 返回 `false` 表示「刷出来的不是同一个档案」,调用方必须当错误处理。
#[must_use]
pub fn same_profile(&self, other: &Self) -> bool {
self.profile_id == other.profile_id
}
}
#[cfg(test)]
mod tests {
use super::{MicrosoftSession, RefreshDecision, RefreshReason};
use crate::auth::secret::Secret;
fn session(name: &str, not_after_ms: u64) -> MicrosoftSession {
MicrosoftSession {
profile_id: "0f8fad5bd9cb469fa16570867728950e".to_string(),
profile_name: name.to_string(),
token_type: "Bearer".to_string(),
access_token: Secret::new("mc-token-value"),
refresh_token: Secret::new("live-refresh-value"),
not_after_ms,
user_id: None,
}
}
#[test]
fn decision_matrix() {
let now = 1_000_000;
assert_eq!(
session("Steve", now + 1).refresh_decision(now),
RefreshDecision::Reuse
);
assert_eq!(
session("", now + 1).refresh_decision(now),
RefreshDecision::Refresh(RefreshReason::MissingProfileName)
);
assert_eq!(
session("Steve", now - 1).refresh_decision(now),
RefreshDecision::Refresh(RefreshReason::Expired)
);
assert_eq!(
session(" ", now).refresh_decision(now),
RefreshDecision::Refresh(RefreshReason::MissingProfileNameAndExpired)
);
}
#[test]
fn zero_not_after_forces_refresh() {
assert_eq!(
session("Steve", 0).refresh_decision(5),
RefreshDecision::Refresh(RefreshReason::Expired)
);
}
#[test]
fn new_computes_not_after_from_expires_in() {
let built = MicrosoftSession::new(
"uuid",
"Steve",
Secret::new("a"),
Secret::new("r"),
86_400,
1_000,
None,
);
assert_eq!(built.not_after_ms, 1_000 + 86_400_000);
assert_eq!(built.token_type, "Bearer");
}
#[test]
fn placeholders_need_a_name() {
assert!(session("", 10).to_placeholders().is_err());
let placeholders = session("Steve", 10).to_placeholders().expect("占位符");
assert_eq!(placeholders.player_name, "Steve");
assert_eq!(placeholders.user_type, "msa");
assert_eq!(placeholders.user_properties, "{}");
// token 进了占位符(启动需要),但它自己的 Debug 是脱敏的。
assert_eq!(placeholders.access_token, "mc-token-value");
assert!(!format!("{:?}", session("Steve", 10)).contains("mc-token-value"));
}
}