用户实跑 debug 二进制看到「本期未支持」,误以为功能没做;实际是构建/启动时 没注入 Azure 公共客户端 id(登录三轨已实现、白名单已批、真机验签过)。 - core/auth/microsoft/client_id.rs(新):id 的来源判定(env > build,空白值不算配)、 `ClientIdStatus`/`log_line()` 那行 `[auth] msa client_id=… source=…`、以及 「未配置 + 怎么配(含可照抄命令)」的共用文案 `MISSING_CLIENT_ID_MESSAGE`。零网络。 - core/auth/error.rs:`AuthError::Unsupported` 不再统一拼「本期未支持:」前缀——该变体 全仓唯一构造点就是缺 client id,硬拼前缀让「没配置」被读成「没实现」。`code()` 仍是 `unsupported`,枚举形状与其它分支不动(tower 批的最小改动)。 - core/auth/microsoft/mod.rs:`from_env()` 走 `resolve_client_id()`,失败措辞取自上面那条 文案,且在网络之前早退(连 HTTP 客户端都不建);导出 `client_id_status()`。 - cli:`map_auth_error` 的 Unsupported hint 给两条能照抄的命令;`accounts` 帮助说清 「不是功能缺失」与两种注入方式;新增 `msa_client_id_log_line()` 给 GUI 侧出面。 - app-iced:`on_boot` 打一行 `[auth] msa client_id=… source=…`(判据来自 core,不重算)。 - i18n:`LaunchMsaNotConfigured` 中英各一条,golden 按 `Key::ALL` 顺序在 `LaunchFailedWithCode` 之后追加一行。 本地便利路径(gitignored,随报告交付):`DevDocs/ops/ull-msa-run.sh` 读本地 id 文件 或 `azure-msa-app.md` 注入环境变量再起 GUI,脚本本身不含 id。
367 lines
14 KiB
Rust
367 lines
14 KiB
Rust
//! 微软轨:device-code 与授权码(浏览器)两条登录流 + 会话/刷新语义
|
||
//! (`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5、`research/08` §3)。
|
||
//!
|
||
//! 分六块,各管一件事:
|
||
//! - [`client_id`]:Azure 公共客户端 id 的**配置状态**(来源 / 配没配 / 启动日志那一行,零网络);
|
||
//! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测);
|
||
//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock);
|
||
//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层);
|
||
//! - [`authcode`] + [`callback`]:授权码流(授权 URL + 本地 HTTP 回调服务器 + code 换 token);
|
||
//! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。
|
||
//!
|
||
//! 两条流只差「怎么拿到 MS access_token」:拿到之后都进同一个 [`chain::complete_chain`],
|
||
//! 落盘/刷新也共用 [`MicrosoftSession`],所以 GUI 账户页的两颗按钮不会长出两套凭据语义。
|
||
//!
|
||
//! # client id 从哪来
|
||
//!
|
||
//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(且租户是 `consumers`、
|
||
//! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的
|
||
//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由
|
||
//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
|
||
//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`],文案是
|
||
//! [`client_id::MISSING_CLIENT_ID_MESSAGE`](「未配置」+ 怎么配,见 [`client_id`])——
|
||
//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。
|
||
//!
|
||
//! 措辞纪律(M117):id 缺失是**没配置**,不是「本期未支持」。判定与那行启动日志都在
|
||
//! [`client_id`],本模块只消费结论。
|
||
|
||
pub mod authcode;
|
||
pub mod callback;
|
||
pub mod chain;
|
||
pub mod client_id;
|
||
pub mod endpoints;
|
||
pub mod flow;
|
||
|
||
pub mod session;
|
||
|
||
pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri};
|
||
pub use callback::CallbackServer;
|
||
// client id 的判定与启动日志行留一层 re-export:调用方(CLI / GUI / 启动日志)只认
|
||
// `auth::microsoft::*` 这一条路径,不必知道它是单独一块(M117)。
|
||
pub use client_id::{
|
||
client_id_status, resolve_client_id, ClientIdSource, ClientIdStatus, CLIENT_ID_BUILD,
|
||
CLIENT_ID_ENV, MISSING_CLIENT_ID_MESSAGE,
|
||
};
|
||
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;
|
||
|
||
/// 微软账户登录/刷新。
|
||
#[derive(Debug, Clone)]
|
||
pub struct MicrosoftAuth {
|
||
client: Client,
|
||
client_id: String,
|
||
endpoints: MicrosoftEndpoints,
|
||
poll: PollPolicy,
|
||
authcode: AuthCodePolicy,
|
||
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(),
|
||
authcode: AuthCodePolicy::default(),
|
||
journal: None,
|
||
})
|
||
}
|
||
|
||
/// 从环境/构建期取 client id(CLI 与 GUI 的默认入口)。
|
||
///
|
||
/// 与 [`Self::new`] 的唯一差别:这条**生产入口**默认挂上便携数据根的失败日志
|
||
/// (`.ULL/logs/auth.jsonl`,见 [`AuthJournal::portable_default`]),
|
||
/// 交互式命令失败时 403 的状态码/正文摘要不会再随终端滚走。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// 两处都没配时返回 [`AuthError::Unsupported`],文案是
|
||
/// [`MISSING_CLIENT_ID_MESSAGE`](「未配置」+ 怎么配)。这条路径**零网络**:
|
||
/// id 判定在网络之前,判断失败就地早退,连 HTTP 客户端都不建。
|
||
pub fn from_env() -> AuthResult<Self> {
|
||
let Some((client_id, _source)) = resolve_client_id() else {
|
||
return Err(AuthError::Unsupported(
|
||
MISSING_CLIENT_ID_MESSAGE.to_string(),
|
||
));
|
||
};
|
||
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
|
||
}
|
||
|
||
/// 换授权码流参数(回调超时 / 固定端口 / 绑定重试 / 固定 state;测试与 CLI 用)。
|
||
#[must_use]
|
||
pub fn with_authcode_policy(mut self, policy: AuthCodePolicy) -> Self {
|
||
self.authcode = policy;
|
||
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
|
||
}
|
||
|
||
/// 授权码流登录(浏览器 + 本地回调,`research/08` §3 方案 A,GUI 的默认入口)。
|
||
///
|
||
/// `present` 在端口绑定之后、等待回调之前被调用:宿主在这一刻拉起系统默认浏览器
|
||
/// (core 不做 UI,也不假设有 GUI——CLI 可以只把 URL 打到 stderr)。
|
||
/// 失败时与 [`Self::device_code_login`] 一样往失败日志追加一条结构化记录(M38)。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// 端口绑不上/网络失败、等回调超时、用户在授权页取消、OAuth 拒绝,以及整链的
|
||
/// [`AuthError`](含白名单未批时 `login_with_xbox` 的 403 诊断)。
|
||
pub async fn authcode_login<F>(&self, present: F) -> AuthResult<MicrosoftSession>
|
||
where
|
||
F: FnOnce(&AuthCodePrompt),
|
||
{
|
||
let result = authcode::login(
|
||
&self.client,
|
||
&self.endpoints,
|
||
&self.client_id,
|
||
self.authcode.clone(),
|
||
present,
|
||
)
|
||
.await;
|
||
if let Err(error) = &result {
|
||
self.record_failure("authcode_login", error);
|
||
}
|
||
result
|
||
}
|
||
|
||
/// 刷新: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};
|
||
|
||
#[test]
|
||
fn explicit_client_id_builds() {
|
||
let auth = MicrosoftAuth::new("0000-test-client").expect("构造");
|
||
assert!(auth
|
||
.endpoints()
|
||
.profile
|
||
.contains("api.minecraftservices.com"));
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|