feat(core): 加 MSA 授权码流(本地回调服务器)并接到账户页

research/08 方案 A:GUI 的微软登录改走授权码流(HMCL 主流做法),设备码流保留给
CLI/无浏览器场景。两条流只差「怎么拿到 MS access_token」,拿到之后共用
chain::complete_chain 与 MicrosoftSession,落盘/刷新语义没有第二套。

core(crates/core/src/auth/microsoft/)
- endpoints:新增 authorize(…/consumers/oauth2/v2.0/authorize),与 token 同租户。
- authcode:RedirectUri 是 http://localhost:{port}/auth 的唯一来源(授权 URL、
  token 请求体、实际监听三处共用,坑 5);AuthCodePolicy(回调超时 300s / 绑定重试 3 次 /
  固定端口 / 固定 state);authorization_url(response_type=code + prompt=select_account +
  scope=XboxLive.signin%20offline_access);exchange_code 复用 flow 的 oauth_error/tokens_from。
- callback:本地 HTTP/1.1 回调服务器。内核分配端口(占用按次数退避重试);非 /auth 路径
  (favicon、预连)404 后继续等;state 不匹配 400 后继续等;error=access_denied 归 Canceled;
  单条连接读头超时 5s,半开连接拖不垮时间预算。
- MicrosoftAuth::authcode_login(present):绑端口 → present(宿主开浏览器)→ 等回调 →
  换 token → 整链;失败与设备码流一样落 M38 失败日志。

app(crates/app)
- AppCommand::StartBrowserLogin + host::accounts::start_browser_login;设备码/授权码共用
  LoginJob 取消槽位、收尾入库(store_session)与面板 DTO。
- host::opener 用系统默认浏览器打开授权 URL(Windows rundll32 url.dll,FileProtocolHandler)。
- 账户页:主按钮「微软登录」走授权码流,「设备码登录」作备用;DeviceLoginView::browser_waiting
  在面板里给出可复制的授权链接(user_code 留空)。

测试
- crates/core/tests/auth/microsoft_authcode.rs:授权 URL 向量(租户/编码/prompt)、
  URI 一致性(授权 URL 与 token 表单同字面量)、mock 全链、favicon+陈旧 state 后仍拿到 code、
  端口占用重试文案、超时、access_denied→Canceled、login_with_xbox 403 的 M38 诊断
  (detail_code=login_with_xbox_403,真机白名单未批时验收止步于此)。
- 单测:authcode 编码/向量、callback query 解码与错误分流、DTO browser_waiting、conv 面板。

未做/未验证:真机完整登录需 Azure 公共客户端 id(ULL_MSA_CLIENT_ID)与 Minecraft API 许可,
本机只能到「授权页 302 到 login.live.com 且 redirect_uri/scope/prompt 原样保留」这一步。

AskaEth <i@yeij.top>
This commit is contained in:
AskaEth committed 2026-11-01 08:33:11 +08:00
1 parent f62bb00494
commit 8194e38bcf
22 files changed
+1188 -80

No files matched your search

+50 -3
View File
@@ -1,25 +1,35 @@
//! 微软轨:device-code 全流程 + 会话/刷新语义(`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5)。
//! 微软轨:device-code 与授权码(浏览器)两条登录流 + 会话/刷新语义
//! (`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5、`research/08` §3)。
//!
//! 分四块,各管一件事:
//! 分五块,各管一件事:
//! - [`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 从哪来
//!
//! device-code 流必须带一个**已在 Azure 注册的公共客户端 id**。HMCL 把它烘进 jar 的
//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(且租户是 `consumers`、
//! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的
//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由
//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`] 并说清要配什么——
//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。
pub mod authcode;
pub mod callback;
pub mod chain;
pub mod endpoints;
pub mod flow;
pub mod session;
pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri};
pub use callback::CallbackServer;
pub use endpoints::MicrosoftEndpoints;
pub use flow::{DeviceCodePrompt, PollPolicy};
pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA};
@@ -45,6 +55,7 @@ pub struct MicrosoftAuth {
client_id: String,
endpoints: MicrosoftEndpoints,
poll: PollPolicy,
authcode: AuthCodePolicy,
journal: Option<AuthJournal>,
}
@@ -62,6 +73,7 @@ impl MicrosoftAuth {
client_id: client_id.into(),
endpoints: MicrosoftEndpoints::consumers(),
poll: PollPolicy::default(),
authcode: AuthCodePolicy::default(),
journal: None,
})
}
@@ -131,6 +143,13 @@ impl MicrosoftAuth {
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 {
@@ -177,6 +196,34 @@ impl MicrosoftAuth {
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