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
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:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user