复审 r1 的 P1-1:「用浏览器登录」这颗按钮在生产二进制里是哑的——`start_browser_login` 用的是
共享的 `MicrosoftAuth::from_env()`(`browser_first=false`),于是 `browser_then_device_login`
里整段预检 / 回退 / 说明编排不执行,按钮静默退化成设备码,任务③(authcode 可选加速路)
实际未交付。
- P1-1:入口改成 `msa_auth(env, MsaEntry::{DeviceCode,Browser})`,浏览器入口走
`.with_browser_first(true)`。守卫钉两段:①调用点(按 `LoginJob` 分桶记下生产入口实际请求的
模式,避开同进程并发用例串台)②模式真的落到 `MicrosoftAuth::browser_first`。变异实测:
把浏览器入口改回 `DeviceCode` ⇒ 该用例红。核心侧另有报文级证据:浏览器优先路必然对
`/authorize` 发一次预检(`unregistered_redirect_falls_back_to_the_device_code` 断言 GET 计数 == 1)。
- P2-1:`listeners_opened` 改成**绑定现场**计数——`CallbackServer::bind_counting` 在
`TcpListener::bind` 前 +1,`login_counting` 把 per-login 的 `AtomicUsize` 传下去;
删掉「进了授权码分支就记 1」的代理指标。复做变异(预检之前偷绑一个监听口)⇒ 3 条
msalogin 用例红,包括两条 `listeners_opened == 0`。两处 stale 文档(引用不存在的
`callback::bind_attempts()`)改成引用 `listeners_opened` / `bind_counting`。
- P2-2:预检加整体 deadline(10s,`PREFLIGHT_TIMEOUT`),超时归 `PreflightUnavailable` 并
回退设备码;新增「接了连接却不出响应」的假服务器单测(200ms deadline 内必须收场)。
(下载引擎的 HTTP 客户端只设了 connect_timeout,这条兜住「header 之后停住」。)
- P2-3:client_id 守卫加严到三种拼法(`concat!` / 跨行两段 / `format!` 逐段拼),做法是
「先收字符串字面量再拼」+「连续纯 hex 字面量累计 ≥32 且 ≥2 段」,并跳过注释(否则本模块
文档里的示例会自指假红);每条一个自检向量,且不误杀 `session.rs` 的 profile UUID。
- P2-4:页面级断言补中文(`Lang::ZhCn` 走 `Page::build`),并把回退说明的文案出口收敛成
视图自己的 `login::browser_note_text`(视图与取样共用,不再测试另写一份映射)。
- P2-5:主按钮文案改成「使用 Microsoft 账户登录」/「Sign in with your Microsoft account」,
下面补一行「浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。」。
门禁:`cargo fmt --check`、`cargo clippy --workspace --all-targets -- -D warnings`、
`cargo test --workspace --all-targets`(51 个 test 目标全绿)通过;`client_id.rs` 的判据拆到
`client_id/tests.rs` 守 400 行红线。截图三张(入口 / 设备码 / 回退)已重出。
275 lines
12 KiB
Rust
275 lines
12 KiB
Rust
//! M178 守卫:微软登录在**官方产物**(应用注册里没有任何重定向 URI)上的行为。
|
||
//!
|
||
//! 用户 2026-11-08 实测的现象:GUI 直接走授权码流 → 授权端点回
|
||
//! `invalid_request ... 'redirect_uri' is not valid` → 浏览器里一张 AAD 原始错误页,
|
||
//! 启动器干等超时 ⇒ 官方产物上「微软登录」整体不可用。
|
||
//!
|
||
//! 本文件钉四件事(都是能给答案「能不能变红」的):
|
||
//!
|
||
//! | 守卫 | 用例 | 抓到什么 |
|
||
//! |---|---|---|
|
||
//! | ① 回退且提示可读 | [`unregistered_redirect_falls_back_to_the_device_code`] | 回退没发生 / 原因码丢了(页面就只能画原文) |
|
||
//! | ② 不白开监听口 | 同上 + [`device_code_default_never_opens_a_listening_port`] | 预检被挪到绑定之后(`listeners_opened` 会变 1) |
|
||
//! | ③ 注册过就仍能走浏览器 | [`registered_loopback_still_runs_the_auth_code_flow`] | 把加速路彻底焊死(一刀切回退) |
|
||
//! | ④ 取消不被偷偷换流 | [`user_cancel_on_the_browser_page_does_not_fall_back`] | 取消被当成失败的后退理由 |
|
||
//!
|
||
//! 另:**真微软登录没有在这里验**(要真人拿自己账号点一次),这里只证错误处理与回退路径。
|
||
|
||
use std::sync::{Arc, Mutex};
|
||
use std::time::Duration;
|
||
|
||
use unlucky_core::auth::microsoft::{
|
||
AuthCodePolicy, BrowserFallbackReason, MicrosoftAuth, MicrosoftEndpoints,
|
||
};
|
||
use unlucky_core::auth::AuthError;
|
||
|
||
use crate::support::{full_routes, AuthMock, MockReply};
|
||
|
||
const CLIENT_ID: &str = "test-client-id";
|
||
/// 授权端点对**没登记**的回环 redirect_uri 的应答(2026-11-08 实测那张页面的形状)。
|
||
const INVALID_REDIRECT_PAGE: &str =
|
||
"error=invalid_request&error_description=The+provided+value+for+the+input+\
|
||
parameter+%27redirect_uri%27+is+not+valid.+The+expected+value+is+a+URI+which+matches+a+\
|
||
+redirect+URI+registered+for+this+client+application.";
|
||
/// 授权端点对**已登记**回环 URI 的正常应答(登录页,无拒绝字样)。
|
||
const SIGN_IN_PAGE: &str = "<!DOCTYPE html><html><head><title>Sign in to your account</title>\
|
||
</head><body><form id=\"loginForm\"></form></body></html>";
|
||
|
||
/// 设备码面板里看到的 `(用户码, 回退原因码)`(跨闭包共享)。
|
||
type Seen = Arc<Mutex<Option<(String, Option<BrowserFallbackReason>)>>>;
|
||
|
||
fn policy(state: &str) -> AuthCodePolicy {
|
||
AuthCodePolicy {
|
||
callback_timeout: Duration::from_millis(2_000),
|
||
bind_attempts: 1,
|
||
port: None,
|
||
state: Some(state.to_string()),
|
||
}
|
||
}
|
||
|
||
/// 路由表 + 定向的 `/authorize` 应答(预检打的就是这个 path)。
|
||
fn routes_with_authorize(reply: MockReply) -> Vec<(&'static str, Vec<MockReply>)> {
|
||
let mut routes = full_routes();
|
||
routes.push(("/authorize", vec![reply]));
|
||
routes
|
||
}
|
||
|
||
fn auth(base: &str, browser_first: bool) -> MicrosoftAuth {
|
||
MicrosoftAuth::new(CLIENT_ID)
|
||
.expect("构造")
|
||
.with_endpoints(MicrosoftEndpoints::all_on(base))
|
||
.with_poll_policy(crate::support::microsoft::fast_poll())
|
||
.with_authcode_policy(policy("state-abc"))
|
||
.with_browser_first(browser_first)
|
||
}
|
||
|
||
/// 守卫 ① + ②:没登记回环 → 回退设备码、原因码是「没登记」、**监听口一次都不开**。
|
||
#[tokio::test]
|
||
async fn unregistered_redirect_falls_back_to_the_device_code() {
|
||
let mock = AuthMock::start(routes_with_authorize(MockReply::text(
|
||
400,
|
||
INVALID_REDIRECT_PAGE,
|
||
)))
|
||
.await;
|
||
|
||
let seen: Seen = Arc::new(Mutex::new(None));
|
||
let seen_for_device = Arc::clone(&seen);
|
||
let outcome = auth(&mock.base(), true)
|
||
.browser_then_device_login(
|
||
|_prompt| panic!("没登记回环时**不许**弹浏览器(用户只会看到 AAD 错误页)"),
|
||
move |prompt, fallback| {
|
||
*seen_for_device.lock().unwrap() =
|
||
Some((prompt.user_code.clone(), fallback.map(|note| note.reason)));
|
||
},
|
||
)
|
||
.await
|
||
.expect("必须回退到设备码并成功");
|
||
|
||
// ① 回退确实发生,且宿主拿到的是**结构化原因码**(页面据此出可读文案,而不是画原文)
|
||
let (user_code, reason) = seen.lock().unwrap().clone().expect("设备码必须被展示");
|
||
assert_eq!(user_code, "ABCD-1234");
|
||
assert_eq!(reason, Some(BrowserFallbackReason::RedirectNotRegistered));
|
||
assert_eq!(
|
||
reason.map(BrowserFallbackReason::code),
|
||
Some("redirect_not_registered")
|
||
);
|
||
assert_eq!(outcome.session.profile_name, "Steve");
|
||
let note = outcome.browser.expect("必须带回退说明");
|
||
assert_eq!(note.reason, BrowserFallbackReason::RedirectNotRegistered);
|
||
// 原文只进日志:说明里的 detail 有内容,但页面只拿码(i18n 在 app-iced 侧)。
|
||
assert!(!note.detail.is_empty());
|
||
|
||
// ② 预检在绑定之前:这次登录一个监听口都没开(用户机上不会出现「占了端口却没人回调」)
|
||
assert_eq!(
|
||
outcome.listeners_opened, 0,
|
||
"没登记回环时不许绑本地端口(预检必须在绑定之前)"
|
||
);
|
||
// ③ **报文级证据**:预检真的发出去了(授权端点收到 1 次 GET),而不是被跳过。
|
||
// 这条也是复审 P1-1 的回执:只要生产路径把 `browser_first` 置位,这条 GET 就会发生。
|
||
assert_eq!(
|
||
mock.bodies_on("/authorize").len(),
|
||
1,
|
||
"浏览器优先路必须真的发一次回环注册预检"
|
||
);
|
||
mock.stop();
|
||
}
|
||
|
||
/// 守卫 ②(默认路那一半):设备码是默认,浏览器流根本没被启用 ⇒ 监听计数不涨。
|
||
#[tokio::test]
|
||
async fn device_code_default_never_opens_a_listening_port() {
|
||
let mock = AuthMock::start(full_routes()).await;
|
||
let device_seen = Arc::new(Mutex::new(None));
|
||
let device_flag = Arc::clone(&device_seen);
|
||
|
||
let outcome = auth(&mock.base(), false)
|
||
.browser_then_device_login(
|
||
|_| panic!("默认路不许碰浏览器回调"),
|
||
move |prompt, fallback| {
|
||
*device_flag.lock().unwrap() = Some(prompt.user_code.clone());
|
||
assert!(fallback.is_none(), "没走浏览器就不该有回退说明");
|
||
},
|
||
)
|
||
.await
|
||
.expect("设备码登录成功");
|
||
|
||
assert_eq!(
|
||
device_seen.lock().unwrap().clone().as_deref(),
|
||
Some("ABCD-1234")
|
||
);
|
||
assert!(outcome.browser.is_none());
|
||
assert_eq!(
|
||
outcome.session.profile_id,
|
||
crate::support::microsoft::PROFILE_ID
|
||
);
|
||
assert_eq!(outcome.listeners_opened, 0, "设备码流不许开任何本地监听口");
|
||
// 授权端点一次都没被请求(那正是会返回 invalid_request 的那一跳)
|
||
assert!(mock.bodies_on("/authorize").is_empty());
|
||
mock.stop();
|
||
}
|
||
|
||
/// 守卫 ③:回环**真的登记了**的时候,加速路必须还能走(否则就是把功能一刀切焊死)。
|
||
#[tokio::test]
|
||
async fn registered_loopback_still_runs_the_auth_code_flow() {
|
||
let mock = AuthMock::start(routes_with_authorize(MockReply::html(200, SIGN_IN_PAGE))).await;
|
||
|
||
let outcome = auth(&mock.base(), true)
|
||
.browser_then_device_login(
|
||
|prompt| {
|
||
let redirect = prompt.redirect_uri.clone();
|
||
tokio::spawn(async move {
|
||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||
let url = format!("{redirect}?code=the-auth-code&state=state-abc");
|
||
let response = reqwest::get(&url).await.expect("浏览器回调");
|
||
assert_eq!(response.status(), 200);
|
||
});
|
||
},
|
||
|_prompt, _note| panic!("登记了回环就不该回退设备码"),
|
||
)
|
||
.await
|
||
.expect("授权码流应当走通");
|
||
|
||
assert_eq!(outcome.session.profile_name, "Steve");
|
||
assert!(outcome.browser.is_none(), "走通了就不该有回退说明");
|
||
assert_eq!(
|
||
outcome.listeners_opened, 1,
|
||
"登记了回环就应当真的绑端口(否则加速路名存实亡)"
|
||
);
|
||
// 换 token 用的是**授权码** grant(不是设备码那条)。
|
||
let token_bodies = mock.bodies_on("/token");
|
||
assert_eq!(token_bodies.len(), 1, "{token_bodies:?}");
|
||
assert!(token_bodies[0].contains("grant_type=authorization_code"));
|
||
mock.stop();
|
||
}
|
||
|
||
/// 守卫 ④:用户自己取消**不算**失败——不许偷偷换一条流继续要码。
|
||
#[tokio::test]
|
||
async fn user_cancel_on_the_browser_page_does_not_fall_back() {
|
||
let mock = AuthMock::start(routes_with_authorize(MockReply::html(200, SIGN_IN_PAGE))).await;
|
||
let error = auth(&mock.base(), true)
|
||
.browser_then_device_login(
|
||
|prompt| {
|
||
let redirect = prompt.redirect_uri.clone();
|
||
tokio::spawn(async move {
|
||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||
let url = format!("{redirect}?error=access_denied&state=state-abc");
|
||
let _ = reqwest::get(&url).await;
|
||
});
|
||
},
|
||
|_prompt, _note| panic!("用户取消后不许自动换流"),
|
||
)
|
||
.await
|
||
.expect_err("取消应当是最终结果");
|
||
assert_eq!(error, AuthError::Canceled);
|
||
assert!(
|
||
mock.bodies_on("/devicecode").is_empty(),
|
||
"取消之后不该再去要设备码"
|
||
);
|
||
mock.stop();
|
||
}
|
||
|
||
/// 预检问不出结论(连不上)也要回退,且原因码是「没确认」而不是「没登记」。
|
||
#[tokio::test]
|
||
async fn an_unreachable_precheck_falls_back_with_the_unverified_reason() {
|
||
// 只有授权端点指到一个必然关闭的端口:预检必然失败,而设备码那条链照常打 mock。
|
||
let mock = AuthMock::start(full_routes()).await;
|
||
let mut endpoints = MicrosoftEndpoints::all_on(&mock.base());
|
||
endpoints.authorize = "http://127.0.0.1:1/authorize".to_string();
|
||
let reason = Arc::new(Mutex::new(None));
|
||
let reason_flag = Arc::clone(&reason);
|
||
let outcome = MicrosoftAuth::new(CLIENT_ID)
|
||
.expect("构造")
|
||
.with_endpoints(endpoints)
|
||
.with_poll_policy(crate::support::microsoft::fast_poll())
|
||
.with_authcode_policy(policy("state-abc"))
|
||
.with_browser_first(true)
|
||
.browser_then_device_login(
|
||
|_prompt| panic!("预检失败时不许弹浏览器"),
|
||
move |_prompt, fallback| {
|
||
*reason_flag.lock().unwrap() = fallback.map(|note| note.reason);
|
||
},
|
||
)
|
||
.await
|
||
.expect("预检失败不该拦住设备码");
|
||
assert_eq!(
|
||
*reason.lock().unwrap(),
|
||
Some(BrowserFallbackReason::PreflightUnavailable)
|
||
);
|
||
assert_eq!(
|
||
outcome.browser.map(|note| note.reason),
|
||
Some(BrowserFallbackReason::PreflightUnavailable)
|
||
);
|
||
assert_eq!(outcome.session.profile_name, "Steve");
|
||
assert_eq!(outcome.listeners_opened, 0, "预检失败时不该绑端口");
|
||
mock.stop();
|
||
}
|
||
|
||
/// 与上两例配对:`/authorize` 回 200 但正文是 AADSTS50011 的拒绝页,也不许当「已登记」。
|
||
#[tokio::test]
|
||
async fn aadsts50011_page_is_not_treated_as_registered() {
|
||
let mock = AuthMock::start(routes_with_authorize(MockReply::text(
|
||
200,
|
||
"AADSTS50011: The redirect URI specified in the request does not match the redirect URIs configured",
|
||
)))
|
||
.await;
|
||
let note = Arc::new(Mutex::new(None));
|
||
let note_flag = Arc::clone(¬e);
|
||
let outcome = auth(&mock.base(), true)
|
||
.browser_then_device_login(
|
||
|_| panic!("AADSTS50011 的页面不许当已登记"),
|
||
move |_prompt, fallback| {
|
||
*note_flag.lock().unwrap() = fallback.map(|note| note.reason);
|
||
},
|
||
)
|
||
.await
|
||
.expect("回退设备码后应当成功");
|
||
assert_eq!(
|
||
*note.lock().unwrap(),
|
||
Some(BrowserFallbackReason::RedirectNotRegistered)
|
||
);
|
||
assert_eq!(
|
||
outcome.browser.map(|note| note.reason),
|
||
Some(BrowserFallbackReason::RedirectNotRegistered)
|
||
);
|
||
mock.stop();
|
||
}
|