Files
Unlucky-Launcher/crates/core/tests/auth/microsoft_msalogin.rs
T
AskaEth f82bd395f8 fix(msa): 微软登录默认走设备码,浏览器流降级为可选加速路
官方产物上点「微软登录」必失败:GUI 只接了授权码流(随机端口回环回调),而 Azure 应用
注册当初是为 device-code 建的,重定向 URI 一项都没登记,于是授权端点当场回
`invalid_request: ... 'redirect_uri' is not valid`,浏览器里只剩一张 AAD 原始错误页,
启动器干等 5 分钟超时。设备码流本身是好的(CLI `accounts add msa` 一直能用)。

- core:新增 `preflight`(回环重定向 URI 注册预检)与 `login`(浏览器优先 + 自动回退设备码)。
  预检拿「即将使用的 redirect_uri」(端口不参与匹配,微软官方文档 *Localhost exceptions*
  原文已引进模块文档)先问一次授权端点;没登记 / 问不出来就地跳过,**一个监听口都不开**。
  预检通过才真绑端口走授权码;授权码失败(超时/被拒/绑不上)自动回退设备码并带上结构化
  原因码;用户取消(`Canceled`)原样上抛,不偷偷换流。设备码是默认,零监听口。
- presenter:`start_login` = 设备码(默认);`start_browser_login` = 浏览器优先 + 回退,
  回退原因码写进 `DeviceLoginView.browser_note`(原文只进日志)。
- i18n:三条回退文案(含「怎么修:把 http://localhost/auth 加进重定向 URI,端口不参与匹配」)。
- app-iced:登录面板主按钮改成设备码、浏览器降级为次按钮;重登也走设备码;设备码面板与失败
  面板都能画回退说明。倒计时文案改由视图模型持有(借用渲染),顺带修掉中文倒计时整行不显示。
- 守卫(都能变红):①授权端点回 invalid_request 时确实回退设备码且原因是结构化码;
  ②未登记回环 / 默认设备码路 `listeners_opened == 0`;③回环登记了仍能走授权码(不焊死加速路);
  ④用户取消不被偷偷换流;⑤client id 仍只能构建期注入(扫 `auth/microsoft/` 源码无 UUID 字面量)。
2026-11-08 20:23:42 +08:00

268 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.
//! 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,
"没登记回环时不许绑本地端口(预检必须在绑定之前)"
);
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(&note);
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();
}