Files
Unlucky-Launcher/crates/core/tests/auth/microsoft_authcode.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

240 lines
9.4 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.
//! 授权码流(`research/08` §3 方案 A):本地回调服务器 + 授权 URL + code 换链。
//!
//! 覆盖验收口径:授权码换到 MS token → XBL → XSTS 全通;白名单未批前
//! `login_with_xbox` 的 403 只要求**诊断文案正确**(M38 版),不绕过。
use std::time::Duration;
use serde_json::json;
use unlucky_core::auth::microsoft::{
AuthCodePolicy, AuthCodePrompt, CallbackServer, MicrosoftAuth, MicrosoftEndpoints,
};
use unlucky_core::auth::AuthError;
use crate::support::{full_routes, response_of, AuthMock, MockReply};
const CLIENT_ID: &str = "test-client-id";
fn policy(state: &str, timeout_ms: u64) -> AuthCodePolicy {
AuthCodePolicy {
callback_timeout: Duration::from_millis(timeout_ms),
bind_attempts: 3,
port: None,
state: Some(state.to_string()),
}
}
/// 模拟浏览器:把 `code` 送到本地回调地址(真发一次 HTTP,走 localhost 解析)。
async fn deliver(redirect_uri: &str, query: &str) {
let url = format!("{redirect_uri}?{query}");
// 回调服务器可能在 accept 中,给一拍让绑定先落地。
tokio::time::sleep(Duration::from_millis(10)).await;
let response = reqwest::get(&url).await.expect("浏览器回调");
assert_eq!(response.status(), 200, "回调页应回 200");
let page = response.text().await.expect("回调页正文");
assert!(page.contains("登录完成"), "回调页应是给用户看的完成页");
}
#[tokio::test]
async fn authcode_login_completes_the_chain() {
let mock = AuthMock::start(full_routes()).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_authcode_policy(policy("state-abc", 5_000));
let mut prompted: Option<AuthCodePrompt> = None;
let session = auth
.authcode_login(|prompt| {
prompted = Some(prompt.clone());
let redirect = prompt.redirect_uri.clone();
tokio::spawn(
async move { deliver(&redirect, "code=the-auth-code&state=state-abc").await },
);
})
.await
.expect("授权码登录成功");
// 1. 宿主拿到的是「授权 URL + 本地回调地址」,两者端口必须一致。
let prompt = prompted.expect("必须回调展示授权 URL");
let redirect = prompt.redirect_uri.clone();
assert!(redirect.starts_with("http://localhost:"), "{redirect}");
assert!(redirect.ends_with("/auth"), "{redirect}");
assert!(prompt.authorization_url.contains("prompt=select_account"));
// 端点表在测试里整体指向 mock,所以授权页的路径是 `/authorize`(租户口径见向量单测)。
assert!(
prompt.authorization_url.starts_with(&mock.base())
&& prompt
.authorization_url
.contains("/authorize?client_id=test-client-id"),
"{}",
prompt.authorization_url
);
assert!(prompt.authorization_url.contains("state=state-abc"));
assert_eq!(prompt.timeout, Duration::from_millis(5_000));
// 2. code 换 token 的表单:grant_type + 原样重定向 URI(URI 严格一致那条坑)。
let token_bodies = mock.bodies_on("/token");
assert_eq!(token_bodies.len(), 1, "只该换一次 token");
let body = &token_bodies[0];
assert!(body.contains("grant_type=authorization_code"), "{body}");
assert!(body.contains("code=the-auth-code"), "{body}");
assert!(
body.contains("redirect_uri=http%3A%2F%2Flocalhost%3A") && !body.contains(&redirect),
"redirect_uri 必须是编码后的同一字面量:{body}"
);
assert!(
body.contains("scope=XboxLive.signin+offline_access")
|| body.contains("scope=XboxLive.signin%20offline_access"),
"{body}"
);
// 授权 URL 里的 redirect_uri 与 token 请求里的必须是同一个端口。
let encoded = redirect.replace(':', "%3A").replace('/', "%2F");
assert!(
prompt.authorization_url.contains(&encoded),
"{}",
prompt.authorization_url
);
assert!(body.contains(&encoded), "{body}");
// 3. 后链与会话:与设备码流同一套字段语义。
assert_eq!(session.profile_name, "Steve");
assert_eq!(session.access_token.expose(), "mc-access-token-value");
assert_eq!(session.refresh_token.expose(), "live-refresh-token-value");
assert!(!format!("{session:?}").contains("mc-access-token-value"));
assert_eq!(mock.bodies_on("/xbl").len(), 1);
assert_eq!(mock.bodies_on("/xsts").len(), 1);
mock.stop();
}
#[tokio::test]
async fn callback_server_ignores_unrelated_requests_and_bad_state() {
let server = CallbackServer::bind(None, 1, "state-ok")
.await
.expect("绑定");
let port = server.port();
let browser = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let base = format!("http://localhost:{port}");
// favicon / 浏览器预热:404 但绝不能结束等待。
let favicon = reqwest::get(format!("{base}/favicon.ico"))
.await
.expect("favicon");
assert_eq!(favicon.status(), 404);
// state 不匹配(旧标签页的迟到回调):400,继续等。
let stale = reqwest::get(format!("{base}/auth?code=stale-code&state=state-old"))
.await
.expect("迟到的回调");
assert_eq!(stale.status(), 400);
let good = reqwest::get(format!("{base}/auth?code=good-code&state=state-ok"))
.await
.expect("真回调");
assert_eq!(good.status(), 200);
});
let code = server
.wait_for_code(Duration::from_secs(5))
.await
.expect("应拿到 code");
assert_eq!(code, "good-code");
browser.await.expect("模拟浏览器任务");
}
#[tokio::test]
async fn callback_timeout_is_reported() {
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_authcode_policy(policy("state-abc", 30));
let error = auth
.authcode_login(|_prompt| {})
.await
.expect_err("没人回调就该超时");
assert_eq!(error.code(), "timeout");
assert!(
error.message().contains("浏览器回调"),
"{}",
error.message()
);
}
#[tokio::test]
async fn access_denied_on_the_callback_page_is_canceled() {
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_authcode_policy(policy("state-abc", 5_000));
let error = auth
.authcode_login(|prompt| {
let redirect = prompt.redirect_uri.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(10)).await;
let url = format!("{redirect}?error=access_denied&state=state-abc");
let _ = reqwest::get(&url).await;
});
})
.await
.expect_err("用户取消");
assert_eq!(error, AuthError::Canceled);
}
#[tokio::test]
async fn busy_port_retries_then_reports_which_port() {
// 先占住一个端口,再让回调服务器去绑同一个:必须失败并说清是哪个端口。
let occupied = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("占端口");
let port = occupied.local_addr().expect("地址").port();
let error = CallbackServer::bind(Some(port), 2, "s")
.await
.expect_err("端口已被占");
assert_eq!(error.code(), "network");
assert!(
error.message().contains(&port.to_string()),
"文案要指出端口:{}",
error.message()
);
assert!(error.message().contains("重试 2 次"), "{}", error.message());
// 同一台机器上换个端口就能绑上(重试机制不是把错误吞掉)。
let ok = CallbackServer::bind(None, 1, "s").await.expect("内核分配");
assert_ne!(ok.port(), port);
}
#[tokio::test]
async fn login_with_xbox_403_keeps_the_m38_diagnostic() {
// 白名单未批的真机状态:授权码换到 token、XBL/XSTS 全通,最后一步 403。
let mut routes = full_routes();
let (_, login) = routes
.iter_mut()
.find(|(path, _)| *path == "/login_with_xbox")
.expect("路由表里有 login_with_xbox");
*login = vec![MockReply::json_status(
403,
json!({ "path": "/authentication/login_with_xbox", "errorType": "FORBIDDEN",
"error": "Invalid app registration" }),
)];
let mock = AuthMock::start(routes).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_authcode_policy(policy("state-abc", 5_000));
let error = auth
.authcode_login(|prompt| {
let redirect = prompt.redirect_uri.clone();
tokio::spawn(async move { deliver(&redirect, "code=c&state=state-abc").await });
})
.await
.expect_err("403 必须冒出来");
let detail = response_of(&error);
assert_eq!(detail.status, 403);
assert_eq!(detail.detail_code(), "login_with_xbox_403");
// 「授权码这条流本身是通的」——这正是验收止步的位置。
assert_eq!(mock.bodies_on("/token").len(), 1);
assert_eq!(mock.bodies_on("/xbl").len(), 1);
assert_eq!(mock.bodies_on("/xsts").len(), 1);
let message = error.message();
assert!(message.contains("Minecraft 登录被拒"), "{message}");
assert!(message.contains("Invalid app registration"), "{message}");
mock.stop();
}