//! 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 = "Sign in to your account\
"; /// 设备码面板里看到的 `(用户码, 回退原因码)`(跨闭包共享)。 type Seen = Arc)>>>; 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)> { 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(); }