reviewer P2(crates/core/src/auth/microsoft/callback.rs):handle() 先处理 error 参数、 之后才校验 state,于是任意一次 `GET http://localhost:{port}/auth?error=access_denied` (state 随便给甚至不给)都会让 wait_for_code 直接返回 Canceled,登录被静默中止、账户不入库 —— 不需要知道随机 state、不需要 code、不需要任何凭据。违反 RFC 6749 §4.1.2.1 / RFC 9207 (错误响应同样要校验 state),也与本模块「与本次登录无关的请求一律继续等待」的声明矛盾。 - state 校验提到 error 之前:state 不匹配的请求(无论带不带 error)一律 400 后继续等; 只有带**本机 state** 的 access_denied 才判 Canceled。模块文档第 4 条同步改写。 - 顺带采纳 reviewer 的两条非计数观察: * 读头预算改为 accept 之后按 deadline 重算(原来复用 accept 前的 remaining, 总时长可超发 ≤5s);预算归零直接超时。 * state 由 64 位升到 128 位(两个独立随机种子的 SipHash 输出拼接),对齐 RFC 6749 §10.10 的熵建议;新增单测断言 32 位 hex 且每次不同。 - 新增回归用例(crates/core/tests/auth/microsoft_authcode.rs): `foreign_state_cannot_cancel_the_login` —— 先发 `?error=access_denied`(无 state) 再发 `?error=access_denied&state=not-our-state`,两次都必须 400 且服务器继续等待, 随后真回调拿到 code;`access_denied_with_our_state_is_still_canceled` 钉住另一半语义。 既有的「陈旧 state 回 400 后继续等」用例保持通过。 门禁:cargo fmt --all --check OK;cargo clippy --workspace --all-targets -- -D warnings exit 0 无告警;cargo test -p unlucky-core -p unlucky-app 18 个 suite 全绿(auth 59 例)。 AskaEth <i@yeij.top>
296 lines
12 KiB
Rust
296 lines
12 KiB
Rust
//! 授权码流(`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 foreign_state_cannot_cancel_the_login() {
|
||
// P2 回归:回调先校验 state,再处理 error(RFC 6749 §4.1.2.1 / RFC 9207)。
|
||
// 修之前 `GET /auth?error=access_denied`(state 随便给或不给)就能把登录判成 Canceled。
|
||
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}/auth");
|
||
// 1. 连 state 都不给:与本次登录无关 → 400,继续等。
|
||
let bare = reqwest::get(format!("{base}?error=access_denied"))
|
||
.await
|
||
.expect("伪造取消");
|
||
assert_eq!(bare.status(), 400);
|
||
// 2. state 明显不匹配:同样忽略,绝不当成用户取消。
|
||
let foreign = reqwest::get(format!("{base}?error=access_denied&state=not-our-state"))
|
||
.await
|
||
.expect("伪造取消带错 state");
|
||
assert_eq!(foreign.status(), 400);
|
||
// 3. 真正带对 state 的回调仍然能完成登录。
|
||
let good = reqwest::get(format!("{base}?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("伪造的 access_denied 不能中止登录");
|
||
assert_eq!(code, "good-code");
|
||
browser.await.expect("模拟浏览器任务");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn access_denied_with_our_state_is_still_canceled() {
|
||
// 另一半语义:用户真的在授权页点了取消(带本机 state)→ 仍然是 Canceled。
|
||
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 url = format!("http://localhost:{port}/auth?error=access_denied&state=state-ok");
|
||
let _ = reqwest::get(url).await;
|
||
});
|
||
let error = server
|
||
.wait_for_code(Duration::from_secs(5))
|
||
.await
|
||
.expect_err("本机 state 的取消要生效");
|
||
assert_eq!(error, AuthError::Canceled);
|
||
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();
|
||
}
|