fix(msa): 浏览器登录按钮在生产路径真的置位 browser_first(复审 r1 收口)
复审 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 行红线。截图三张(入口 / 设备码 / 回退)已重出。
This commit is contained in:
1 parent
f82bd395f8
commit
a40b3a5940
15 files changed
+745
-294
No files matched your search
@@ -216,6 +216,11 @@ pub(super) fn force_account_state(app: &mut App) {
|
|||||||
"closed" => {
|
"closed" => {
|
||||||
let _ = app.on_account(crate::pages::account::Message::Close);
|
let _ = app.on_account(crate::pages::account::Message::Close);
|
||||||
}
|
}
|
||||||
|
// M178:登录入口面板(两个按钮 + 一行「没有浏览器时用这个」)。复审 r1 P2-5 的文案改动
|
||||||
|
// 要有图可看:`device_login` 是 Idle ⇒ 面板落在空闲态(主按钮=设备码,次按钮=浏览器)。
|
||||||
|
"msa-entry" => {
|
||||||
|
app.account_form.panel = crate::pages::account::Panel::Microsoft;
|
||||||
|
}
|
||||||
// 有账号时「Add an account」展开态(P2-4 的收敛后形态)
|
// 有账号时「Add an account」展开态(P2-4 的收敛后形态)
|
||||||
"add" => {
|
"add" => {
|
||||||
app.account_form.section_open = true;
|
app.account_form.section_open = true;
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! ## 为什么不是「一个转圈」
|
//! ## 为什么不是「一个转圈」
|
||||||
//!
|
//!
|
||||||
//! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
|
//! 设备码的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
|
||||||
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、
|
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码、链接、倒计时与「已过期,重试」。
|
||||||
//! 倒计时(`15:00 left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。
|
|
||||||
//!
|
//!
|
||||||
//! ## 文案一律**借用模型里的串**(M178)
|
//! ## 文案一律**借用模型里的串**(M178)
|
||||||
//!
|
//!
|
||||||
@@ -60,12 +59,15 @@ pub fn panel<'a>(
|
|||||||
/// 空闲:两条流的入口。**主按钮是设备码**(M178):它不依赖应用注册里的任何一项,是官方
|
/// 空闲:两条流的入口。**主按钮是设备码**(M178):它不依赖应用注册里的任何一项,是官方
|
||||||
/// 产物上唯一开箱可用的路;浏览器流是右手的次要按钮(要注册 `http://localhost/auth`,
|
/// 产物上唯一开箱可用的路;浏览器流是右手的次要按钮(要注册 `http://localhost/auth`,
|
||||||
/// 没登记时在预检处直接回退设备码)。
|
/// 没登记时在预检处直接回退设备码)。
|
||||||
|
///
|
||||||
|
/// 文案(复审 r1 P2-5):主按钮写「使用 Microsoft 账户登录」而不是「改用设备码」;
|
||||||
|
/// 「没有浏览器时用这个」放在按钮下面一行说明里。
|
||||||
fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||||
let body = column![row![
|
let body = column![row![
|
||||||
buttons::filled(
|
buttons::filled(
|
||||||
ui,
|
ui,
|
||||||
Button::new(
|
Button::new(
|
||||||
catalog.text(Key::AccountDeviceUseCode),
|
catalog.text(Key::AccountDeviceSignIn),
|
||||||
Account::StartDevice.into_page(),
|
Account::StartDevice.into_page(),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -86,7 +88,14 @@ fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
|||||||
]
|
]
|
||||||
.spacing(ui.space(Space::X2))
|
.spacing(ui.space(Space::X2))
|
||||||
.align_y(Alignment::Center),]
|
.align_y(Alignment::Center),]
|
||||||
.spacing(ui.space(Space::X3));
|
.spacing(ui.space(Space::X3))
|
||||||
|
.push(frame::text_role(
|
||||||
|
ui,
|
||||||
|
TypeRole::BodySmall,
|
||||||
|
false,
|
||||||
|
catalog.text(Key::AccountDeviceFallbackHint),
|
||||||
|
ui.color(Role::OnSurfaceVariant),
|
||||||
|
));
|
||||||
frame::card(ui, body)
|
frame::card(ui, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +124,7 @@ fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
|||||||
frame::card(ui, body)
|
frame::card(ui, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设备码等待态:**码 + 链接 + 倒计时**(`note` = 浏览器登录没走成时的回退说明)。
|
/// 设备码等待态:码 + 链接 + 倒计时(`note` = 回退说明)。
|
||||||
fn device_code<'a>(
|
fn device_code<'a>(
|
||||||
ui: &'a Ui,
|
ui: &'a Ui,
|
||||||
code: &'a str,
|
code: &'a str,
|
||||||
@@ -257,9 +266,7 @@ fn browser<'a>(
|
|||||||
frame::card(ui, body)
|
frame::card(ui, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**)。
|
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**;文案由模型拼好,这里只定颜色与强调)。
|
||||||
///
|
|
||||||
/// 文案由模型拼好(`panels::device_panel` 用 catalog 渲染 `{time}`),这里只决定颜色与强调。
|
|
||||||
fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, PageMessage> {
|
fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, PageMessage> {
|
||||||
if expired {
|
if expired {
|
||||||
return frame::text_role(ui, TypeRole::LabelMedium, true, text, ui.color(Role::Error));
|
return frame::text_role(ui, TypeRole::LabelMedium, true, text, ui.color(Role::Error));
|
||||||
@@ -273,6 +280,11 @@ fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, P
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 回退说明的**唯一文案出口**(视图与页面级断言共用:断言取样走这里,才是「视图会画的那句」)。
|
||||||
|
pub(super) fn browser_note_text(note: BrowserLoginNote, catalog: Catalog) -> &'static str {
|
||||||
|
catalog.text(super::model::note_key(note))
|
||||||
|
}
|
||||||
|
|
||||||
/// 回退说明那一行(`None` = 没有这回事)。
|
/// 回退说明那一行(`None` = 没有这回事)。
|
||||||
fn note_text<'a>(
|
fn note_text<'a>(
|
||||||
ui: &'a Ui,
|
ui: &'a Ui,
|
||||||
@@ -285,20 +297,17 @@ fn note_text<'a>(
|
|||||||
ui,
|
ui,
|
||||||
TypeRole::BodySmall,
|
TypeRole::BodySmall,
|
||||||
false,
|
false,
|
||||||
catalog.text(super::model::note_key(note)),
|
browser_note_text(note, catalog),
|
||||||
ui.color(Role::OnSurfaceVariant),
|
ui.color(Role::OnSurfaceVariant),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。
|
/// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。
|
||||||
///
|
///
|
||||||
/// 四点修订叠在这里:
|
/// - 语言泄漏(r1 p1):只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
|
||||||
/// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
|
/// - 不许通栏实心红(tower r2):面板底 `SurfaceContainerHigh` + 描边,`ErrorContainer` 只做图标块强调;
|
||||||
/// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边,
|
/// - 可操作的失败要有可读说明(M132):缺 client id 换专属标题 + 「要设哪个环境变量」,且不给「重试」;
|
||||||
/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小);
|
/// - M178:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。
|
||||||
/// - **可操作的失败要有可读说明(M132)**:缺 client id 换专属标题 + 「要设哪个环境变量」的
|
|
||||||
/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错;
|
|
||||||
/// - **M178**:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。
|
|
||||||
fn failed<'a>(
|
fn failed<'a>(
|
||||||
ui: &'a Ui,
|
ui: &'a Ui,
|
||||||
failure: LoginFailure,
|
failure: LoginFailure,
|
||||||
@@ -308,7 +317,7 @@ fn failed<'a>(
|
|||||||
let mut body = catalog.text(failure_body_key(failure)).to_string();
|
let mut body = catalog.text(failure_body_key(failure)).to_string();
|
||||||
if let Some(note) = note {
|
if let Some(note) = note {
|
||||||
body.push_str("\n\n");
|
body.push_str("\n\n");
|
||||||
body.push_str(catalog.text(super::model::note_key(note)));
|
body.push_str(browser_note_text(note, catalog));
|
||||||
}
|
}
|
||||||
let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]);
|
let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]);
|
||||||
let mut actions: Vec<Element<'a, PageMessage>> = Vec::new();
|
let mut actions: Vec<Element<'a, PageMessage>> = Vec::new();
|
||||||
|
|||||||
@@ -4,15 +4,23 @@
|
|||||||
//! i18n 那句「为什么 + 怎么修」,而不是 AAD 的原始报文。core 那一半(真的回退、不白开监听口)
|
//! i18n 那句「为什么 + 怎么修」,而不是 AAD 的原始报文。core 那一半(真的回退、不白开监听口)
|
||||||
//! 在 `crates/core/tests/auth/microsoft_msalogin.rs`。
|
//! 在 `crates/core/tests/auth/microsoft_msalogin.rs`。
|
||||||
|
|
||||||
|
use unlucky_i18n::Lang;
|
||||||
use unlucky_presenter::dto::{
|
use unlucky_presenter::dto::{
|
||||||
AppEvent, BrowserLoginNote, CoreEvent, DeviceLoginView, LoginFailure,
|
AppEvent, BrowserLoginNote, CoreEvent, DeviceLoginView, LoginFailure,
|
||||||
};
|
};
|
||||||
use unlucky_presenter::state::AppState;
|
use unlucky_presenter::state::AppState;
|
||||||
|
|
||||||
use super::model::{DevicePanel, Form};
|
use super::avatar::Cache;
|
||||||
|
use super::login::browser_note_text;
|
||||||
|
use super::model::{DevicePanel, Form, Page};
|
||||||
use super::tests::build;
|
use super::tests::build;
|
||||||
use super::tests_text::{has_cjk, on_screen};
|
use super::tests_text::{has_cjk, on_screen};
|
||||||
|
|
||||||
|
/// 中文界面的同一页(`build` 钉死英文,这里要中文那一套)。
|
||||||
|
fn build_zh(state: &AppState, form: &Form) -> Page {
|
||||||
|
Page::build(state, form, &mut Cache::default(), Lang::ZhCn, 1180.0)
|
||||||
|
}
|
||||||
|
|
||||||
/// **M178 守卫(页面这一半)**:浏览器登录没走成、已回退设备码时,页面上要有一句**可读的**
|
/// **M178 守卫(页面这一半)**:浏览器登录没走成、已回退设备码时,页面上要有一句**可读的**
|
||||||
/// 中文/英文说明(含「怎么修」),且**不是** AAD 的原始报文。
|
/// 中文/英文说明(含「怎么修」),且**不是** AAD 的原始报文。
|
||||||
///
|
///
|
||||||
@@ -60,6 +68,37 @@ application."
|
|||||||
);
|
);
|
||||||
assert!(!text.contains("AADSTS"), "原始报文不许上屏:{text}");
|
assert!(!text.contains("AADSTS"), "原始报文不许上屏:{text}");
|
||||||
assert!(!text.contains("is not valid"), "原始报文不许上屏:{text}");
|
assert!(!text.contains("is not valid"), "原始报文不许上屏:{text}");
|
||||||
|
|
||||||
|
// 3. 中文界面同样走一遍(复审 r1 P2-4):文案来自**视图自己的出口**
|
||||||
|
// `login::browser_note_text`,中文要有、可照抄的修法要有、AAD 原始码不许有。
|
||||||
|
let zh = build_zh(&state, &Form::new());
|
||||||
|
let zh_text = on_screen(&zh);
|
||||||
|
assert!(has_cjk(&zh_text), "中文界面要真有中文:{zh_text}");
|
||||||
|
let note_text = browser_note_text(
|
||||||
|
BrowserLoginNote::RedirectNotRegistered,
|
||||||
|
Lang::ZhCn.catalog(),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
note_text.contains("http://localhost/auth"),
|
||||||
|
"中文文案也要给出可照抄的重定向 URI:{note_text}"
|
||||||
|
);
|
||||||
|
assert!(note_text.contains("重定向 URI"), "{note_text}");
|
||||||
|
assert!(note_text.contains("设备码"), "{note_text}");
|
||||||
|
for forbidden in ["invalid_request", "AADSTS", "is not valid", "{"] {
|
||||||
|
assert!(
|
||||||
|
!zh_text.contains(forbidden),
|
||||||
|
"中文界面混进了原始报文:{zh_text}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!note_text.contains(forbidden),
|
||||||
|
"词条混进了原始报文:{note_text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 视图那一行的来源就是这一条文案:取样函数与视图共用同一个出口(不是测试另写映射)。
|
||||||
|
assert_eq!(
|
||||||
|
browser_note_text(BrowserLoginNote::PreflightUnavailable, Lang::En.catalog()),
|
||||||
|
"Could not confirm that browser sign-in is usable, so it switched to a device code."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 回退之后设备码又失败:失败面板上**两者都要在**(失败原因 + 为什么没走浏览器)。
|
/// 回退之后设备码又失败:失败面板上**两者都要在**(失败原因 + 为什么没走浏览器)。
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
use unlucky_i18n::{Key, Lang};
|
use unlucky_i18n::{Key, Lang};
|
||||||
use unlucky_presenter::dto::AccountNoticeCode;
|
use unlucky_presenter::dto::AccountNoticeCode;
|
||||||
|
|
||||||
use super::login::{failure_body_key, failure_title_key};
|
use super::login::{browser_note_text, failure_body_key, failure_title_key};
|
||||||
use super::model::{AuthlibPanel, DevicePanel, Hint, Page};
|
use super::model::{AuthlibPanel, DevicePanel, Hint, Page};
|
||||||
|
|
||||||
/// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。
|
/// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。
|
||||||
@@ -68,9 +68,10 @@ pub(super) fn on_screen(page: &Page) -> String {
|
|||||||
out.push_str(link);
|
out.push_str(link);
|
||||||
// 倒计时文案由模型拼好(M178:owned 文字路径画不出中文)。
|
// 倒计时文案由模型拼好(M178:owned 文字路径画不出中文)。
|
||||||
out.push_str(countdown);
|
out.push_str(countdown);
|
||||||
// M178:浏览器登录没走成时那一句「为什么 + 怎么修」也在这条取样线上。
|
// M178:回退说明走**视图自己的**文案出口(`login::browser_note_text`),
|
||||||
|
// 不是测试另写一份映射——否则视图哪天不画这一行,取样仍会绿。
|
||||||
if let Some(note) = note {
|
if let Some(note) = note {
|
||||||
out.push_str(catalog.text(super::model::note_key(*note)));
|
out.push_str(browser_note_text(*note, catalog));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DevicePanel::Browser {
|
DevicePanel::Browser {
|
||||||
@@ -84,7 +85,7 @@ pub(super) fn on_screen(page: &Page) -> String {
|
|||||||
out.push_str(catalog.text(failure_body_key(*failure)));
|
out.push_str(catalog.text(failure_body_key(*failure)));
|
||||||
out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())]));
|
out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())]));
|
||||||
if let Some(note) = note {
|
if let Some(note) = note {
|
||||||
out.push_str(catalog.text(super::model::note_key(*note)));
|
out.push_str(browser_note_text(*note, catalog));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DevicePanel::Succeeded { name } => {
|
DevicePanel::Succeeded { name } => {
|
||||||
|
|||||||
@@ -173,11 +173,28 @@ pub(crate) async fn login<F>(
|
|||||||
policy: AuthCodePolicy,
|
policy: AuthCodePolicy,
|
||||||
present: F,
|
present: F,
|
||||||
) -> AuthResult<MicrosoftSession>
|
) -> AuthResult<MicrosoftSession>
|
||||||
|
where
|
||||||
|
F: FnOnce(&AuthCodePrompt),
|
||||||
|
{
|
||||||
|
login_counting(client, endpoints, client_id, policy, None, present).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同 [`login`],但把**绑定现场**的监听口次数记进 `counter`(M178 的取证计数)。
|
||||||
|
pub(crate) async fn login_counting<F>(
|
||||||
|
client: &Client,
|
||||||
|
endpoints: &MicrosoftEndpoints,
|
||||||
|
client_id: &str,
|
||||||
|
policy: AuthCodePolicy,
|
||||||
|
counter: Option<&std::sync::atomic::AtomicUsize>,
|
||||||
|
present: F,
|
||||||
|
) -> AuthResult<MicrosoftSession>
|
||||||
where
|
where
|
||||||
F: FnOnce(&AuthCodePrompt),
|
F: FnOnce(&AuthCodePrompt),
|
||||||
{
|
{
|
||||||
let state = policy.state.clone().unwrap_or_else(nonce);
|
let state = policy.state.clone().unwrap_or_else(nonce);
|
||||||
let server = CallbackServer::bind(policy.port, policy.bind_attempts, state.clone()).await?;
|
let server =
|
||||||
|
CallbackServer::bind_counting(policy.port, policy.bind_attempts, state.clone(), counter)
|
||||||
|
.await?;
|
||||||
let redirect_uri = RedirectUri::new(server.port());
|
let redirect_uri = RedirectUri::new(server.port());
|
||||||
present(&AuthCodePrompt {
|
present(&AuthCodePrompt {
|
||||||
authorization_url: authorization_url(endpoints, client_id, &redirect_uri, &state),
|
authorization_url: authorization_url(endpoints, client_id, &redirect_uri, &state),
|
||||||
@@ -206,14 +223,15 @@ pub(crate) enum AuthCodeAttempt {
|
|||||||
|
|
||||||
/// 带预检的授权码登录:注册里有这个回环 URI 才真去绑端口、弹浏览器。
|
/// 带预检的授权码登录:注册里有这个回环 URI 才真去绑端口、弹浏览器。
|
||||||
///
|
///
|
||||||
/// 顺序是刻意的——**预检在绑定之前**:没登记的机器上连监听口都不开(守卫断言
|
/// 顺序是刻意的——**预检在绑定之前**:没登记的机器上连监听口都不开(`counter` 是绑定现场的
|
||||||
/// [`super::callback::bind_attempts`] 不涨),也就不会出现「浏览器里一张 AAD 错误页 +
|
/// 真计数,见 [`CallbackServer::bind_counting`]),也就不会出现「浏览器里一张 AAD 错误页 +
|
||||||
/// 启动器干等 5 分钟」的那套现象(M178 用户实测的那个 bug)。
|
/// 启动器干等 5 分钟」的那套现象(M178 用户实测的那个 bug)。
|
||||||
pub(crate) async fn login_checked<F>(
|
pub(crate) async fn login_checked<F>(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
endpoints: &MicrosoftEndpoints,
|
endpoints: &MicrosoftEndpoints,
|
||||||
client_id: &str,
|
client_id: &str,
|
||||||
policy: AuthCodePolicy,
|
policy: AuthCodePolicy,
|
||||||
|
counter: &std::sync::atomic::AtomicUsize,
|
||||||
present: F,
|
present: F,
|
||||||
) -> AuthResult<AuthCodeAttempt>
|
) -> AuthResult<AuthCodeAttempt>
|
||||||
where
|
where
|
||||||
@@ -235,7 +253,8 @@ where
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let session = login(client, endpoints, client_id, policy, present).await?;
|
let session =
|
||||||
|
login_counting(client, endpoints, client_id, policy, Some(counter), present).await?;
|
||||||
Ok(AuthCodeAttempt::LoggedIn(session))
|
Ok(AuthCodeAttempt::LoggedIn(session))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,11 +66,29 @@ impl CallbackServer {
|
|||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
attempts: u8,
|
attempts: u8,
|
||||||
state: impl Into<String>,
|
state: impl Into<String>,
|
||||||
|
) -> AuthResult<Self> {
|
||||||
|
Self::bind_counting(port, attempts, state, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同 [`Self::bind`],但把**每一次真正调用 `TcpListener::bind`** 记进 `counter`(M178)。
|
||||||
|
///
|
||||||
|
/// 为什么不把计数写在外层:`LoginOutcome::listeners_opened` 是「这次登录有没有偷开监听口」
|
||||||
|
/// 的唯一证据,它必须是**绑定现场**的数,不能是「进没进授权码分支」的代理指标
|
||||||
|
/// (复审 r1 P2-1 的变异 D 实测:代理指标对「真的多绑了一次」不敏感)。
|
||||||
|
/// 计数发生在 `TcpListener::bind` 调用之前:要数的是「去开了没」,不是「开成功了没」。
|
||||||
|
pub(crate) async fn bind_counting(
|
||||||
|
port: Option<u16>,
|
||||||
|
attempts: u8,
|
||||||
|
state: impl Into<String>,
|
||||||
|
counter: Option<&std::sync::atomic::AtomicUsize>,
|
||||||
) -> AuthResult<Self> {
|
) -> AuthResult<Self> {
|
||||||
let wanted = port.unwrap_or(0);
|
let wanted = port.unwrap_or(0);
|
||||||
let attempts = attempts.max(1);
|
let attempts = attempts.max(1);
|
||||||
let mut last = String::new();
|
let mut last = String::new();
|
||||||
for attempt in 0..attempts {
|
for attempt in 0..attempts {
|
||||||
|
if let Some(counter) = counter {
|
||||||
|
counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
match TcpListener::bind(("127.0.0.1", wanted)).await {
|
match TcpListener::bind(("127.0.0.1", wanted)).await {
|
||||||
Ok(listener) => {
|
Ok(listener) => {
|
||||||
let port = listener
|
let port = listener
|
||||||
|
|||||||
@@ -116,238 +116,5 @@ pub fn client_id_status() -> ClientIdStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "client_id/tests.rs"]
|
||||||
use std::sync::Mutex;
|
mod tests;
|
||||||
|
|
||||||
use super::{
|
|
||||||
client_id_status, resolve_client_id, ClientIdSource, CLIENT_ID_BUILD, CLIENT_ID_ENV,
|
|
||||||
MISSING_CLIENT_ID_MESSAGE,
|
|
||||||
};
|
|
||||||
use crate::auth::error::AuthError;
|
|
||||||
use crate::auth::microsoft::MicrosoftAuth;
|
|
||||||
|
|
||||||
/// 环境变量是**进程级**的:动它的用例必须串行,否则并行跑会互相摘/设,结论随机。
|
|
||||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
|
||||||
|
|
||||||
/// 用例结束后把环境变量还原(panic 也还原:`Drop` 跑得到)。
|
|
||||||
struct EnvGuard {
|
|
||||||
saved: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EnvGuard {
|
|
||||||
fn clear() -> Self {
|
|
||||||
let saved = std::env::var(CLIENT_ID_ENV).ok();
|
|
||||||
std::env::remove_var(CLIENT_ID_ENV);
|
|
||||||
Self { saved }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set(value: &str) -> Self {
|
|
||||||
let saved = std::env::var(CLIENT_ID_ENV).ok();
|
|
||||||
std::env::set_var(CLIENT_ID_ENV, value);
|
|
||||||
Self { saved }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for EnvGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
match self.saved.take() {
|
|
||||||
Some(value) => std::env::set_var(CLIENT_ID_ENV, value),
|
|
||||||
None => std::env::remove_var(CLIENT_ID_ENV),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_client_id_message_says_unconfigured_not_unimplemented() {
|
|
||||||
// 抓得住:文案退回「本期未支持」这类说法(用户实跑到的那个 bug),
|
|
||||||
// 或者只说「需要 id」却不给怎么配(用户下一步就卡在这里)。
|
|
||||||
// 抓不住:文案好不好读——那要靠 review。
|
|
||||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains("客户端 id"));
|
|
||||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains("未配置"));
|
|
||||||
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未支持"));
|
|
||||||
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未实现"));
|
|
||||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains(CLIENT_ID_ENV));
|
|
||||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains("cargo build"));
|
|
||||||
// 硬约束:这句话进日志、进横幅、进错误,**示例一律是 `<id>` 占位**,不出现真 id。
|
|
||||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains("<id>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn from_env_without_client_id_is_a_config_error_not_a_feature_gap() {
|
|
||||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
|
||||||
let _guard = EnvGuard::clear();
|
|
||||||
let result = MicrosoftAuth::from_env();
|
|
||||||
match result {
|
|
||||||
Err(AuthError::Unsupported(message)) => {
|
|
||||||
// 机读码保持 `unsupported`(CLI `--json` 与既有调用方靠它分流),
|
|
||||||
// 但**人读文案**必须说「未配置」。
|
|
||||||
assert_eq!(message, MISSING_CLIENT_ID_MESSAGE);
|
|
||||||
assert!(!message.contains("未支持"));
|
|
||||||
}
|
|
||||||
// 构建期注入过 id 时唯一合法(`ULL_MSA_CLIENT_ID` 也设进 `cargo test` 的环境就会走到这里;
|
|
||||||
// 官方发布构建即烘了 id,但门禁里的 `cargo test` 不带该变量,故常态走上面那条。
|
|
||||||
// 注意:把该变量导出到跑测试的 shell 会让 presenter 的 M132 入口锚**故意红**
|
|
||||||
// (它要求「两处都没有 id」的构建,见 crates/presenter/src/host/accounts/tests.rs)。
|
|
||||||
Ok(_) => assert!(
|
|
||||||
CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()),
|
|
||||||
"没配 id 却构造成功"
|
|
||||||
),
|
|
||||||
Err(other) => panic!("应是 Unsupported,实际 {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn env_client_id_wins_over_build_and_the_log_says_so() {
|
|
||||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
|
||||||
let _guard = EnvGuard::set("0000-test-env-id");
|
|
||||||
|
|
||||||
let (id, source) = resolve_client_id().expect("环境变量给了 id");
|
|
||||||
assert_eq!(id, "0000-test-env-id");
|
|
||||||
// 两者都存在时环境变量优先,且**不告警**,但来源必须是 env。
|
|
||||||
assert_eq!(source, ClientIdSource::Env);
|
|
||||||
|
|
||||||
let status = client_id_status();
|
|
||||||
assert!(status.is_configured());
|
|
||||||
assert_eq!(status.source(), Some(ClientIdSource::Env));
|
|
||||||
assert_eq!(
|
|
||||||
status.log_line(),
|
|
||||||
"[auth] msa client_id=configured source=env"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 有 id 时**不再报配置错误**:只验到「进得了登录流程的入口」为止,
|
|
||||||
// `from_env` 本身零网络(它只建 reqwest 客户端,不发请求)。
|
|
||||||
let auth = MicrosoftAuth::from_env().expect("配了 id 就该构造成功");
|
|
||||||
assert!(auth.endpoints().profile.contains("minecraftservices.com"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_client_id_log_line_is_machine_readable() {
|
|
||||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
|
||||||
let _guard = EnvGuard::clear();
|
|
||||||
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
|
|
||||||
// 构建期注入过 id:这条没有「缺配置」可言,跳过。
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let status = client_id_status();
|
|
||||||
assert!(!status.is_configured());
|
|
||||||
assert_eq!(status.source(), None);
|
|
||||||
assert_eq!(
|
|
||||||
status.log_line(),
|
|
||||||
"[auth] msa client_id=missing source=none"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blank_env_value_counts_as_missing() {
|
|
||||||
// 抓得住:`ULL_MSA_CLIENT_ID=""`(空串/全空白)被当成配好了,进而拿空 id 去联网。
|
|
||||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
|
||||||
let _guard = EnvGuard::set(" ");
|
|
||||||
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
|
|
||||||
assert_eq!(
|
|
||||||
resolve_client_id().map(|(_, source)| source),
|
|
||||||
Some(ClientIdSource::Build),
|
|
||||||
"空白环境变量必须让位给构建期注入"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
assert!(resolve_client_id().is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// **M178 守卫**:client id 只能构建期/运行期注入,**微软轨源码里不许出现任何真实 id**。
|
|
||||||
///
|
|
||||||
/// 抓得住:为了让登录「开箱可用」而把一个真 client id 写进源码(或写进本模块的默认值)。
|
|
||||||
/// 抓不住:`.github/workflows` 里 Secret 的接线(那不在本 crate 的范围内,靠发布链自己的
|
|
||||||
/// `packaging/check_release_semantics.py` 与 Actions 日志看)。
|
|
||||||
#[test]
|
|
||||||
fn no_client_id_is_hardcoded_in_the_microsoft_sources() {
|
|
||||||
// 判据一:本模块的 id 只能来自 `option_env!`(构建期注入)。
|
|
||||||
let manifest = env!("CARGO_MANIFEST_DIR");
|
|
||||||
let source = std::fs::read_to_string(format!("{manifest}/src/auth/microsoft/client_id.rs"))
|
|
||||||
.expect("读 client_id.rs");
|
|
||||||
assert!(
|
|
||||||
source.contains("option_env!(\"ULL_MSA_CLIENT_ID\")"),
|
|
||||||
"client id 的构建期注入点不见了"
|
|
||||||
);
|
|
||||||
// 判据二:`auth/microsoft/` 下的任何源码都不许出现 UUID 形状的字面量
|
|
||||||
// (Azure 客户端 id 就是 UUID)。扫**全部文件**,所以把 id 挪到别的模块同样会红。
|
|
||||||
// 范围只到 `auth/microsoft/`:`auth/offline.rs` 里那个带连字符的 UUID 是离线名字的
|
|
||||||
// 测试向量,与 client id 无关。
|
|
||||||
let mut offenders = Vec::new();
|
|
||||||
let mut stack = vec![format!("{manifest}/src/auth/microsoft")];
|
|
||||||
while let Some(dir) = stack.pop() {
|
|
||||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
stack.push(path.display().to_string());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if path.extension().is_some_and(|ext| ext == "rs") {
|
|
||||||
let text = std::fs::read_to_string(&path).unwrap_or_default();
|
|
||||||
if contains_uuid_literal(&text) {
|
|
||||||
offenders.push(path.display().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
offenders.is_empty(),
|
|
||||||
"这些源文件里出现了 UUID 形状的字面量(client id 只能构建期注入):{offenders:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 源码里有没有 `8-4-4-4-12` 的十六进制字面量(Azure 客户端 id 的形状)。
|
|
||||||
fn contains_uuid_literal(text: &str) -> bool {
|
|
||||||
fn hex(value: &[u8]) -> bool {
|
|
||||||
!value.is_empty() && value.iter().all(u8::is_ascii_hexdigit)
|
|
||||||
}
|
|
||||||
let bytes = text.as_bytes();
|
|
||||||
let dashes: Vec<usize> = bytes
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(_, byte)| **byte == b'-')
|
|
||||||
.map(|(index, _)| index)
|
|
||||||
.collect();
|
|
||||||
dashes.windows(4).any(|window| {
|
|
||||||
let (first, last) = (window[0], window[3]);
|
|
||||||
let gaps = [
|
|
||||||
window[1] - first,
|
|
||||||
window[2] - window[1],
|
|
||||||
window[3] - window[2],
|
|
||||||
];
|
|
||||||
// 4-4-4-12 的段间距(连字符本身没算进 gap):5-5-5-13。
|
|
||||||
if gaps != [5, 5, 5] || last + 13 > bytes.len() || first < 8 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
hex(&bytes[first - 8..first])
|
|
||||||
&& hex(&bytes[first + 1..window[1]])
|
|
||||||
&& hex(&bytes[window[1] + 1..window[2]])
|
|
||||||
&& hex(&bytes[window[2] + 1..last])
|
|
||||||
&& hex(&bytes[last + 1..last + 13])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 判据自检:`contains_uuid_literal` 认得出 UUID,也不会把普通代码当 UUID。
|
|
||||||
///
|
|
||||||
/// 没有这条,「源码里没有 UUID」可能因为判据本身坏掉而永远为真(假绿)。
|
|
||||||
/// 样本**运行时拼**:字面量写在这里会被上面的扫描抓成 offender(自指陷阱)。
|
|
||||||
#[test]
|
|
||||||
fn the_uuid_literal_detector_bites() {
|
|
||||||
let sample = format!(
|
|
||||||
"\"{}-{}-{}-{}-{}\"",
|
|
||||||
"83fd9b2f", "da89", "41f7", "b41b", "13742a73841c"
|
|
||||||
);
|
|
||||||
assert!(contains_uuid_literal(&sample), "{sample}");
|
|
||||||
assert!(!contains_uuid_literal("// 0000-test-client"));
|
|
||||||
assert!(!contains_uuid_literal("a-b-c-d"));
|
|
||||||
assert!(!contains_uuid_literal(
|
|
||||||
"http://localhost/auth?client_id=not-a-uuid"
|
|
||||||
));
|
|
||||||
// 段长不对的连字符串不算(`8-4-4-4-12` 之外一律放行)。
|
|
||||||
assert!(!contains_uuid_literal(
|
|
||||||
"abcdefgh-1234-5678-9012-12345678901"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
//! `client_id` 的判据(从 `client_id.rs` 拆出来守 400 行硬线)。
|
||||||
|
//!
|
||||||
|
//! 两块:①「缺 id」文案与日志行的措辞纪律(M117);②**M178 守卫**——client id 只能构建期注入,
|
||||||
|
//! 微软轨源码里不许出现 UUID 形状的字面量(复审 r1 P2-3 加严:`concat!` / 跨行 / `format!`
|
||||||
|
//! 三种拼法都要抓得住,且不许误杀 `session.rs` 里那个 profile UUID)。
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
client_id_status, resolve_client_id, ClientIdSource, CLIENT_ID_BUILD, CLIENT_ID_ENV,
|
||||||
|
MISSING_CLIENT_ID_MESSAGE,
|
||||||
|
};
|
||||||
|
use crate::auth::error::AuthError;
|
||||||
|
use crate::auth::microsoft::MicrosoftAuth;
|
||||||
|
|
||||||
|
/// 环境变量是**进程级**的:动它的用例必须串行,否则并行跑会互相摘/设,结论随机。
|
||||||
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
/// 用例结束后把环境变量还原(panic 也还原:`Drop` 跑得到)。
|
||||||
|
struct EnvGuard {
|
||||||
|
saved: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnvGuard {
|
||||||
|
fn clear() -> Self {
|
||||||
|
let saved = std::env::var(CLIENT_ID_ENV).ok();
|
||||||
|
std::env::remove_var(CLIENT_ID_ENV);
|
||||||
|
Self { saved }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set(value: &str) -> Self {
|
||||||
|
let saved = std::env::var(CLIENT_ID_ENV).ok();
|
||||||
|
std::env::set_var(CLIENT_ID_ENV, value);
|
||||||
|
Self { saved }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for EnvGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
match self.saved.take() {
|
||||||
|
Some(value) => std::env::set_var(CLIENT_ID_ENV, value),
|
||||||
|
None => std::env::remove_var(CLIENT_ID_ENV),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_client_id_message_says_unconfigured_not_unimplemented() {
|
||||||
|
// 抓得住:文案退回「本期未支持」这类说法(用户实跑到的那个 bug),
|
||||||
|
// 或者只说「需要 id」却不给怎么配(用户下一步就卡在这里)。
|
||||||
|
// 抓不住:文案好不好读——那要靠 review。
|
||||||
|
assert!(MISSING_CLIENT_ID_MESSAGE.contains("客户端 id"));
|
||||||
|
assert!(MISSING_CLIENT_ID_MESSAGE.contains("未配置"));
|
||||||
|
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未支持"));
|
||||||
|
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未实现"));
|
||||||
|
assert!(MISSING_CLIENT_ID_MESSAGE.contains(CLIENT_ID_ENV));
|
||||||
|
assert!(MISSING_CLIENT_ID_MESSAGE.contains("cargo build"));
|
||||||
|
// 硬约束:这句话进日志、进横幅、进错误,**示例一律是 `<id>` 占位**,不出现真 id。
|
||||||
|
assert!(MISSING_CLIENT_ID_MESSAGE.contains("<id>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_env_without_client_id_is_a_config_error_not_a_feature_gap() {
|
||||||
|
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
let _guard = EnvGuard::clear();
|
||||||
|
let result = MicrosoftAuth::from_env();
|
||||||
|
match result {
|
||||||
|
Err(AuthError::Unsupported(message)) => {
|
||||||
|
// 机读码保持 `unsupported`(CLI `--json` 与既有调用方靠它分流),
|
||||||
|
// 但**人读文案**必须说「未配置」。
|
||||||
|
assert_eq!(message, MISSING_CLIENT_ID_MESSAGE);
|
||||||
|
assert!(!message.contains("未支持"));
|
||||||
|
}
|
||||||
|
// 构建期注入过 id 时唯一合法(`ULL_MSA_CLIENT_ID` 也设进 `cargo test` 的环境就会走到这里;
|
||||||
|
// 官方发布构建即烘了 id,但门禁里的 `cargo test` 不带该变量,故常态走上面那条。
|
||||||
|
// 注意:把该变量导出到跑测试的 shell 会让 presenter 的 M132 入口锚**故意红**
|
||||||
|
// (它要求「两处都没有 id」的构建,见 crates/presenter/src/host/accounts/tests.rs)。
|
||||||
|
Ok(_) => assert!(
|
||||||
|
CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()),
|
||||||
|
"没配 id 却构造成功"
|
||||||
|
),
|
||||||
|
Err(other) => panic!("应是 Unsupported,实际 {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_client_id_wins_over_build_and_the_log_says_so() {
|
||||||
|
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
let _guard = EnvGuard::set("0000-test-env-id");
|
||||||
|
|
||||||
|
let (id, source) = resolve_client_id().expect("环境变量给了 id");
|
||||||
|
assert_eq!(id, "0000-test-env-id");
|
||||||
|
// 两者都存在时环境变量优先,且**不告警**,但来源必须是 env。
|
||||||
|
assert_eq!(source, ClientIdSource::Env);
|
||||||
|
|
||||||
|
let status = client_id_status();
|
||||||
|
assert!(status.is_configured());
|
||||||
|
assert_eq!(status.source(), Some(ClientIdSource::Env));
|
||||||
|
assert_eq!(
|
||||||
|
status.log_line(),
|
||||||
|
"[auth] msa client_id=configured source=env"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 有 id 时**不再报配置错误**:只验到「进得了登录流程的入口」为止,
|
||||||
|
// `from_env` 本身零网络(它只建 reqwest 客户端,不发请求)。
|
||||||
|
let auth = MicrosoftAuth::from_env().expect("配了 id 就该构造成功");
|
||||||
|
assert!(auth.endpoints().profile.contains("minecraftservices.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_client_id_log_line_is_machine_readable() {
|
||||||
|
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
let _guard = EnvGuard::clear();
|
||||||
|
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
|
||||||
|
// 构建期注入过 id:这条没有「缺配置」可言,跳过。
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let status = client_id_status();
|
||||||
|
assert!(!status.is_configured());
|
||||||
|
assert_eq!(status.source(), None);
|
||||||
|
assert_eq!(
|
||||||
|
status.log_line(),
|
||||||
|
"[auth] msa client_id=missing source=none"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blank_env_value_counts_as_missing() {
|
||||||
|
// 抓得住:`ULL_MSA_CLIENT_ID=""`(空串/全空白)被当成配好了,进而拿空 id 去联网。
|
||||||
|
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
let _guard = EnvGuard::set(" ");
|
||||||
|
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_client_id().map(|(_, source)| source),
|
||||||
|
Some(ClientIdSource::Build),
|
||||||
|
"空白环境变量必须让位给构建期注入"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert!(resolve_client_id().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **M178 守卫**:client id 只能构建期/运行期注入,**微软轨源码里不许出现任何真实 id**。
|
||||||
|
///
|
||||||
|
/// 抓得住:为了让登录「开箱可用」而把一个真 client id 写进源码(或写进本模块的默认值)。
|
||||||
|
/// 抓不住:`.github/workflows` 里 Secret 的接线(那不在本 crate 的范围内,靠发布链自己的
|
||||||
|
/// `packaging/check_release_semantics.py` 与 Actions 日志看)。
|
||||||
|
#[test]
|
||||||
|
fn no_client_id_is_hardcoded_in_the_microsoft_sources() {
|
||||||
|
// 判据一:本模块的 id 只能来自 `option_env!`(构建期注入)。
|
||||||
|
let manifest = env!("CARGO_MANIFEST_DIR");
|
||||||
|
let source = std::fs::read_to_string(format!("{manifest}/src/auth/microsoft/client_id.rs"))
|
||||||
|
.expect("读 client_id.rs");
|
||||||
|
assert!(
|
||||||
|
source.contains("option_env!(\"ULL_MSA_CLIENT_ID\")"),
|
||||||
|
"client id 的构建期注入点不见了"
|
||||||
|
);
|
||||||
|
// 判据二:`auth/microsoft/` 下的任何源码都不许出现 UUID 形状的字面量
|
||||||
|
// (Azure 客户端 id 就是 UUID)。扫**全部文件**,所以把 id 挪到别的模块同样会红。
|
||||||
|
// 范围只到 `auth/microsoft/`:`auth/offline.rs` 里那个带连字符的 UUID 是离线名字的
|
||||||
|
// 测试向量,与 client id 无关。
|
||||||
|
let mut offenders = Vec::new();
|
||||||
|
let mut stack = vec![format!("{manifest}/src/auth/microsoft")];
|
||||||
|
while let Some(dir) = stack.pop() {
|
||||||
|
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
stack.push(path.display().to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if path.extension().is_some_and(|ext| ext == "rs") {
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap_or_default();
|
||||||
|
if contains_uuid_literal(&text) {
|
||||||
|
offenders.push(path.display().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
offenders.is_empty(),
|
||||||
|
"这些源文件里出现了 UUID 形状的字面量(client id 只能构建期注入):{offenders:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 源码里有没有「Azure 客户端 id 形状」的字面量(复审 r1 P2-3 加严)。
|
||||||
|
///
|
||||||
|
/// 两条判据,覆盖复审点名的三种拼法:
|
||||||
|
/// 1. **带连字符**(`8-4-4-4-12`):把全部字面量首尾相接后扫,于是
|
||||||
|
/// `concat!("83fd9b2f-", "da89-…")`、跨行相邻两段、单段完整 UUID 都能抓到;
|
||||||
|
/// 2. **无连字符的 32 位十六进制、且由 ≥2 个**连续**的「纯十六进制字面量**拼起来
|
||||||
|
/// (`format!("{}-{}-{}-{}", "83fd9b2f", "da89", …)` 这类逐段拼法)。
|
||||||
|
///
|
||||||
|
/// 为什么判据 2 要求 ≥2 段:`session.rs` 的测试数据里有 profile UUID
|
||||||
|
/// (`"0f8fad5bd9cb469fa16570867728950e"`)——单个字面量的 32 位 hex 是**档案 id**,
|
||||||
|
/// 不是 client id;只有「拼出来的」才可疑。也不能把字面量无分隔地全部相接去数 hex 段:
|
||||||
|
/// 那样下一个字面量的开头若恰好是 hex 字符(如 `"Bearer"` 的 `Bea`)会把上一段续长造成误杀。
|
||||||
|
fn contains_uuid_literal(text: &str) -> bool {
|
||||||
|
let literals = string_literals(text);
|
||||||
|
let joined: String = literals.iter().map(String::as_str).collect();
|
||||||
|
if has_dashed_uuid(&joined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 判据 2:连续的「纯 hex 字面量」累计长度 ≥32 且至少两段。
|
||||||
|
let mut run_len = 0;
|
||||||
|
let mut run_parts = 0;
|
||||||
|
for literal in &literals {
|
||||||
|
let pure_hex = !literal.is_empty() && literal.bytes().all(|b| b.is_ascii_hexdigit());
|
||||||
|
if pure_hex {
|
||||||
|
run_len += literal.len();
|
||||||
|
run_parts += 1;
|
||||||
|
if run_len >= 32 && run_parts >= 2 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
run_len = 0;
|
||||||
|
run_parts = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收集源码里的字符串字面量内容(跳过注释;`\` 转义按「跳一个字符」处理)。
|
||||||
|
///
|
||||||
|
/// 为什么跳注释:本模块的文档里就写着 `concat!("83fd9b2f-", "da89-…")` 这类**说明性**例子,
|
||||||
|
/// 不跳的话自检样本会被自己的文档抓成 offender(实测踩过)。注释里出现 id 不构成「代码里
|
||||||
|
/// 硬编码 id」,判据只管可执行的字面量。
|
||||||
|
///
|
||||||
|
/// 近似处(够用即可):字符字面量 `'a'` 不当字符串;`r#"…"#` 按普通字面量近似(前缀 `r`
|
||||||
|
/// 不在引号内,不影响内容提取)。
|
||||||
|
fn string_literals(source: &str) -> Vec<String> {
|
||||||
|
let bytes = source.as_bytes();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut index = 0;
|
||||||
|
while index < bytes.len() {
|
||||||
|
match bytes[index] {
|
||||||
|
// 行注释:跳到行尾
|
||||||
|
b'/' if bytes.get(index + 1) == Some(&b'/') => {
|
||||||
|
while index < bytes.len() && bytes[index] != b'\n' {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 块注释:跳到 `*/`
|
||||||
|
b'/' if bytes.get(index + 1) == Some(&b'*') => {
|
||||||
|
index += 2;
|
||||||
|
while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/')
|
||||||
|
{
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
index = (index + 2).min(bytes.len());
|
||||||
|
}
|
||||||
|
b'"' => {
|
||||||
|
index += 1;
|
||||||
|
let start = index;
|
||||||
|
let mut escaped = false;
|
||||||
|
while index < bytes.len() {
|
||||||
|
let byte = bytes[index];
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match byte {
|
||||||
|
b'\\' => {
|
||||||
|
escaped = true;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
b'"' => break,
|
||||||
|
_ => index += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let end = index.min(bytes.len());
|
||||||
|
out.push(String::from_utf8_lossy(&bytes[start..end]).into_owned());
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
_ => index += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拼接后的串里有没有 `8-4-4-4-12` 的十六进制段(间距 5-5-5-13)。
|
||||||
|
fn has_dashed_uuid(text: &str) -> bool {
|
||||||
|
fn hex(value: &[u8]) -> bool {
|
||||||
|
!value.is_empty() && value.iter().all(u8::is_ascii_hexdigit)
|
||||||
|
}
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
let dashes: Vec<usize> = bytes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, byte)| **byte == b'-')
|
||||||
|
.map(|(index, _)| index)
|
||||||
|
.collect();
|
||||||
|
dashes.windows(4).any(|window| {
|
||||||
|
let (first, last) = (window[0], window[3]);
|
||||||
|
let gaps = [
|
||||||
|
window[1] - first,
|
||||||
|
window[2] - window[1],
|
||||||
|
window[3] - window[2],
|
||||||
|
];
|
||||||
|
if gaps != [5, 5, 5] || last + 13 > bytes.len() || first < 8 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
hex(&bytes[first - 8..first])
|
||||||
|
&& hex(&bytes[first + 1..window[1]])
|
||||||
|
&& hex(&bytes[window[1] + 1..window[2]])
|
||||||
|
&& hex(&bytes[window[2] + 1..last])
|
||||||
|
&& hex(&bytes[last + 1..last + 13])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判据自检:三种拼法都要抓得住,且不能把普通代码当 client id。
|
||||||
|
///
|
||||||
|
/// 没有这条,扫描可能因为判据本身坏掉而永远为真(假绿)。
|
||||||
|
/// 样本**运行时拼、且源码里不出现十六进制字面量**(用 `char` 数组拼):否则这条自检的
|
||||||
|
/// 样本本身会被扫描抓成 offender——自指陷阱(实测踩过一次)。
|
||||||
|
#[test]
|
||||||
|
fn the_uuid_literal_detector_bites_three_shapes() {
|
||||||
|
let part = |chars: &[char]| chars.iter().collect::<String>();
|
||||||
|
let head = part(&['8', '3', 'f', 'd', '9', 'b', '2', 'f']);
|
||||||
|
let p2 = part(&['d', 'a', '8', '9']);
|
||||||
|
let p3 = part(&['4', '1', 'f', '7']);
|
||||||
|
let p4 = part(&['b', '4', '1', 'b']);
|
||||||
|
let tail = part(&['1', '3', '7', '4', '2', 'a', '7', '3', '8', '4', '1', 'c']);
|
||||||
|
let uuid = format!("{head}-{p2}-{p3}-{p4}-{tail}");
|
||||||
|
|
||||||
|
// ① 单个完整字面量(基线)。
|
||||||
|
let plain = format!("let id = \"{uuid}\";");
|
||||||
|
assert!(contains_uuid_literal(&plain), "{plain}");
|
||||||
|
// ② `concat!` 拼出来(复审点名的形态之一)。
|
||||||
|
let concatenated = format!("const ID: &str = concat!(\"{head}-\", \"{p2}-{p3}-{p4}-{tail}\");");
|
||||||
|
assert!(contains_uuid_literal(&concatenated), "{concatenated}");
|
||||||
|
// ③ 跨行两段字面量。
|
||||||
|
let split_lines =
|
||||||
|
format!("let id = concat!(\n \"{head}-{p2}-\",\n \"{p3}-{p4}-{tail}\"\n);");
|
||||||
|
assert!(contains_uuid_literal(&split_lines), "{split_lines}");
|
||||||
|
// ④ `format!` 逐段拼(无连字符落点,靠「连续纯 hex 字面量累计 ≥32 且 ≥2 段」抓)。
|
||||||
|
let formatted = format!(
|
||||||
|
"let id = format!(\"{{}}-{{}}-{{}}-{{}}-{{}}\", \"{head}\", \"{p2}\", \"{p3}\", \"{p4}\", \"{tail}\");"
|
||||||
|
);
|
||||||
|
assert!(contains_uuid_literal(&formatted), "{formatted}");
|
||||||
|
|
||||||
|
// 反例:这些都不许误杀。
|
||||||
|
assert!(!contains_uuid_literal("// 0000-test-client"));
|
||||||
|
assert!(!contains_uuid_literal("a-b-c-d"));
|
||||||
|
assert!(!contains_uuid_literal(
|
||||||
|
"let uri = \"http://localhost/auth?client_id=not-a-uuid\";"
|
||||||
|
));
|
||||||
|
// 单个字面量的 32 位 hex = 既有测试数据(`session.rs` 的 profile UUID),不算 client id。
|
||||||
|
let profile = format!("let profile_id = \"{head}{p2}{p3}{p4}{tail}\";");
|
||||||
|
assert!(!contains_uuid_literal(&profile), "{profile}");
|
||||||
|
// 段长不对的连字符串不算(`8-4-4-4-12` 之外一律放行)。
|
||||||
|
assert!(!contains_uuid_literal(
|
||||||
|
"let x = \"abcdefgh-1234-5678-9012-12345678901\";"
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
//! 回退说明有两层:结构化码 [`BrowserFallbackReason`](宿主据它出 i18n,**能说清怎么修**)
|
//! 回退说明有两层:结构化码 [`BrowserFallbackReason`](宿主据它出 i18n,**能说清怎么修**)
|
||||||
//! 与原始诊断 [`BrowserFallback::detail`](只进日志,不上屏)。
|
//! 与原始诊断 [`BrowserFallback::detail`](只进日志,不上屏)。
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
use crate::auth::error::{AuthError, AuthResult};
|
use crate::auth::error::{AuthError, AuthResult};
|
||||||
|
|
||||||
use super::authcode::{self, AuthCodeAttempt, AuthCodePrompt};
|
use super::authcode::{self, AuthCodeAttempt, AuthCodePrompt};
|
||||||
@@ -85,9 +87,10 @@ impl BrowserFallback {
|
|||||||
|
|
||||||
/// 一次「浏览器优先」登录的完整结果(M178)。
|
/// 一次「浏览器优先」登录的完整结果(M178)。
|
||||||
///
|
///
|
||||||
/// `listeners_opened` 是**这次登录**为本地回调开的监听口个数(0 或 1)。它存在的唯一理由是
|
/// `listeners_opened` 是**这次登录**真的去开本地监听口的次数(0 或 1,`bind_counting` 在
|
||||||
/// 守卫能直接数:「没登记回环」「走设备码」这两条路上必须是 0——用进程级全局计数器做不到
|
/// `TcpListener::bind` 现场 +1)。它存在的唯一理由是守卫能直接数:「没登记回环」「走设备码」
|
||||||
/// 这一点(并行跑的测试会互相搅动),所以计数跟着结果走。
|
/// 这两条路上必须是 0。用进程级全局计数器做不到这一点(并行跑的测试会互相搅动),所以计数
|
||||||
|
/// 是 per-login 的 `AtomicUsize`,但**增长点在绑定现场**,不在「进没进某个分支」。
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LoginOutcome {
|
pub struct LoginOutcome {
|
||||||
/// 拿到的会话(两条流任意一条走通)
|
/// 拿到的会话(两条流任意一条走通)
|
||||||
@@ -135,14 +138,16 @@ impl MicrosoftAuth {
|
|||||||
FD: FnMut(&DeviceCodePrompt, Option<&BrowserFallback>),
|
FD: FnMut(&DeviceCodePrompt, Option<&BrowserFallback>),
|
||||||
{
|
{
|
||||||
let mut fallback: Option<BrowserFallback> = None;
|
let mut fallback: Option<BrowserFallback> = None;
|
||||||
// 预检通过之后才会绑端口(`login_checked` 的顺序),所以只有「真去试了」才记 1。
|
// 绑定现场的**真计数**(`CallbackServer::bind_counting` 每次调 `TcpListener::bind` 前 +1):
|
||||||
let mut bound_listener = false;
|
// 不是「进没进授权码分支」的代理指标——那正是复审 r1 P2-1 变异 D 打穿的地方。
|
||||||
|
let listeners = AtomicUsize::new(0);
|
||||||
if self.browser_first {
|
if self.browser_first {
|
||||||
match authcode::login_checked(
|
match authcode::login_checked(
|
||||||
&self.client,
|
&self.client,
|
||||||
&self.endpoints,
|
&self.endpoints,
|
||||||
&self.client_id,
|
&self.client_id,
|
||||||
self.authcode.clone(),
|
self.authcode.clone(),
|
||||||
|
&listeners,
|
||||||
present_browser,
|
present_browser,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -151,14 +156,13 @@ impl MicrosoftAuth {
|
|||||||
return Ok(LoginOutcome {
|
return Ok(LoginOutcome {
|
||||||
session,
|
session,
|
||||||
browser: None,
|
browser: None,
|
||||||
listeners_opened: 1,
|
listeners_opened: listeners.load(Ordering::Relaxed),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 跳过:**没绑端口**(预检在绑定之前),所以计数仍是 0。
|
// 跳过:**没绑端口**(预检在绑定之前),计数仍是 0。
|
||||||
Ok(AuthCodeAttempt::Skipped(note)) => fallback = Some(note),
|
Ok(AuthCodeAttempt::Skipped(note)) => fallback = Some(note),
|
||||||
Err(AuthError::Canceled) => return Err(AuthError::Canceled),
|
Err(AuthError::Canceled) => return Err(AuthError::Canceled),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
bound_listener = true;
|
|
||||||
self.record_failure("authcode_login", &error);
|
self.record_failure("authcode_login", &error);
|
||||||
let reason = if preflight::error_means_unregistered(&error) {
|
let reason = if preflight::error_means_unregistered(&error) {
|
||||||
BrowserFallbackReason::RedirectNotRegistered
|
BrowserFallbackReason::RedirectNotRegistered
|
||||||
@@ -185,7 +189,7 @@ impl MicrosoftAuth {
|
|||||||
Ok(LoginOutcome {
|
Ok(LoginOutcome {
|
||||||
session,
|
session,
|
||||||
browser: fallback,
|
browser: fallback,
|
||||||
listeners_opened: usize::from(bound_listener),
|
listeners_opened: listeners.load(Ordering::Relaxed),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,10 +23,13 @@
|
|||||||
//! 做:拿**即将使用的那个** `redirect_uri`(只有端口可以是任意的,所以用固定探测端口拼形状)
|
//! 做:拿**即将使用的那个** `redirect_uri`(只有端口可以是任意的,所以用固定探测端口拼形状)
|
||||||
//! 去授权端点发一次**不带凭据的 GET**,看它是否当场报 `invalid_request` / `AADSTS50011`。
|
//! 去授权端点发一次**不带凭据的 GET**,看它是否当场报 `invalid_request` / `AADSTS50011`。
|
||||||
//! 不注册 → 不绑本地端口、不弹浏览器,直接回退设备码。这样「没登记回调地址」的机器上
|
//! 不注册 → 不绑本地端口、不弹浏览器,直接回退设备码。这样「没登记回调地址」的机器上
|
||||||
//! 监听口一次都不开(守卫断言 `callback::bind_attempts()` 不涨)。
|
//! 监听口一次都不开(守卫断言 `LoginOutcome::listeners_opened == 0`,那个数是
|
||||||
|
//! [`super::callback::CallbackServer::bind_counting`] 在 `TcpListener::bind` 现场加的)。
|
||||||
//!
|
//!
|
||||||
//! 不做:预检失败**不等于**登录被拒——它只决定「要不要试浏览器」。
|
//! 不做:预检失败**不等于**登录被拒——它只决定「要不要试浏览器」。
|
||||||
//! `Unknown`(网络问题)同样按「别试」处理:设备码流不依赖任何注册项,永远更稳。
|
//! `Unknown`(网络问题/整体超时)同样按「别试」处理:设备码流不依赖任何注册项,永远更稳。
|
||||||
|
//! 整次预检有 [`PREFLIGHT_TIMEOUT`] 的上限:服务器接了连接却不出响应时,不能让「浏览器登录」
|
||||||
|
//! 按钮把界面卡在 Requesting(取消按钮只收面板、停不掉已经跑起来的线程)。
|
||||||
|
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
|
||||||
@@ -44,6 +47,14 @@ pub const PROBE_PORT: u16 = 53_691;
|
|||||||
/// 预检请求里的 `state`(不参与任何校验,只为拼出形状完整的授权 URL)。
|
/// 预检请求里的 `state`(不参与任何校验,只为拼出形状完整的授权 URL)。
|
||||||
const PROBE_STATE: &str = "ull-redirect-probe";
|
const PROBE_STATE: &str = "ull-redirect-probe";
|
||||||
|
|
||||||
|
/// 整次预检的时间上限(复审 r1 P2-2)。
|
||||||
|
///
|
||||||
|
/// 为什么必须有:下载引擎那个 HTTP 客户端只设了 `connect_timeout`(10s),**没有**整体超时
|
||||||
|
/// ——`read_timeout` 从未应用到 reqwest 客户端。授权端点接了连接却不出响应(或 header 后停住)时,
|
||||||
|
/// 这一次「用浏览器登录」会把界面卡在 Requesting,而取消按钮只收面板、停不掉已经跑起来的线程。
|
||||||
|
/// 超时按 [`RedirectRegistration::Unknown`] 处理:回退设备码,原因码「没确认」。
|
||||||
|
pub const PREFLIGHT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
|
||||||
/// 授权端点对 `redirect_uri` 的态度。
|
/// 授权端点对 `redirect_uri` 的态度。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum RedirectRegistration {
|
pub enum RedirectRegistration {
|
||||||
@@ -84,6 +95,42 @@ pub(super) async fn check(
|
|||||||
endpoints: &MicrosoftEndpoints,
|
endpoints: &MicrosoftEndpoints,
|
||||||
client_id: &str,
|
client_id: &str,
|
||||||
redirect_uri: &RedirectUri,
|
redirect_uri: &RedirectUri,
|
||||||
|
) -> RedirectRegistration {
|
||||||
|
check_with_deadline(
|
||||||
|
client,
|
||||||
|
endpoints,
|
||||||
|
client_id,
|
||||||
|
redirect_uri,
|
||||||
|
PREFLIGHT_TIMEOUT,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`check`] 的可注入 deadline 版本(单测用短 deadline,不必等 10 秒)。
|
||||||
|
pub(super) async fn check_with_deadline(
|
||||||
|
client: &Client,
|
||||||
|
endpoints: &MicrosoftEndpoints,
|
||||||
|
client_id: &str,
|
||||||
|
redirect_uri: &RedirectUri,
|
||||||
|
deadline: std::time::Duration,
|
||||||
|
) -> RedirectRegistration {
|
||||||
|
match tokio::time::timeout(deadline, probe(client, endpoints, client_id, redirect_uri)).await {
|
||||||
|
Ok(verdict) => verdict,
|
||||||
|
Err(_) => RedirectRegistration::Unknown {
|
||||||
|
detail: format!(
|
||||||
|
"预检 {} 毫秒内没有结论,按「没确认」处理",
|
||||||
|
deadline.as_millis()
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 真正的预检请求(不含超时;由 [`check_with_deadline`] 套 deadline)。
|
||||||
|
async fn probe(
|
||||||
|
client: &Client,
|
||||||
|
endpoints: &MicrosoftEndpoints,
|
||||||
|
client_id: &str,
|
||||||
|
redirect_uri: &RedirectUri,
|
||||||
) -> RedirectRegistration {
|
) -> RedirectRegistration {
|
||||||
let url = authorization_url(endpoints, client_id, redirect_uri, PROBE_STATE);
|
let url = authorization_url(endpoints, client_id, redirect_uri, PROBE_STATE);
|
||||||
let response = client.get(&url).send().await;
|
let response = client.get(&url).send().await;
|
||||||
@@ -175,9 +222,13 @@ fn snippet(body: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{error_means_unregistered, rejects_redirect_uri, snippet, RedirectRegistration};
|
use super::{
|
||||||
|
check_with_deadline, error_means_unregistered, rejects_redirect_uri, snippet,
|
||||||
|
RedirectRegistration,
|
||||||
|
};
|
||||||
use crate::auth::body::ResponseDetail;
|
use crate::auth::body::ResponseDetail;
|
||||||
use crate::auth::error::AuthError;
|
use crate::auth::error::AuthError;
|
||||||
|
use crate::auth::microsoft::{MicrosoftEndpoints, RedirectUri};
|
||||||
|
|
||||||
/// 用户 2026-11-08 实测的那张错误页(原文截取)必须被判成「没登记」。
|
/// 用户 2026-11-08 实测的那张错误页(原文截取)必须被判成「没登记」。
|
||||||
#[test]
|
#[test]
|
||||||
@@ -234,6 +285,50 @@ redirect+URI+registered+for+this+client+application.";
|
|||||||
.is_registered());
|
.is_registered());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **复审 r1 P2-2**:服务器接了连接却永不出响应时,预检必须靠**整体 deadline** 收场
|
||||||
|
/// (不是 connect_timeout:连接早就建好了),结论是「没确认」⇒ 上层回退设备码。
|
||||||
|
///
|
||||||
|
/// 抓得住:把 `tokio::time::timeout` 去掉(那条路会一直挂着,本用例在 deadline 后
|
||||||
|
/// 断言超时不出来,必然失败)。
|
||||||
|
/// 抓不住:真机上网关的其它卡法(这里只有一个「不回字」的假服务器)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_silent_authorize_server_hits_the_overall_deadline() {
|
||||||
|
// 假授权端点:accept 之后一个字节都不写(connect 成功、读永远等不到)。
|
||||||
|
let silent = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||||
|
.await
|
||||||
|
.expect("假服务器起不来");
|
||||||
|
let port = silent.local_addr().expect("地址").port();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut held = Vec::new();
|
||||||
|
loop {
|
||||||
|
let Ok((stream, _)) = silent.accept().await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
held.push(stream);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let client =
|
||||||
|
crate::download::http::build_client(&crate::download::config::EngineConfig::default())
|
||||||
|
.expect("HTTP 客户端");
|
||||||
|
let mut endpoints = MicrosoftEndpoints::all_on("http://127.0.0.1:1");
|
||||||
|
endpoints.authorize = format!("http://127.0.0.1:{port}/authorize");
|
||||||
|
let verdict = check_with_deadline(
|
||||||
|
&client,
|
||||||
|
&endpoints,
|
||||||
|
"test-client-id",
|
||||||
|
&RedirectUri::new(53_691),
|
||||||
|
std::time::Duration::from_millis(200),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match verdict {
|
||||||
|
RedirectRegistration::Unknown { detail } => {
|
||||||
|
assert!(detail.contains("200"), "文案要带 deadline:{detail}");
|
||||||
|
}
|
||||||
|
other => panic!("静默服务器必须走 Unknown,实际 {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snippet_is_single_line_and_capped() {
|
fn snippet_is_single_line_and_capped() {
|
||||||
let long = format!("a\nb\t{}", "x".repeat(400));
|
let long = format!("a\nb\t{}", "x".repeat(400));
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ async fn unregistered_redirect_falls_back_to_the_device_code() {
|
|||||||
outcome.listeners_opened, 0,
|
outcome.listeners_opened, 0,
|
||||||
"没登记回环时不许绑本地端口(预检必须在绑定之前)"
|
"没登记回环时不许绑本地端口(预检必须在绑定之前)"
|
||||||
);
|
);
|
||||||
|
// ③ **报文级证据**:预检真的发出去了(授权端点收到 1 次 GET),而不是被跳过。
|
||||||
|
// 这条也是复审 P1-1 的回执:只要生产路径把 `browser_first` 置位,这条 GET 就会发生。
|
||||||
|
assert_eq!(
|
||||||
|
mock.bodies_on("/authorize").len(),
|
||||||
|
1,
|
||||||
|
"浏览器优先路必须真的发一次回环注册预检"
|
||||||
|
);
|
||||||
mock.stop();
|
mock.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ macro_rules! account_entries {
|
|||||||
AccountDeviceRetry => ("Try again", "重试"),
|
AccountDeviceRetry => ("Try again", "重试"),
|
||||||
AccountDeviceCancel => ("Cancel", "取消"),
|
AccountDeviceCancel => ("Cancel", "取消"),
|
||||||
AccountDeviceBrowser => ("Sign in with browser", "用浏览器登录"),
|
AccountDeviceBrowser => ("Sign in with browser", "用浏览器登录"),
|
||||||
AccountDeviceUseCode => ("Use a device code instead", "改用设备码"),
|
// M178 复审 r1 P2-5:这是**主按钮**(设备码流),不能读起来像备选。
|
||||||
|
AccountDeviceSignIn => ("Sign in with your Microsoft account", "使用 Microsoft 账户登录"),
|
||||||
|
AccountDeviceFallbackHint => ("No browser handy? This way shows a code you can type on any device.", "浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。"),
|
||||||
// ── 第三方站表单
|
// ── 第三方站表单
|
||||||
AccountServerLabel => ("Server address", "服务器地址"),
|
AccountServerLabel => ("Server address", "服务器地址"),
|
||||||
AccountServerHint => ("API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"),
|
AccountServerHint => ("API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"),
|
||||||
|
|||||||
@@ -135,7 +135,8 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
|||||||
("AccountDeviceRetry", "Try again", "重试"),
|
("AccountDeviceRetry", "Try again", "重试"),
|
||||||
("AccountDeviceCancel", "Cancel", "取消"),
|
("AccountDeviceCancel", "Cancel", "取消"),
|
||||||
("AccountDeviceBrowser", "Sign in with browser", "用浏览器登录"),
|
("AccountDeviceBrowser", "Sign in with browser", "用浏览器登录"),
|
||||||
("AccountDeviceUseCode", "Use a device code instead", "改用设备码"),
|
("AccountDeviceSignIn", "Sign in with your Microsoft account", "使用 Microsoft 账户登录"),
|
||||||
|
("AccountDeviceFallbackHint", "No browser handy? This way shows a code you can type on any device.", "浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。"),
|
||||||
("AccountServerLabel", "Server address", "服务器地址"),
|
("AccountServerLabel", "Server address", "服务器地址"),
|
||||||
("AccountServerHint", "API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"),
|
("AccountServerHint", "API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"),
|
||||||
("AccountServerProbe", "Check server", "校验服务器"),
|
("AccountServerProbe", "Check server", "校验服务器"),
|
||||||
|
|||||||
@@ -188,12 +188,11 @@ impl LoginJob {
|
|||||||
|
|
||||||
/// 起一次微软登录(M178:**GUI 的默认路**——设备码)。
|
/// 起一次微软登录(M178:**GUI 的默认路**——设备码)。
|
||||||
///
|
///
|
||||||
/// 设备码流不依赖任何应用注册项(Azure 那边只需要一个公共客户端 id),所以它是唯一
|
/// 设备码流不依赖任何应用注册项,是唯一「在官方产物上开箱可用」的路;它**一个本地监听口都不开**,
|
||||||
/// 「在官方产物上开箱可用」的路。它**一个本地监听口都不开**,也就不会撞上
|
/// 也就不会撞上 `redirect_uri is not valid`(守卫断言 `listeners_opened == 0`,在 bind 现场加)。
|
||||||
/// `redirect_uri is not valid` 那张 AAD 错误页(守卫断言见 `callback::bind_attempts`)。
|
|
||||||
pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
|
pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
|
||||||
spawn("msa-login", move || {
|
spawn("msa-login", move || {
|
||||||
let auth = match microsoft_auth(&env) {
|
let auth = match msa_auth(&env, MsaEntry::DeviceCode, &job) {
|
||||||
Ok(auth) => auth,
|
Ok(auth) => auth,
|
||||||
Err(error) => return fail(&tx, auth_failure(&error), error.to_string()),
|
Err(error) => return fail(&tx, auth_failure(&error), error.to_string()),
|
||||||
};
|
};
|
||||||
@@ -212,16 +211,13 @@ pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
|
|||||||
|
|
||||||
/// 起一次「浏览器优先、失败自动回退设备码」的登录(M178:可选加速路)。
|
/// 起一次「浏览器优先、失败自动回退设备码」的登录(M178:可选加速路)。
|
||||||
///
|
///
|
||||||
/// 只在用户**显式要求**时才走到这里(账户页的「用浏览器登录」)。即使如此也不盲试:
|
/// 只在用户**显式要求**时才走到这里(账户页的「用浏览器登录」)。即使如此也不盲试:`core` 先做
|
||||||
/// `core` 先做回环重定向 URI 的注册预检,没登记就**不绑端口、不弹浏览器**,直接设备码;
|
/// 回环注册预检,没登记就**不绑端口、不弹浏览器**,直接设备码;试了但失败同样回退并给一句说明。
|
||||||
/// 试了但失败(超时/被拒/端口绑不上)同样回退。两条路都不满意时用户还能在面板上看到
|
|
||||||
/// 一句「为什么改用设备码 + 怎么修」(结构化码 → i18n,原文只进日志)。
|
|
||||||
///
|
|
||||||
/// 与设备码流共用同一个 [`LoginJob`] 取消槽位、同一段账户入库逻辑,所以两条流的
|
|
||||||
/// 凭据/刷新语义完全一致(`core` 侧也共用 [`unlucky_core::auth::MicrosoftSession`])。
|
|
||||||
pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) {
|
pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) {
|
||||||
spawn("msa-browser", move || {
|
spawn("msa-browser", move || {
|
||||||
let auth = match microsoft_auth(&env) {
|
// **P1-1 的那一行**:这里是「用户点了用浏览器登录」,必须把 browser_first 置位,
|
||||||
|
// 否则下面这段预检/回退编排整段不执行(复审 r1 实测的洞)。
|
||||||
|
let auth = match msa_auth(&env, MsaEntry::Browser, &job) {
|
||||||
Ok(auth) => auth,
|
Ok(auth) => auth,
|
||||||
Err(error) => return fail(&tx, auth_failure(&error), error.to_string()),
|
Err(error) => return fail(&tx, auth_failure(&error), error.to_string()),
|
||||||
};
|
};
|
||||||
@@ -273,16 +269,75 @@ fn browser_note(reason: BrowserFallbackReason) -> BrowserLoginNote {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生产入口的 `MicrosoftAuth`:client id 取环境/构建期,失败日志落 `<数据根>/logs/`。
|
/// 微软登录的两个入口(决定 `MicrosoftAuth::browser_first`,M178)。
|
||||||
fn microsoft_auth(env: &HostEnv) -> AuthResult<MicrosoftAuth> {
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum MsaEntry {
|
||||||
|
/// 默认路:设备码。**一个监听口都不开**,也永远不做回环预检。
|
||||||
|
DeviceCode,
|
||||||
|
/// 用户显式点了「用浏览器登录」:浏览器优先(预检 → 授权码 → 失败自动回退设备码)。
|
||||||
|
Browser,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MsaEntry {
|
||||||
|
/// 这个入口要不要把 `browser_first` 置位。
|
||||||
|
const fn browser_first(self) -> bool {
|
||||||
|
matches!(self, Self::Browser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生产入口的 `MicrosoftAuth`(client id 取环境/构建期,失败日志落 `<数据根>/logs/`)。
|
||||||
|
///
|
||||||
|
/// **`browser_first` 必须在这里按入口置位**(复审 r1 P1-1):只构造默认 `MicrosoftAuth` 的话,
|
||||||
|
/// 浏览器按钮会静默退化成设备码——预检/回退码/说明全不可达。
|
||||||
|
fn msa_auth(env: &HostEnv, entry: MsaEntry, _job: &LoginJob) -> AuthResult<MicrosoftAuth> {
|
||||||
|
#[cfg(test)]
|
||||||
|
test_seam::record(_job, entry);
|
||||||
MicrosoftAuth::from_env().map(|auth| {
|
MicrosoftAuth::from_env().map(|auth| {
|
||||||
auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir(
|
auth.with_browser_first(entry.browser_first())
|
||||||
env.layout.logs(),
|
.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir(
|
||||||
)))
|
env.layout.logs(),
|
||||||
|
)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `microsoft_auth` 失败 → 原因码。
|
/// 测试缝:记下生产入口**实际请求**的模式,让守卫能钉住「按钮 → browser_first=true」这条线。
|
||||||
|
///
|
||||||
|
/// 为什么需要它:两条入口在没有 client id 的测试构建里都会在网络之前早退,拿不到
|
||||||
|
/// `MicrosoftAuth` 实例去问 `browser_first()`;而 P1-1 的 bug 恰恰在**调用点**。这里在被调用
|
||||||
|
/// 的一刻就把模式记下来,把调用点变成可断言的事实。
|
||||||
|
///
|
||||||
|
/// 为什么按 `LoginJob` 的取消槽指针分桶:同一进程里别的用例也在调这两个入口,一个全局
|
||||||
|
/// 「最后一次」记录会串台(实测过:并行跑时读到别人的 Browser ⇒ 假红)。
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test_seam {
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use super::{LoginJob, MsaEntry};
|
||||||
|
|
||||||
|
static ENTRIES: Mutex<Vec<(usize, bool)>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
fn key(job: &LoginJob) -> usize {
|
||||||
|
Arc::as_ptr(&job.cancelled) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn record(job: &LoginJob, entry: MsaEntry) {
|
||||||
|
let key = key(job);
|
||||||
|
let mut entries = ENTRIES.lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
entries.push((key, entry.browser_first()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这个 job 的生产构造请求的模式(`None` = 还没调用过)。
|
||||||
|
pub(super) fn mode_for(job: &LoginJob) -> Option<bool> {
|
||||||
|
let key = key(job);
|
||||||
|
let entries = ENTRIES.lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.find(|(recorded, _)| *recorded == key)
|
||||||
|
.map(|(_, browser_first)| *browser_first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `msa_auth` 失败 → 原因码。
|
||||||
///
|
///
|
||||||
/// **只有「两处都没配 client id」单独成码**(M132):`from_env` 的 `Unsupported` 是它唯一的
|
/// **只有「两处都没配 client id」单独成码**(M132):`from_env` 的 `Unsupported` 是它唯一的
|
||||||
/// 产出点,且这条路径零网络。上屏的正文是 catalog 里的固定文案(原文照旧只进日志),理由是
|
/// 产出点,且这条路径零网络。上屏的正文是 catalog 里的固定文案(原文照旧只进日志),理由是
|
||||||
|
|||||||
@@ -186,6 +186,77 @@ fn login_job_cancel_flag_is_shared() {
|
|||||||
assert!(clone.is_cancelled());
|
assert!(clone.is_cancelled());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **M178 复审 r1 P1-1 的守卫**:生产路径上,「用浏览器登录」这颗按钮必须真的把
|
||||||
|
/// `browser_first` 置位,设备码那颗必须不置位。
|
||||||
|
///
|
||||||
|
/// 修复前实测过的形态:`start_browser_login` 用共享的 `microsoft_auth(&env)`(默认
|
||||||
|
/// `browser_first=false`),于是 `browser_then_device_login` 里整段预检/回退编排不执行——
|
||||||
|
/// 按钮静默退化成设备码、连一句说明都不出,`preflight` / `BrowserFallbackReason` /
|
||||||
|
/// `BrowserLoginNote` / 三条 i18n 文案全成不可达代码。
|
||||||
|
///
|
||||||
|
/// 抓得住(本用例就是为此写的):把 `msa_auth(&env, MsaEntry::Browser)` 改回
|
||||||
|
/// `MsaEntry::DeviceCode`(或删掉 `.with_browser_first(...)` 那一跳以外的模式选择),
|
||||||
|
/// 记录会变成 `false` ⇒ 红。
|
||||||
|
/// 抓不住:真的去连微软(无 client id 的测试构建里两条入口都在网络之前早退)。
|
||||||
|
#[test]
|
||||||
|
fn the_browser_button_actually_turns_on_browser_first() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("ull-msa-entry-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
let env = HostEnv::at_root(&dir);
|
||||||
|
std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录");
|
||||||
|
|
||||||
|
// 设备码入口:不许碰浏览器(也不许碰回环预检)。
|
||||||
|
let device_job = LoginJob::new();
|
||||||
|
let (tx, _rx) = super::super::job::channel();
|
||||||
|
start_login(env.clone(), tx, device_job.clone());
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_entry(&device_job),
|
||||||
|
Some(false),
|
||||||
|
"设备码入口把 browser_first 置成了 true:默认路会去开监听口/弹浏览器"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 浏览器入口:**必须**置位,否则预检与回退整段不可达。
|
||||||
|
let browser_job = LoginJob::new();
|
||||||
|
let (tx, _rx) = super::super::job::channel();
|
||||||
|
start_browser_login(env, tx, browser_job.clone());
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_entry(&browser_job),
|
||||||
|
Some(true),
|
||||||
|
"「用浏览器登录」没有置位 browser_first:按钮会静默退化成设备码(复审 r1 P1-1 的洞)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 等登录线程把「这个 job 的入口模式」记下来(线程是 `spawn` 出去的,最多等这么久)。
|
||||||
|
///
|
||||||
|
/// 按 job 分桶而不是读「最后一次」:同一进程里别的用例也在调这两个入口(实测会串台假红)。
|
||||||
|
fn wait_for_entry(job: &LoginJob) -> Option<bool> {
|
||||||
|
for _ in 0..200 {
|
||||||
|
if let Some(flag) = super::test_seam::mode_for(job) {
|
||||||
|
return Some(flag);
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 置位不是「记了个数」:`msa_auth` 必须把模式真的落到 `MicrosoftAuth::browser_first` 上。
|
||||||
|
///
|
||||||
|
/// 前半段(入口 → 模式)由上一个用例守;这里守后半段(模式 → 实例字段)。两者缺一,
|
||||||
|
/// 就会出现「日志说置位了、实际没置位」这类假绿。
|
||||||
|
#[test]
|
||||||
|
fn the_entry_mode_lands_on_the_auth_instance() {
|
||||||
|
assert!(!MsaEntry::DeviceCode.browser_first());
|
||||||
|
assert!(MsaEntry::Browser.browser_first());
|
||||||
|
// 无 client id 时 `from_env` 早退,所以这里用显式 id 走同一个 builder 链。
|
||||||
|
let auth = unlucky_core::auth::microsoft::MicrosoftAuth::new("0000-entry-test")
|
||||||
|
.expect("构造")
|
||||||
|
.with_browser_first(MsaEntry::Browser.browser_first());
|
||||||
|
assert!(
|
||||||
|
auth.browser_first(),
|
||||||
|
"with_browser_first 没把字段置上(core 里 browser_then_device_login 会整段跳过)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// **M178**:core 的回退原因码 → 页面的说明码。两边的 ascii 码必须逐字相同——
|
/// **M178**:core 的回退原因码 → 页面的说明码。两边的 ascii 码必须逐字相同——
|
||||||
/// 日志里看到的码就是页面上 `Code:` 那一行,对不上就没法用用户回报的码定位。
|
/// 日志里看到的码就是页面上 `Code:` 那一行,对不上就没法用用户回报的码定位。
|
||||||
///
|
///
|
||||||
|
|||||||
Reference in new issue
Block a user