Files
Unlucky-Launcher/crates/core/src/auth/microsoft/flow.rs
T
AskaEth 8194e38bcf 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>
2026-11-01 08:33:11 +08:00

314 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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)
}
pub(crate) 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 只在两个字段都非空时处理,我们收紧)。
pub(crate) 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,
}
}
}