From f82bd395f8f2e221e26ae91b5d7ee1fcf3711a7e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 11:14:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(msa):=20=E5=BE=AE=E8=BD=AF=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=BB=98=E8=AE=A4=E8=B5=B0=E8=AE=BE=E5=A4=87=E7=A0=81?= =?UTF-8?q?=EF=BC=8C=E6=B5=8F=E8=A7=88=E5=99=A8=E6=B5=81=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E4=B8=BA=E5=8F=AF=E9=80=89=E5=8A=A0=E9=80=9F=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 官方产物上点「微软登录」必失败: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 字面量)。 --- crates/app-iced/src/app/update/account.rs | 22 +- .../app-iced/src/app/update/account/tests.rs | 4 +- crates/app-iced/src/pages/account.rs | 3 + crates/app-iced/src/pages/account/login.rs | 118 +++++--- crates/app-iced/src/pages/account/model.rs | 19 +- crates/app-iced/src/pages/account/panels.rs | 49 +++- crates/app-iced/src/pages/account/tests.rs | 13 +- .../app-iced/src/pages/account/tests_msa.rs | 87 ++++++ .../app-iced/src/pages/account/tests_text.rs | 27 +- crates/core/src/auth/microsoft/authcode.rs | 45 +++ crates/core/src/auth/microsoft/client_id.rs | 98 +++++++ crates/core/src/auth/microsoft/login.rs | 191 +++++++++++++ crates/core/src/auth/microsoft/mod.rs | 58 +++- crates/core/src/auth/microsoft/preflight.rs | 245 ++++++++++++++++ crates/core/tests/auth/main.rs | 2 + crates/core/tests/auth/microsoft_msalogin.rs | 267 ++++++++++++++++++ crates/i18n/src/catalog/account.rs | 59 ++++ crates/i18n/src/catalog/golden/tail2.rs | 3 + crates/presenter/src/dto/accounts.rs | 47 +-- crates/presenter/src/dto/accounts/failure.rs | 28 +- crates/presenter/src/dto/mod.rs | 4 +- crates/presenter/src/host/accounts.rs | 84 ++++-- crates/presenter/src/host/accounts/tests.rs | 31 ++ 23 files changed, 1373 insertions(+), 131 deletions(-) create mode 100644 crates/app-iced/src/pages/account/tests_msa.rs create mode 100644 crates/core/src/auth/microsoft/login.rs create mode 100644 crates/core/src/auth/microsoft/preflight.rs create mode 100644 crates/core/tests/auth/microsoft_msalogin.rs diff --git a/crates/app-iced/src/app/update/account.rs b/crates/app-iced/src/app/update/account.rs index c2a8467..b6f2f18 100644 --- a/crates/app-iced/src/app/update/account.rs +++ b/crates/app-iced/src/app/update/account.rs @@ -95,7 +95,7 @@ impl App { self.dispatch(AppCommand::AddAuthlibAccount(request)); } } - Panel::Microsoft => return self.on_account(Account::StartBrowser), + Panel::Microsoft => return self.on_account(Account::StartDevice), Panel::None => {} }, Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin), @@ -136,7 +136,8 @@ impl App { match account.kind { AccountKind::Microsoft => { self.account_form.panel = Panel::Microsoft; - self.dispatch(AppCommand::StartBrowserLogin); + // M178:重登也走设备码(不依赖任何应用注册项)。想试浏览器再点面板里的次按钮。 + self.dispatch(AppCommand::StartDeviceLogin); } AccountKind::Authlib => { self.account_form.open(Track::Authlib); @@ -173,6 +174,23 @@ pub(super) fn force_account_state(app: &mut App) { app.state.device_login_started = Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600)); } + // M178:浏览器登录没走成、已回退设备码的那一态(设备码 + 一句「为什么 + 怎么修」)。 + // + // 为什么在 app 层造:真走到这一步要一次真网络请求(预检),截图通道要的是**确定性**。 + // 这里只替换登录视图本身——面板的渲染路径与真 host 完全同一条 + // (`apply_device_login` → `pages::account::panels::device_panel` → `login::panel`)。 + "msa-fallback" => { + app.account_form.panel = crate::pages::account::Panel::Microsoft; + app.state.apply_device_login( + unlucky_presenter::dto::DeviceLoginView::waiting( + "ABCD-EFGH", + "https://www.microsoft.com/link", + 900, + "", + ) + .with_browser_note(unlucky_presenter::dto::BrowserLoginNote::RedirectNotRegistered), + ); + } // 第三方站**失败**态:面板打开 + mock 那边给出的 `AuthlibLoginChanged(Failed)` "authlib-failed" => { app.account_form.panel = crate::pages::account::Panel::Authlib; diff --git a/crates/app-iced/src/app/update/account/tests.rs b/crates/app-iced/src/app/update/account/tests.rs index d74492b..4d43d37 100644 --- a/crates/app-iced/src/app/update/account/tests.rs +++ b/crates/app-iced/src/app/update/account/tests.rs @@ -125,10 +125,10 @@ fn the_device_code_flow_is_started_visible_and_cancellable() { let _ = app.on_account(Account::StartDevice); match &app.account.device { DevicePanel::Code { - code, remaining, .. + code, countdown, .. } => { assert_eq!(code, "ABCD-EFGH", "用户码要上屏"); - assert!(remaining.is_some_and(|left| left > 0), "倒计时在跑"); + assert!(!countdown.is_empty(), "倒计时在跑(文案由模型拼好)"); } other => panic!("进行中状态必须可见,实际 {other:?}"), } diff --git a/crates/app-iced/src/pages/account.rs b/crates/app-iced/src/pages/account.rs index a7ef912..d016ea6 100644 --- a/crates/app-iced/src/pages/account.rs +++ b/crates/app-iced/src/pages/account.rs @@ -38,6 +38,9 @@ pub mod widgets; #[cfg(test)] mod tests; #[cfg(test)] +#[path = "account/tests_msa.rs"] +mod tests_msa; +#[cfg(test)] #[path = "account/tests_text.rs"] mod tests_text; diff --git a/crates/app-iced/src/pages/account/login.rs b/crates/app-iced/src/pages/account/login.rs index 6d6c718..b967c34 100644 --- a/crates/app-iced/src/pages/account/login.rs +++ b/crates/app-iced/src/pages/account/login.rs @@ -4,7 +4,14 @@ //! //! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久, //! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、 -//! 倒计时(`{secs}s left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。 +//! 倒计时(`15:00 left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。 +//! +//! ## 文案一律**借用模型里的串**(M178) +//! +//! 倒计时/回退说明这类运行时格式化出来的句子由 [`super::panels`] 在模型层拼好(带 i18n), +//! 视图只拿 `&'a str` 画。为什么不在这里 `format!` 后交给 `frame::text_role_owned`: +//! 那条 owned 路径上的**中文画不出来**(实测:同一位置 ASCII 能画、`剩 15:00` 一片空白), +//! 而借用路径正常。模型持有字符串、视图借用,两个问题一起消掉。 use iced::widget::{column, row}; use iced::{Alignment, Element}; @@ -13,7 +20,7 @@ use unlucky_design::color::Role; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; use unlucky_i18n::{Catalog, Key}; -use unlucky_presenter::dto::LoginFailure; +use unlucky_presenter::dto::{BrowserLoginNote, LoginFailure}; use crate::app::components::buttons::{self, Button}; use crate::pages::frame; @@ -36,34 +43,37 @@ pub fn panel<'a>( DevicePanel::Code { code, link, - remaining, + countdown, expired, - } => device_code(ui, code, link, *remaining, *expired, catalog), + note, + } => device_code(ui, code, link, countdown, *expired, *note, catalog), DevicePanel::Browser { link, - remaining, + countdown, expired, - } => browser(ui, link, *remaining, *expired, catalog), - DevicePanel::Failed { failure } => failed(ui, *failure, catalog), + } => browser(ui, link, countdown, *expired, catalog), + DevicePanel::Failed { failure, note } => failed(ui, *failure, *note, catalog), DevicePanel::Succeeded { name } => succeeded(ui, name, catalog), } } -/// 空闲:给出两条流的入口(GUI 默认授权码流,设备码是备选)。 +/// 空闲:两条流的入口。**主按钮是设备码**(M178):它不依赖应用注册里的任何一项,是官方 +/// 产物上唯一开箱可用的路;浏览器流是右手的次要按钮(要注册 `http://localhost/auth`, +/// 没登记时在预检处直接回退设备码)。 fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { let body = column![row![ buttons::filled( ui, Button::new( - catalog.text(Key::AccountDeviceBrowser), - Account::StartBrowser.into_page(), + catalog.text(Key::AccountDeviceUseCode), + Account::StartDevice.into_page(), ) ), buttons::outlined( ui, Button::new( - catalog.text(Key::AccountDeviceUseCode), - Account::StartDevice.into_page(), + catalog.text(Key::AccountDeviceBrowser), + Account::StartBrowser.into_page(), ) ), buttons::text( @@ -105,13 +115,14 @@ fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { frame::card(ui, body) } -/// 设备码等待态:**码 + 链接 + 倒计时**。 +/// 设备码等待态:**码 + 链接 + 倒计时**(`note` = 浏览器登录没走成时的回退说明)。 fn device_code<'a>( ui: &'a Ui, code: &'a str, link: &'a str, - remaining: Option, + countdown: &'a str, expired: bool, + note: Option, catalog: Catalog, ) -> Element<'a, PageMessage> { let mut actions = row![ @@ -150,9 +161,7 @@ fn device_code<'a>( ), )); - let countdown = countdown_line(ui, remaining, expired, catalog); - - let body = column![ + let mut body = column![ frame::text_role( ui, TypeRole::LabelMedium, @@ -174,7 +183,7 @@ fn device_code<'a>( link, ui.color(Role::OnSurfaceVariant), ), - countdown, + countdown_line(ui, countdown, expired), frame::text_role( ui, TypeRole::BodySmall, @@ -182,17 +191,21 @@ fn device_code<'a>( catalog.text(Key::AccountDeviceHint), ui.color(Role::OnSurfaceVariant), ), - actions, ] .spacing(ui.space(Space::X3)); - frame::card(ui, body) + // 回退说明放在**动作行之前**:它解释的是「这个面板为什么是设备码」, + // 用户读完再决定复制码/取消(放在按钮下面会像脚注,读不到)。 + if let Some(text) = note_text(ui, note, catalog) { + body = body.push(text); + } + frame::card(ui, body.push(actions)) } /// 授权码等待态:浏览器已拉起,链接可复制;过期可重试。 fn browser<'a>( ui: &'a Ui, link: &'a str, - remaining: Option, + countdown: &'a str, expired: bool, catalog: Catalog, ) -> Element<'a, PageMessage> { @@ -237,7 +250,7 @@ fn browser<'a>( link, ui.color(Role::OnSurfaceVariant), ), - countdown_line(ui, remaining, expired, catalog), + countdown_line(ui, countdown, expired), actions, ] .spacing(ui.space(Space::X3)); @@ -245,29 +258,13 @@ fn browser<'a>( } /// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**)。 -fn countdown_line<'a>( - ui: &'a Ui, - remaining: Option, - expired: bool, - catalog: Catalog, -) -> Element<'a, PageMessage> { +/// +/// 文案由模型拼好(`panels::device_panel` 用 catalog 渲染 `{time}`),这里只决定颜色与强调。 +fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, PageMessage> { if expired { - return frame::text_role( - ui, - TypeRole::LabelMedium, - true, - catalog.text(Key::AccountDeviceExpired), - ui.color(Role::Error), - ); + return frame::text_role(ui, TypeRole::LabelMedium, true, text, ui.color(Role::Error)); } - let text = remaining.map_or_else(String::new, |secs| { - // mm:ss(`15:00 left`)而不是 `900s left`:人读的是分秒,而且不随语言换格式 - // (数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog)。 - let clock = format!("{:02}:{:02}", secs / 60, secs % 60); - catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)]) - }); - // 运行时格式化的串走 `text_role_owned`(`text_role` 只借 `&'a str`) - frame::text_role_owned( + frame::text_role( ui, TypeRole::LabelMedium, false, @@ -276,16 +273,43 @@ fn countdown_line<'a>( ) } +/// 回退说明那一行(`None` = 没有这回事)。 +fn note_text<'a>( + ui: &'a Ui, + note: Option, + catalog: Catalog, +) -> Option> { + let note = note?; + // 用正文色而不是 `Error`:这不是失败态(设备码接着跑),画成红色会让人以为登录已经挂了。 + Some(frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(super::model::note_key(note)), + ui.color(Role::OnSurfaceVariant), + )) +} + /// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。 /// -/// 三点修订叠在这里: +/// 四点修订叠在这里: /// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏; /// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边, /// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小); /// - **可操作的失败要有可读说明(M132)**:缺 client id 换专属标题 + 「要设哪个环境变量」的 -/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错。 -fn failed<'a>(ui: &'a Ui, failure: LoginFailure, catalog: Catalog) -> Element<'a, PageMessage> { - let body = catalog.text(failure_body_key(failure)).to_string(); +/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错; +/// - **M178**:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。 +fn failed<'a>( + ui: &'a Ui, + failure: LoginFailure, + note: Option, + catalog: Catalog, +) -> Element<'a, PageMessage> { + let mut body = catalog.text(failure_body_key(failure)).to_string(); + if let Some(note) = note { + body.push_str("\n\n"); + body.push_str(catalog.text(super::model::note_key(note))); + } let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]); let mut actions: Vec> = Vec::new(); if !matches!(failure, LoginFailure::MsaNotConfigured) { diff --git a/crates/app-iced/src/pages/account/model.rs b/crates/app-iced/src/pages/account/model.rs index 03d6eea..36d21eb 100644 --- a/crates/app-iced/src/pages/account/model.rs +++ b/crates/app-iced/src/pages/account/model.rs @@ -7,7 +7,9 @@ use iced::widget::image; use unlucky_i18n::{Key, Lang}; -use unlucky_presenter::dto::{AccountKind, AccountNotice, AccountNoticeCode, AccountView}; +use unlucky_presenter::dto::{ + AccountKind, AccountNotice, AccountNoticeCode, AccountView, BrowserLoginNote, +}; use unlucky_presenter::state::AppState; use super::avatar::{Avatar, Cache}; @@ -164,6 +166,18 @@ impl Form { } } +/// 回退说明码 → i18n key(**唯一的「码→文案」映射点**;原文永远不上屏)。 +/// +/// 与 [`Track::title_key`] 同一种写法:页面只认 key,后端只给码,两边独立演进。 +#[must_use] +pub const fn note_key(note: BrowserLoginNote) -> Key { + match note { + BrowserLoginNote::RedirectNotRegistered => Key::AccountBrowserFallbackNotRegistered, + BrowserLoginNote::PreflightUnavailable => Key::AccountBrowserFallbackUnverified, + BrowserLoginNote::AttemptFailed => Key::AccountBrowserFallbackFailed, + } +} + /// 列表行 / 当前账户卡片用的数据。 #[derive(Debug, Clone, PartialEq)] pub struct Row { @@ -253,7 +267,8 @@ impl Page { .map(|account| row_of(account, avatars, lang)) .collect(); let active = rows.iter().position(|row| row.active); - let device = device_panel(&state.device_login, state.device_code_remaining()); + let remaining = state.device_code_remaining(); + let device = device_panel(&state.device_login, remaining, lang.catalog()); // 登录在进行(或刚失败/刚成功)时,面板**必须**是微软那条轨:否则用户点了登录、 // 后台已经在等设备码,页面上却只剩三张入口卡——那正是「只转圈」的另一种坏法。 let panel = if matches!(device, DevicePanel::Hidden) { diff --git a/crates/app-iced/src/pages/account/panels.rs b/crates/app-iced/src/pages/account/panels.rs index 5feb6b9..b956329 100644 --- a/crates/app-iced/src/pages/account/panels.rs +++ b/crates/app-iced/src/pages/account/panels.rs @@ -10,7 +10,10 @@ //! 现在直接把字段删掉:视图**没有材料可画**,想画原文是编译错误——比任何测试都硬。 //! 原文仍在 presenter 的 `DeviceLoginView/AuthlibLoginView::message` 里(只进 `eprintln!`)。 -use unlucky_presenter::dto::{AuthlibLoginView, DeviceLoginPhase, DeviceLoginView, LoginFailure}; +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{ + AuthlibLoginView, BrowserLoginNote, DeviceLoginPhase, DeviceLoginView, LoginFailure, +}; /// 微软登录面板要画什么(每态都不同,**不允许只有一个转圈**)。 #[derive(Debug, Clone, PartialEq, Eq)] @@ -25,17 +28,23 @@ pub enum DevicePanel { code: String, /// 验证链接 link: String, - /// 剩余秒数 - remaining: Option, - /// 是否已过期 + /// 倒计时那行**已经渲染好的文案**(`15:00 left` / `剩 15:00`;过期时是「已过期」) + /// + /// 为什么文案在模型层拼(M178):`frame::text_role_owned` 那条 owned 文字路径 + /// **画不出中文**(ASCII 正常),倒计时在中文界面会整行消失。模型持有字符串、 + /// 视图用 `&'a str` 借用,两边都不依赖那条路径。 + countdown: String, + /// 是否已过期(决定颜色与「重试」按钮) expired: bool, + /// 浏览器登录没走成、已回退到设备码的说明(M178;`None` = 没这回事) + note: Option, }, /// 授权码流:等浏览器回调 Browser { /// 授权链接(可复制) link: String, - /// 剩余秒数 - remaining: Option, + /// 倒计时那行已渲染好的文案(同上) + countdown: String, /// 是否已过期 expired: bool, }, @@ -43,6 +52,8 @@ pub enum DevicePanel { Failed { /// 原因码(页面据此选 i18n) failure: LoginFailure, + /// 回退说明(浏览器登录没走成之后设备码也失败了;M178) + note: Option, }, /// 成功 Succeeded { @@ -76,8 +87,13 @@ pub enum AuthlibPanel { } /// 微软登录状态 → 面板态(倒计时与「已过期」都在这里定性,UI 只画)。 -pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option) -> DevicePanel { +pub(super) fn device_panel( + view: &DeviceLoginView, + remaining: Option, + catalog: Catalog, +) -> DevicePanel { let expired = remaining == Some(0); + let countdown = countdown_text(remaining, expired, catalog); match view.phase { DeviceLoginPhase::Idle => DevicePanel::Hidden, DeviceLoginPhase::Requesting => DevicePanel::Requesting, @@ -85,20 +101,22 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option) -> De if view.user_code.trim().is_empty() { DevicePanel::Browser { link: view.verification_uri.clone(), - remaining, + countdown, expired, } } else { DevicePanel::Code { code: view.user_code.clone(), link: view.qr_link.clone(), - remaining, + countdown, expired, + note: view.browser_note, } } } DeviceLoginPhase::Failed => DevicePanel::Failed { failure: view.failure.unwrap_or(LoginFailure::Other), + note: view.browser_note, }, DeviceLoginPhase::Succeeded => DevicePanel::Succeeded { name: view.account_name.clone(), @@ -106,6 +124,19 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option) -> De } } +/// 倒计时那一行的文案(`mm:ss` + i18n;过期/不知道有效期的两种情况都在这里收口)。 +/// +/// `mm:ss` 而不是 `900s`:人读的是分秒;数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog。 +fn countdown_text(remaining: Option, expired: bool, catalog: Catalog) -> String { + if expired { + return catalog.text(Key::AccountDeviceExpired).to_string(); + } + remaining.map_or_else(String::new, |secs| { + let clock = format!("{:02}:{:02}", secs / 60, secs % 60); + catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)]) + }) +} + /// 第三方轨状态 → 面板态(顺带取出「校验通过的服务器名」)。 pub(super) fn authlib_panel(view: &AuthlibLoginView) -> (AuthlibPanel, String) { match view.phase { diff --git a/crates/app-iced/src/pages/account/tests.rs b/crates/app-iced/src/pages/account/tests.rs index 92b0651..70d6b91 100644 --- a/crates/app-iced/src/pages/account/tests.rs +++ b/crates/app-iced/src/pages/account/tests.rs @@ -33,7 +33,7 @@ fn account(name: &str, kind: AccountKind, active: bool) -> AccountView { } } -fn build(state: &AppState, form: &Form) -> Page { +pub(super) fn build(state: &AppState, form: &Form) -> Page { // 断言口径是英文界面(app 钉死 `Lang::En`):英文目录下「视图里有中文」=「后端原文上屏了」。 // 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。 Page::build(state, form, &mut Cache::default(), Lang::En, 1180.0) @@ -212,12 +212,15 @@ fn the_device_code_panel_shows_a_countdown_and_expires() { match &page.device { DevicePanel::Code { code, - remaining, + countdown, expired, .. } => { assert_eq!(code, "ABCD-EFGH"); - assert_eq!(*remaining, Some(900)); + assert!( + countdown.contains("15:00"), + "倒计时要画成 mm:ss:{countdown}" + ); assert!(!expired); } other => panic!("等待态应该是设备码面板,实际 {other:?}"), @@ -232,10 +235,10 @@ fn the_device_code_panel_shows_a_countdown_and_expires() { let page = build(&state, &Form::new()); match &page.device { DevicePanel::Code { - remaining, expired, .. + countdown, expired, .. } => { - assert_eq!(*remaining, Some(0)); assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)"); + assert!(!countdown.is_empty(), "过期也要有一句人话,不是空行"); } other => panic!("过期态仍是设备码面板,实际 {other:?}"), } diff --git a/crates/app-iced/src/pages/account/tests_msa.rs b/crates/app-iced/src/pages/account/tests_msa.rs new file mode 100644 index 0000000..c5f4987 --- /dev/null +++ b/crates/app-iced/src/pages/account/tests_msa.rs @@ -0,0 +1,87 @@ +//! M178 的登录回退判据(从 `tests.rs` 拆出来守 400 行硬线)。 +//! +//! 这两条钉的是**页面这一半**:浏览器登录没走成、已回退设备码时,用户看到的是 +//! i18n 那句「为什么 + 怎么修」,而不是 AAD 的原始报文。core 那一半(真的回退、不白开监听口) +//! 在 `crates/core/tests/auth/microsoft_msalogin.rs`。 + +use unlucky_presenter::dto::{ + AppEvent, BrowserLoginNote, CoreEvent, DeviceLoginView, LoginFailure, +}; +use unlucky_presenter::state::AppState; + +use super::model::{DevicePanel, Form}; +use super::tests::build; +use super::tests_text::{has_cjk, on_screen}; + +/// **M178 守卫(页面这一半)**:浏览器登录没走成、已回退设备码时,页面上要有一句**可读的** +/// 中文/英文说明(含「怎么修」),且**不是** AAD 的原始报文。 +/// +/// 抓得住:`browser_note` 在 `device_panel` 里被丢掉(面板就只剩用户码,用户不知道为什么 +/// 变成了设备码)、或有人把 presenter 的原始诊断串(`invalid_request ...`)画上屏。 +/// 抓不住:这句提示在 1180 宽下会不会换行难看(那靠出图给 tower 看)。 +#[test] +fn a_browser_fallback_shows_a_readable_note_not_the_raw_aad_error() { + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged( + DeviceLoginView { + message: "invalid_request: The provided value for the input parameter 'redirect_uri' \ +is not valid. The expected value is a URI which matches a redirect URI registered for this client \ +application." + .to_string(), + ..DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "") + } + .with_browser_note(BrowserLoginNote::RedirectNotRegistered), + ))); + let page = build(&state, &Form::new()); + + // 1. 面板态带着说明码(不是只剩一个用户码) + match &page.device { + DevicePanel::Code { note, code, .. } => { + assert_eq!(*note, Some(BrowserLoginNote::RedirectNotRegistered)); + assert_eq!(code, "ABCD-EFGH"); + } + other => panic!("回退后仍是设备码面板,实际 {other:?}"), + } + + // 2. 英文界面上画出来的那一段:是 i18n 文案、含可照抄的修法,且没有 AAD 原始报文/中文 + let text = on_screen(&page); + assert!(!has_cjk(&text), "英文界面不许出现中文:{text}"); + assert!( + text.contains("http://localhost/auth"), + "提示里要给出可照抄的重定向 URI:{text}" + ); + assert!( + text.to_lowercase().contains("redirect uri"), + "提示里要点名「重定向 URI」这一项:{text}" + ); + assert!( + !text.contains("invalid_request"), + "原始报文不许上屏:{text}" + ); + assert!(!text.contains("AADSTS"), "原始报文不许上屏:{text}"); + assert!(!text.contains("is not valid"), "原始报文不许上屏:{text}"); +} + +/// 回退之后设备码又失败:失败面板上**两者都要在**(失败原因 + 为什么没走浏览器)。 +#[test] +fn a_failed_device_login_keeps_the_fallback_note() { + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged( + DeviceLoginView::failed_as( + LoginFailure::Unreachable, + "连不上 login.microsoftonline.com", + ) + .with_browser_note(BrowserLoginNote::PreflightUnavailable), + ))); + let page = build(&state, &Form::new()); + match &page.device { + DevicePanel::Failed { failure, note } => { + assert_eq!(*failure, LoginFailure::Unreachable); + assert_eq!(*note, Some(BrowserLoginNote::PreflightUnavailable)); + } + other => panic!("应是失败面板,实际 {other:?}"), + } + let text = on_screen(&page); + assert!(!has_cjk(&text), "{text}"); + assert!(text.contains("unreachable"), "失败码要画出来:{text}"); +} diff --git a/crates/app-iced/src/pages/account/tests_text.rs b/crates/app-iced/src/pages/account/tests_text.rs index d5fbd57..5e73a7a 100644 --- a/crates/app-iced/src/pages/account/tests_text.rs +++ b/crates/app-iced/src/pages/account/tests_text.rs @@ -60,23 +60,32 @@ pub(super) fn on_screen(page: &Page) -> String { DevicePanel::Code { code, link, - remaining, - expired, + countdown, + note, + .. } => { out.push_str(code); out.push_str(link); - if *expired { - out.push_str(catalog.text(Key::AccountDeviceExpired)); - } else if let Some(secs) = remaining { - let clock = format!("{:02}:{:02}", secs / 60, secs % 60); - out.push_str(&catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])); + // 倒计时文案由模型拼好(M178:owned 文字路径画不出中文)。 + out.push_str(countdown); + // M178:浏览器登录没走成时那一句「为什么 + 怎么修」也在这条取样线上。 + if let Some(note) = note { + out.push_str(catalog.text(super::model::note_key(*note))); } } - DevicePanel::Browser { link, .. } => out.push_str(link), - DevicePanel::Failed { failure } => { + DevicePanel::Browser { + link, countdown, .. + } => { + out.push_str(link); + out.push_str(countdown); + } + DevicePanel::Failed { failure, note } => { out.push_str(catalog.text(failure_title_key(*failure))); out.push_str(catalog.text(failure_body_key(*failure))); out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())])); + if let Some(note) = note { + out.push_str(catalog.text(super::model::note_key(*note))); + } } DevicePanel::Succeeded { name } => { out.push_str(&catalog.render(Key::AccountLoginSucceeded, &[("name", name)])); diff --git a/crates/core/src/auth/microsoft/authcode.rs b/crates/core/src/auth/microsoft/authcode.rs index 6757829..16ce8f5 100644 --- a/crates/core/src/auth/microsoft/authcode.rs +++ b/crates/core/src/auth/microsoft/authcode.rs @@ -29,7 +29,9 @@ use super::chain::complete_chain; use super::endpoints::MicrosoftEndpoints; use super::flow::{oauth_error, tokens_from, OAuthTokens, SCOPE}; use super::now_ms; +use super::preflight::{self, RedirectRegistration}; use super::session::MicrosoftSession; +use super::{BrowserFallback, BrowserFallbackReason}; /// 授权码 grant 类型。 pub const AUTHORIZATION_CODE_GRANT: &str = "authorization_code"; @@ -194,6 +196,49 @@ where complete_chain(client, endpoints, tokens, now_ms()).await } +/// 一次「浏览器优先」尝试的结果(M178)。 +pub(crate) enum AuthCodeAttempt { + /// 授权码流走通(凭据已换到,整链完成)。 + LoggedIn(MicrosoftSession), + /// **没去试**:预检说这个回环 URI 没登记(或问不出来)。监听端口一次都没开。 + Skipped(BrowserFallback), +} + +/// 带预检的授权码登录:注册里有这个回环 URI 才真去绑端口、弹浏览器。 +/// +/// 顺序是刻意的——**预检在绑定之前**:没登记的机器上连监听口都不开(守卫断言 +/// [`super::callback::bind_attempts`] 不涨),也就不会出现「浏览器里一张 AAD 错误页 + +/// 启动器干等 5 分钟」的那套现象(M178 用户实测的那个 bug)。 +pub(crate) async fn login_checked( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, + policy: AuthCodePolicy, + present: F, +) -> AuthResult +where + F: FnOnce(&AuthCodePrompt), +{ + let probe = RedirectUri::new(preflight::PROBE_PORT); + match preflight::check(client, endpoints, client_id, &probe).await { + RedirectRegistration::Registered => {} + RedirectRegistration::NotRegistered { detail } => { + return Ok(AuthCodeAttempt::Skipped(BrowserFallback::new( + BrowserFallbackReason::RedirectNotRegistered, + detail, + ))) + } + RedirectRegistration::Unknown { detail } => { + return Ok(AuthCodeAttempt::Skipped(BrowserFallback::new( + BrowserFallbackReason::PreflightUnavailable, + detail, + ))) + } + } + let session = login(client, endpoints, client_id, policy, present).await?; + Ok(AuthCodeAttempt::LoggedIn(session)) +} + /// 每次登录一个 `state`(不用于加密,只用来把迟到的旧回调挡掉)。 /// /// 128 位(两个独立随机种子的 SipHash 输出拼接):RFC 6749 §10.10 建议 `state` 至少 diff --git a/crates/core/src/auth/microsoft/client_id.rs b/crates/core/src/auth/microsoft/client_id.rs index 9a5a648..a8ebda7 100644 --- a/crates/core/src/auth/microsoft/client_id.rs +++ b/crates/core/src/auth/microsoft/client_id.rs @@ -252,4 +252,102 @@ mod tests { 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 = 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" + )); + } } diff --git a/crates/core/src/auth/microsoft/login.rs b/crates/core/src/auth/microsoft/login.rs new file mode 100644 index 0000000..d474e2e --- /dev/null +++ b/crates/core/src/auth/microsoft/login.rs @@ -0,0 +1,191 @@ +//! 「浏览器优先、失败自动回退设备码」(M178)——GUI 那条可选加速路的编排。 +//! +//! ## 为什么这条流必须自己会退 +//! +//! 授权码(浏览器)流把用户送回 `http://localhost:{port}/auth`。随机端口只有在「回环 URI +//! 的端口不参与匹配」成立时才可能被接受——微软官方文档 +//! ([*Redirect URI (reply URL) best practices and limitations*](https://learn.microsoft.com/en-us/entra/identity-platform/reply-url), +//! *Localhost exceptions*)原文: +//! +//! > "Due to ephemeral port ranges often required by native applications, the port component +//! > (for example, :5001 or :443) is ignored for the purposes of matching a localhost redirect URI." +//! +//! 也就是说**注册一次 `http://localhost/auth` 就能接住任意端口**;但本项目这个注册当初是为 +//! device-code 建的,重定向 URI 一项都没登记(`DevDocs/ops/azure-msa-app.md`)。没登记时 +//! 授权端点当场回 `invalid_request ... 'redirect_uri' is not valid`,**而且不会**再跳回本地 +//! 回调——用户只看到一张 AAD 原始错误页,启动器干等到超时。这正是 2026-11-08 官方产物上 +//! 「微软登录整体不可用」的根因。 +//! +//! 于是这里的分工是:设备码当默认(不依赖任何注册项),浏览器流只在**用户显式要求**时试, +//! 而且试之前先预检([`super::preflight`]):没登记就一个监听口都不开。 +//! +//! 回退说明有两层:结构化码 [`BrowserFallbackReason`](宿主据它出 i18n,**能说清怎么修**) +//! 与原始诊断 [`BrowserFallback::detail`](只进日志,不上屏)。 + +use crate::auth::error::{AuthError, AuthResult}; + +use super::authcode::{self, AuthCodeAttempt, AuthCodePrompt}; +use super::chain; +use super::flow::{self, DeviceCodePrompt}; +use super::now_ms; +use super::preflight; +use super::session::MicrosoftSession; +use super::MicrosoftAuth; + +/// 「浏览器优先但没走成」的原因(**上屏只看码**,`detail` 只进日志)。 +/// +/// 三个码对应三种不同的下一步: +/// - [`BrowserFallbackReason::RedirectNotRegistered`]:应用注册里没登记回环重定向 URI。 +/// 修法是**具体可执行的**(把 `http://localhost/auth` 加进「重定向 URI」,端口不参与匹配)。 +/// - [`BrowserFallbackReason::PreflightUnavailable`]:没法确认(网络/超时),只能先用设备码。 +/// - [`BrowserFallbackReason::AttemptFailed`]:试过了但失败(超时、换 token 被拒…)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserFallbackReason { + /// 授权端点当场拒绝了这个回环 `redirect_uri`(`invalid_request` / `AADSTS50011`) + RedirectNotRegistered, + /// 预检本身没问出结论(网络/超时/非预期应答) + PreflightUnavailable, + /// 预检通过、但授权码流仍然没走通 + AttemptFailed, +} + +impl BrowserFallbackReason { + /// 稳定的 ascii 码(日志 / 页面「Code: …」/ 测试断言都用它)。 + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::RedirectNotRegistered => "redirect_not_registered", + Self::PreflightUnavailable => "preflight_unavailable", + Self::AttemptFailed => "attempt_failed", + } + } +} + +/// 回退到设备码时的说明:原因码 + 原始诊断(诊断只进日志,不上屏)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserFallback { + /// 原因码 + pub reason: BrowserFallbackReason, + /// 原始诊断(**只进日志**;截断到 240 字符) + pub detail: String, +} + +impl BrowserFallback { + /// 构造(诊断串截断:只进日志,别把整张错误页灌进去)。 + #[must_use] + pub fn new(reason: BrowserFallbackReason, detail: impl Into) -> Self { + let detail = detail.into(); + let detail = match detail.char_indices().nth(240) { + Some((index, _)) => format!("{}…", &detail[..index]), + None => detail, + }; + Self { reason, detail } + } +} + +/// 一次「浏览器优先」登录的完整结果(M178)。 +/// +/// `listeners_opened` 是**这次登录**为本地回调开的监听口个数(0 或 1)。它存在的唯一理由是 +/// 守卫能直接数:「没登记回环」「走设备码」这两条路上必须是 0——用进程级全局计数器做不到 +/// 这一点(并行跑的测试会互相搅动),所以计数跟着结果走。 +#[derive(Debug, Clone)] +pub struct LoginOutcome { + /// 拿到的会话(两条流任意一条走通) + pub session: MicrosoftSession, + /// 回退说明(`None` = 没走浏览器 / 浏览器走通了) + pub browser: Option, + /// 这次登录一共开了几个本地监听口(设备码路恒 0) + pub listeners_opened: usize, +} + +impl LoginOutcome { + /// 浏览器(授权码)流是不是被跳过了。 + #[must_use] + pub fn browser_was_skipped(&self) -> bool { + self.browser.is_some() + } +} + +impl MicrosoftAuth { + /// 浏览器优先、失败自动回退设备码(M178:GUI 的「用浏览器登录」入口)。 + /// + /// 顺序与判据: + /// 1. 先预检回环注册([`authcode::login_checked`])。没登记 / 问不出来 → **不绑端口**、 + /// 不弹浏览器,带一条结构化说明直接进设备码; + /// 2. 预检通过 → 真走授权码流(绑端口 + 弹浏览器); + /// 3. 授权码流失败(超时 / 换 token 被拒 / 端口绑不上)→ 同样回退设备码, + /// 说明码是 [`BrowserFallbackReason::AttemptFailed`](拒绝原因若属于「重定向 URI 没登记」 + /// 则归一成 [`BrowserFallbackReason::RedirectNotRegistered`],好让用户看到怎么修); + /// 4. **用户自己取消不算失败**([`AuthError::Canceled`] 原样上抛):那是明确意图, + /// 不能偷偷换一条流继续问用户要码。 + /// + /// `present_device` 额外收到回退说明(`None` = 根本没走浏览器),宿主据此在设备码面板上 + /// 画一句「为什么改用设备码 + 怎么修」。 + /// + /// # Errors + /// + /// 用户取消、设备码流本身的失败(网络/超时/被拒/无拥有权/无档案),见 [`AuthError`]。 + pub async fn browser_then_device_login( + &self, + present_browser: FB, + mut present_device: FD, + ) -> AuthResult + where + FB: FnOnce(&AuthCodePrompt), + FD: FnMut(&DeviceCodePrompt, Option<&BrowserFallback>), + { + let mut fallback: Option = None; + // 预检通过之后才会绑端口(`login_checked` 的顺序),所以只有「真去试了」才记 1。 + let mut bound_listener = false; + if self.browser_first { + match authcode::login_checked( + &self.client, + &self.endpoints, + &self.client_id, + self.authcode.clone(), + present_browser, + ) + .await + { + Ok(AuthCodeAttempt::LoggedIn(session)) => { + return Ok(LoginOutcome { + session, + browser: None, + listeners_opened: 1, + }) + } + // 跳过:**没绑端口**(预检在绑定之前),所以计数仍是 0。 + Ok(AuthCodeAttempt::Skipped(note)) => fallback = Some(note), + Err(AuthError::Canceled) => return Err(AuthError::Canceled), + Err(error) => { + bound_listener = true; + self.record_failure("authcode_login", &error); + let reason = if preflight::error_means_unregistered(&error) { + BrowserFallbackReason::RedirectNotRegistered + } else { + BrowserFallbackReason::AttemptFailed + }; + fallback = Some(BrowserFallback::new(reason, error.to_string())); + } + } + } + let grant = + flow::request_device_code(&self.client, &self.endpoints, &self.client_id).await?; + present_device(&grant.prompt, fallback.as_ref()); + let tokens = flow::poll_device_token( + &self.client, + &self.endpoints, + &self.client_id, + &grant, + self.poll, + ) + .await?; + let session = + chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await?; + Ok(LoginOutcome { + session, + browser: fallback, + listeners_opened: usize::from(bound_listener), + }) + } +} diff --git a/crates/core/src/auth/microsoft/mod.rs b/crates/core/src/auth/microsoft/mod.rs index ec8c3af..a3e8753 100644 --- a/crates/core/src/auth/microsoft/mod.rs +++ b/crates/core/src/auth/microsoft/mod.rs @@ -1,29 +1,39 @@ //! 微软轨:device-code 与授权码(浏览器)两条登录流 + 会话/刷新语义 //! (`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5、`research/08` §3)。 //! -//! 分六块,各管一件事: //! - [`client_id`]:Azure 公共客户端 id 的**配置状态**(来源 / 配没配 / 启动日志那一行,零网络); //! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测); -//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock); -//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层); -//! - [`authcode`] + [`callback`]:授权码流(授权 URL + 本地 HTTP 回调服务器 + code 换 token); +//! - [`endpoints`] + [`flow`]:端点表与 device-code 发起 / 轮询 / refresh(OAuth 层); +//! - [`authcode`] + [`callback`] + [`preflight`] + [`login`]:授权码流(授权 URL + 本地回调 + +//! 回环注册预检 + 回退设备码的编排); //! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。 //! //! 两条流只差「怎么拿到 MS access_token」:拿到之后都进同一个 [`chain::complete_chain`], //! 落盘/刷新也共用 [`MicrosoftSession`],所以 GUI 账户页的两颗按钮不会长出两套凭据语义。 +//! # 默认走哪条(M178) +//! +//! **设备码是默认**,授权码是可选加速路:授权码流要求应用注册里登记回环重定向 URI +//! (`http://localhost/auth`,端口由微软忽略),而本项目的注册当初只为 device-code 而建。 +//! 没登记时授权端点当场拒掉且**不**把浏览器送回本地回调,用户只看到 +//! `invalid_request ... 'redirect_uri' is not valid` 那张 AAD 原始错误页。所以: +//! +//! | 入口 | 走什么 | +//! |---|---| +//! | [`MicrosoftAuth::device_code_login`] | 设备码,**一个监听口都不开** | +//! | [`MicrosoftAuth::browser_then_device_login`] | 先预检回环注册:没登记就直接设备码;登记了走授权码,失败回退设备码 | +//! | [`MicrosoftAuth::authcode_login`] | 强制授权码(测试/排障用,不做预检) | //! //! # client id 从哪来 //! -//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(且租户是 `consumers`、 -//! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的 -//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由 -//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时 +//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(租户必须是 `consumers`)。 +//! 授权码流额外要求注册里登记过回环重定向 URI(`http://localhost/auth`,端口不参与匹配; +//! 见 [`login`] 引的微软官方原文)。id 与 HMCL 同款模型:二进制里不硬编码, +//! 改由 `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时 //! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`],文案是 //! [`client_id::MISSING_CLIENT_ID_MESSAGE`](「未配置」+ 怎么配,见 [`client_id`])—— //! 这比编一个假 id 让用户看到 `invalid_client` 诚实。 //! -//! 措辞纪律(M117):id 缺失是**没配置**,不是「本期未支持」。判定与那行启动日志都在 -//! [`client_id`],本模块只消费结论。 +//! 措辞纪律(M117):id 缺失是**没配置**不是「本期未支持」,判定与启动日志都在 [`client_id`]。 pub mod authcode; pub mod callback; @@ -31,11 +41,15 @@ pub mod chain; pub mod client_id; pub mod endpoints; pub mod flow; +pub mod login; +pub mod preflight; pub mod session; pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri}; pub use callback::CallbackServer; +// 回环重定向 URI 的注册预检(M178):`RedirectRegistration` 是宿主排障时唯一要看的结论。 +pub use preflight::{RedirectRegistration, PROBE_PORT}; // client id 的判定与启动日志行留一层 re-export:调用方(CLI / GUI / 启动日志)只认 // `auth::microsoft::*` 这一条路径,不必知道它是单独一块(M117)。 pub use client_id::{ @@ -44,6 +58,7 @@ pub use client_id::{ }; pub use endpoints::MicrosoftEndpoints; pub use flow::{DeviceCodePrompt, PollPolicy}; +pub use login::{BrowserFallback, BrowserFallbackReason, LoginOutcome}; pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -63,6 +78,7 @@ pub struct MicrosoftAuth { endpoints: MicrosoftEndpoints, poll: PollPolicy, authcode: AuthCodePolicy, + browser_first: bool, journal: Option, } @@ -81,6 +97,7 @@ impl MicrosoftAuth { endpoints: MicrosoftEndpoints::consumers(), poll: PollPolicy::default(), authcode: AuthCodePolicy::default(), + browser_first: false, journal: None, }) } @@ -155,6 +172,22 @@ impl MicrosoftAuth { self } + /// 「浏览器优先」(M178):授权码流只是**可选加速路**,默认不走。 + /// + /// 只有宿主确认用户显式要求时才置位(GUI 的「用浏览器登录」按钮)。置位后仍然先做 + /// 回环注册预检([`preflight`]):没登记就**不绑端口、不弹浏览器**,直接设备码。 + #[must_use] + pub fn with_browser_first(mut self, enabled: bool) -> Self { + self.browser_first = enabled; + self + } + + /// 是否允许尝试浏览器(授权码)流。 + #[must_use] + pub fn browser_first(&self) -> bool { + self.browser_first + } + /// 当前端点表。 #[must_use] pub fn endpoints(&self) -> &MicrosoftEndpoints { @@ -354,13 +387,14 @@ mod tests { .endpoints() .profile .contains("api.minecraftservices.com")); + // 默认**不是**浏览器优先(M178:设备码才是默认路)。 + assert!(!auth.browser_first()); } #[test] fn now_ms_is_sane() { // 2020-01-01 之后、2100 之前(防止本地时钟/单位抄错)。 let now = now_ms(); - assert!(now > 1_577_836_800_000); - assert!(now < 4_102_444_800_000); + assert!(now > 1_577_836_800_000 && now < 4_102_444_800_000); } } diff --git a/crates/core/src/auth/microsoft/preflight.rs b/crates/core/src/auth/microsoft/preflight.rs new file mode 100644 index 0000000..dac1e6c --- /dev/null +++ b/crates/core/src/auth/microsoft/preflight.rs @@ -0,0 +1,245 @@ +//! 授权码流的「回环重定向 URI 注册预检」(M178)。 +//! +//! ## 为什么需要预检 +//! +//! 授权码流把浏览器送回 `http://localhost:{port}/auth`。**随机端口**只有在「回环 URI 的 +//! 端口不参与匹配」这件事成立时才可能被接受——微软官方文档写得很清楚 +//! ([*Redirect URI (reply URL) best practices and limitations*](https://learn.microsoft.com/en-us/entra/identity-platform/reply-url), +//! 小节 *Localhost exceptions*): +//! +//! > "Due to ephemeral port ranges often required by native applications, the port component +//! > (for example, :5001 or :443) is ignored for the purposes of matching a localhost redirect URI. +//! > As a result, all of these URIs are considered equivalent: `http://localhost/MyApp`, +//! > `http://localhost:1234/MyApp`, `http://localhost:5000/MyApp`, `http://localhost:8080/MyApp`" +//! +//! 换句话说:**注册一次 `http://localhost/auth` 就能接住任意端口**;但**一次都没注册**时, +//! 任何端口的回环 URI 都会被授权端点当场拒掉(`invalid_request: ... 'redirect_uri' is not valid`), +//! 而且**不会**再跳回本地回调——用户只在浏览器里看到一张 AAD 错误页,启动器只能干等超时。 +//! 本项目的 Azure 应用注册当初是为 device-code 建的,重定向 URI 一项都没登记 +//! (`DevDocs/ops/azure-msa-app.md`),所以这条流在官方产物上必然失败。 +//! +//! ## 预检做什么、不做什么 +//! +//! 做:拿**即将使用的那个** `redirect_uri`(只有端口可以是任意的,所以用固定探测端口拼形状) +//! 去授权端点发一次**不带凭据的 GET**,看它是否当场报 `invalid_request` / `AADSTS50011`。 +//! 不注册 → 不绑本地端口、不弹浏览器,直接回退设备码。这样「没登记回调地址」的机器上 +//! 监听口一次都不开(守卫断言 `callback::bind_attempts()` 不涨)。 +//! +//! 不做:预检失败**不等于**登录被拒——它只决定「要不要试浏览器」。 +//! `Unknown`(网络问题)同样按「别试」处理:设备码流不依赖任何注册项,永远更稳。 + +use reqwest::Client; + +use crate::auth::error::AuthError; + +use super::authcode::{authorization_url, RedirectUri}; +use super::endpoints::MicrosoftEndpoints; + +/// 预检用的回环端口。 +/// +/// **不是要绑它**:端口对回环匹配不参与(见模块文档引的那段官方原文),所以随便一个 +/// 「看起来像临时端口」的值就能代表真实绑定后的那个。固定值让日志与测试可复现。 +pub const PROBE_PORT: u16 = 53_691; + +/// 预检请求里的 `state`(不参与任何校验,只为拼出形状完整的授权 URL)。 +const PROBE_STATE: &str = "ull-redirect-probe"; + +/// 授权端点对 `redirect_uri` 的态度。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RedirectRegistration { + /// 注册里有这个回环 URI(端口随意)→ 可以真的走授权码流。 + Registered, + /// 明确没登记(`invalid_request` / `AADSTS50011`)→ 别试,直接设备码。 + NotRegistered { + /// 诊断原文(只进日志) + detail: String, + }, + /// 问不出来(网络/超时/非预期应答)→ 同样不试,但原因是「没确认」而不是「没配」。 + Unknown { + /// 诊断原文(只进日志) + detail: String, + }, +} + +impl RedirectRegistration { + /// 是否值得继续走授权码流。 + #[must_use] + pub fn is_registered(&self) -> bool { + matches!(self, Self::Registered) + } +} + +/// 预检:注册里有没有这个回环重定向 URI。 +/// +/// 判据(顺序即优先级): +/// 1. 应答里出现 `invalid_request` / `AADSTS50011` / `redirect_uri is not valid` 一类字样 → 没登记; +/// 2. 非 2xx/3xx → 按「没登记」处理(AAD 对不合法的 redirect_uri 直接渲染错误页,状态码 400); +/// 3. 其余 2xx/3xx 且没有拒绝字样 → 认为已登记(那就是登录页本身); +/// 4. 连不上 → `Unknown`。 +/// +/// 阅读提示:这条判据**宁可错杀**——错过一次浏览器登录只是慢一步,误放行会让用户撞上 +/// AAD 的原始错误页,而那正是本 mission 要消灭的现象。 +pub(super) async fn check( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, + redirect_uri: &RedirectUri, +) -> RedirectRegistration { + let url = authorization_url(endpoints, client_id, redirect_uri, PROBE_STATE); + let response = client.get(&url).send().await; + let response = match response { + Ok(response) => response, + Err(error) => { + return RedirectRegistration::Unknown { + detail: format!("预检请求失败:{error}"), + } + } + }; + let status = response.status().as_u16(); + // 登录页可能带上几十上百 KB 的脚本;拒绝页只有一小段。读整页是为了抓正文里的错误码, + // reqwest 的 `text()` 会把正文一次读尽(授权页不含任何凭据,不需要脱敏)。 + let body = response.text().await.unwrap_or_default(); + if rejects_redirect_uri(&body) { + return RedirectRegistration::NotRegistered { + detail: format!("HTTP {status}:{}", snippet(&body)), + }; + } + if !(200..400).contains(&status) { + return RedirectRegistration::NotRegistered { + detail: format!("HTTP {status}:{}", snippet(&body)), + }; + } + RedirectRegistration::Registered +} + +/// 这个错误是不是「重定向 URI 没登记」那一类(`AuthError` 侧重判,回调/换 token 用)。 +/// +/// 与 [`rejects_redirect_uri`] 同源:一个看页面正文,一个看结构化错误。两者都覆盖 +/// `invalid_request` 与 `AADSTS50011`,因为同一件事在两条流上的形状不一样。 +#[must_use] +pub(super) fn error_means_unregistered(error: &AuthError) -> bool { + match error { + AuthError::Denied { code, description } => { + is_redirect_code(code) || redirects_rejected(description) + } + AuthError::Response(detail) => { + redirects_rejected(detail.server_message.as_deref().unwrap_or_default()) + || redirects_rejected(detail.digest.as_str()) + } + _ => false, + } +} + +fn is_redirect_code(code: &str) -> bool { + matches!( + code, + "invalid_request" + | "invalid_redirect_uri" + | "redirect_uri_mismatch" + | "unauthorized_client" + ) +} + +fn redirects_rejected(text: &str) -> bool { + let lowered = text.to_lowercase(); + lowered.contains("aadsts50011") + || lowered.contains("invalid_request") + || lowered.contains("redirect_uri is not valid") + || lowered.contains("redirect_uri+is+not+valid") + || lowered.contains("reply url specified in the request does not match") + || (lowered.contains("redirect") && lowered.contains("not valid")) +} + +/// 页面正文里有没有「重定向 URI 不合法」的拒绝标记。 +fn rejects_redirect_uri(body: &str) -> bool { + let lowered = body.to_lowercase(); + lowered.contains("aadsts50011") + || lowered.contains("invalid_request") + || lowered.contains("redirect_uri is not valid") + || lowered.contains("redirect_uri+is+not+valid") + || (lowered.contains("redirect") && lowered.contains("not valid")) +} + +/// 正文摘要(单行、截断):只进日志,不上屏。 +fn snippet(body: &str) -> String { + let one_line: String = body + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + let collapsed = one_line.split_whitespace().collect::>().join(" "); + match collapsed.char_indices().nth(200) { + Some((index, _)) => format!("{}…", &collapsed[..index]), + None => collapsed, + } +} + +#[cfg(test)] +mod tests { + use super::{error_means_unregistered, rejects_redirect_uri, snippet, RedirectRegistration}; + use crate::auth::body::ResponseDetail; + use crate::auth::error::AuthError; + + /// 用户 2026-11-08 实测的那张错误页(原文截取)必须被判成「没登记」。 + #[test] + fn the_reported_aad_page_is_recognized_as_unregistered() { + let page = "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."; + assert!(rejects_redirect_uri(page)); + // 登录页本身(有脚本、有表单)不能被误判成拒绝。 + assert!(!rejects_redirect_uri( + "Sign in to your account..." + )); + } + + #[test] + fn aadsts50011_is_also_a_registration_problem() { + assert!(rejects_redirect_uri( + "AADSTS50011: The redirect URI 'http://localhost:61492/auth' specified in the request does not match" + )); + } + + /// 两条流上的同一件事:授权页拒绝(`Denied`)与 token 端点拒绝(`Response`)都要认。 + #[test] + fn structured_errors_of_both_shapes_are_recognized() { + assert!(error_means_unregistered(&AuthError::Denied { + code: "invalid_request".to_string(), + description: "bad redirect".to_string(), + })); + assert!(error_means_unregistered(&AuthError::Response(Box::new( + ResponseDetail { + step: "token".to_string(), + what: "换 token".to_string(), + status: 400, + content_type: None, + kind: crate::auth::BodyKind::Json, + server_message: Some("AADSTS50011: reply url mismatch".to_string()), + digest: "digest".to_string(), + } + )))); + // 不相干的失败不是注册问题(别把网络/白名单也归到这条)。 + assert!(!error_means_unregistered(&AuthError::Canceled)); + } + + #[test] + fn registration_state_is_answered_by_one_predicate() { + assert!(RedirectRegistration::Registered.is_registered()); + assert!(!RedirectRegistration::NotRegistered { + detail: String::new() + } + .is_registered()); + assert!(!RedirectRegistration::Unknown { + detail: String::new() + } + .is_registered()); + } + + #[test] + fn snippet_is_single_line_and_capped() { + let long = format!("a\nb\t{}", "x".repeat(400)); + let text = snippet(&long); + assert!(!text.contains('\n'), "{text}"); + assert!(text.ends_with('…'), "{text}"); + assert_eq!(text.chars().count(), 201); + } +} diff --git a/crates/core/tests/auth/main.rs b/crates/core/tests/auth/main.rs index 6fba1e5..de76825 100644 --- a/crates/core/tests/auth/main.rs +++ b/crates/core/tests/auth/main.rs @@ -7,6 +7,7 @@ //! - `microsoft_authcode`:授权码流(本地回调服务器 / 授权 URL 向量 / code 换链 / 403 诊断); //! - `microsoft_diagnostics`:非 2xx 正文诊断(JSON/HTML/空体分流)+ 失败日志落盘(M38); //! - `microsoft_refresh`:refresh 轮换、profile 变脸防线、validate 语义、404 二义; +//! - `microsoft_msalogin`:回环重定向 URI 没登记时的回退(M178:默认设备码、不白开监听口); //! - `authlib_api`:Yggdrasil 四动作的报文级对接。 mod support; @@ -16,6 +17,7 @@ mod authlib_api; mod microsoft_authcode; mod microsoft_diagnostics; mod microsoft_flow; +mod microsoft_msalogin; mod microsoft_refresh; mod offline_uuid; mod store_separation; diff --git a/crates/core/tests/auth/microsoft_msalogin.rs b/crates/core/tests/auth/microsoft_msalogin.rs new file mode 100644 index 0000000..b48bfd9 --- /dev/null +++ b/crates/core/tests/auth/microsoft_msalogin.rs @@ -0,0 +1,267 @@ +//! 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, + "没登记回环时不许绑本地端口(预检必须在绑定之前)" + ); + 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(); +} diff --git a/crates/i18n/src/catalog/account.rs b/crates/i18n/src/catalog/account.rs index c498e62..c902306 100644 --- a/crates/i18n/src/catalog/account.rs +++ b/crates/i18n/src/catalog/account.rs @@ -115,6 +115,23 @@ macro_rules! account_entries { // ── 重新登录的引导 AccountHintAuthlibReauth => ("Enter the password again for {server}.", "请输入 {server} 的密码。"), AccountHintOfflineNoRelogin => ("Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"), + // ── 浏览器登录回退到设备码的说明(M178) + // 口径:这不是「登录失败」——设备码接着跑。第一句说清「已改用设备码」, + // 需要改配置的那一条**必须给可照抄的下一步**(哪个门户、加什么值、端口不参与匹配), + // 且**不许**出现 AAD 的原始报文(`invalid_request` / `AADSTS…` / JSON)—— + // 用户 2026-11-08 实测撞到的就是那张原始错误页。 + AccountBrowserFallbackNotRegistered => ( + "This build's app registration has no loopback redirect URI, so browser sign-in cannot work; switched to a device code. To fix: add http://localhost/auth under Redirect URIs in the Azure app registration (the port is ignored), then restart the launcher.", + "这个构建的应用注册里没有登记回环重定向 URI,浏览器登录用不了,已自动改用设备码。怎么修:在 Azure 应用注册的「重定向 URI」里加上 http://localhost/auth(端口不参与匹配),然后重启启动器。" + ), + AccountBrowserFallbackUnverified => ( + "Could not confirm that browser sign-in is usable, so it switched to a device code.", + "没能确认浏览器登录是否可用(预检没成功),已自动改用设备码。" + ), + AccountBrowserFallbackFailed => ( + "Browser sign-in did not finish, so it switched to a device code. If the browser page said the redirect URI is not valid, add http://localhost/auth under Redirect URIs of the app registration.", + "浏览器登录没有完成,已自动改用设备码。如果浏览器里提示 redirect_uri 无效,请把 http://localhost/auth 加进应用注册的「重定向 URI」。" + ), } } }; } @@ -125,6 +142,48 @@ pub(crate) use account_entries; mod tests { use crate::{Catalog, Key, Lang}; + /// **M178**:浏览器登录回退到设备码时那一句,必须「说清为什么 + 怎么修」,且**不许**把 + /// AAD 的原始报文搬上屏(用户实测撞到的正是那张 `invalid_request ... redirect_uri` 错误页)。 + /// + /// 抓得住:文案退回「登录失败,完整原因在日志里」、删掉可照抄的重定向 URI、或有人把 + /// AAD 原始错误码/JSON 塞进词条。 + /// 抓不住:读起来顺不顺(那靠 review 与截图)。 + #[test] + fn the_browser_fallback_copy_explains_itself_without_raw_aad_text() { + for &lang in Lang::ALL { + let catalog = Catalog::of(lang); + let not_registered = catalog.text(Key::AccountBrowserFallbackNotRegistered); + let unverified = catalog.text(Key::AccountBrowserFallbackUnverified); + let failed = catalog.text(Key::AccountBrowserFallbackFailed); + // 「已改用设备码」这条结论三句里都得有(用户要知道画面为什么变了)。 + for copy in [not_registered, unverified, failed] { + assert!( + copy.contains("device code") || copy.contains("设备码"), + "{}: {copy}", + lang.tag() + ); + // 原文不上屏:这两条是 AAD 的机器码,出现即语言/脱敏纪律失效。 + assert!(!copy.contains("invalid_request"), "{}: {copy}", lang.tag()); + assert!(!copy.contains("AADSTS"), "{}: {copy}", lang.tag()); + assert!(!copy.contains('{'), "{}: {copy}", lang.tag()); + } + // 「没登记回环」这一条必须给**可照抄**的下一步(门户 + 值 + 端口不参与匹配)。 + assert!( + not_registered.contains("http://localhost/auth"), + "{not_registered}" + ); + assert!( + not_registered.to_lowercase().contains("redirect uri") + || not_registered.contains("重定向 URI"), + "{not_registered}" + ); + assert!( + not_registered.to_lowercase().contains("port") || not_registered.contains("端口"), + "{not_registered}" + ); + } + } + /// **M132**:缺 client id 这条是本页唯一「说清原因」的失败文案,它的纪律比别的正文更硬。 /// /// 抓得住:文案退回「登录失败 / 完整原因在日志里」、退化成「未支持」、或者只说缺 id 却 diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index 8f2d27f..577af2f 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -180,6 +180,9 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("AccountNoticeTitleDone", "Account updated", "账户已更新"), ("AccountHintAuthlibReauth", "Enter the password again for {server}.", "请输入 {server} 的密码。"), ("AccountHintOfflineNoRelogin", "Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"), + ("AccountBrowserFallbackNotRegistered", "This build's app registration has no loopback redirect URI, so browser sign-in cannot work; switched to a device code. To fix: add http://localhost/auth under Redirect URIs in the Azure app registration (the port is ignored), then restart the launcher.", "这个构建的应用注册里没有登记回环重定向 URI,浏览器登录用不了,已自动改用设备码。怎么修:在 Azure 应用注册的「重定向 URI」里加上 http://localhost/auth(端口不参与匹配),然后重启启动器。"), + ("AccountBrowserFallbackUnverified", "Could not confirm that browser sign-in is usable, so it switched to a device code.", "没能确认浏览器登录是否可用(预检没成功),已自动改用设备码。"), + ("AccountBrowserFallbackFailed", "Browser sign-in did not finish, so it switched to a device code. If the browser page said the redirect URI is not valid, add http://localhost/auth under Redirect URIs of the app registration.", "浏览器登录没有完成,已自动改用设备码。如果浏览器里提示 redirect_uri 无效,请把 http://localhost/auth 加进应用注册的「重定向 URI」。"), ("SettingsGroupUpdate", "Updates", "更新"), ("SettingsUpdateCurrent", "Current version", "当前版本"), ("SettingsUpdateChannel", "Channel", "更新渠道"), diff --git a/crates/presenter/src/dto/accounts.rs b/crates/presenter/src/dto/accounts.rs index eda6885..3e12bf7 100644 --- a/crates/presenter/src/dto/accounts.rs +++ b/crates/presenter/src/dto/accounts.rs @@ -18,7 +18,7 @@ mod redact; #[path = "accounts/tests.rs"] mod tests; -pub use failure::{AccountNotice, AccountNoticeCode, LoginFailure}; +pub use failure::{AccountNotice, AccountNoticeCode, BrowserLoginNote, LoginFailure}; pub use kind::AccountKind; pub use name::valid_offline_name; pub use redact::{looks_like_jwt, redact, REDACTED}; @@ -36,12 +36,11 @@ pub struct AccountView { pub kind: AccountKind, /// 档案 UUID(公开) pub profile_id: String, - /// 登录名(微软轨才有;可空) + /// 登录名(微软轨才有) pub login_name: String, - /// 创建时间展示串(`2026-10-31 20:12`) + /// 创建时间展示串(`2026-10-31 20:12`)。 /// - /// 口径:core 的 `AccountMeta` 只有 `created_at_ms`,**没有 last-used**,所以列表上贴的是 - /// 「添加于 {created}」,不伪造「最近使用」(缺口登记在 M114 报告)。 + /// core 的 `AccountMeta` 只有 `created_at_ms`(无 last-used),所以列表贴「添加于 {created}」,不伪造「最近使用」。 pub created: String, /// 是否是当前启动使用的账户 pub active: bool, @@ -55,8 +54,6 @@ pub struct AccountView { impl AccountView { /// 首字母块上的字:展示名第一个非空字符(大写化),空名字回落 `?`。 - /// - /// 放在 DTO 而不是页面里,是为了「没有皮肤时画什么」这件事只有一处判据,两个 UI 版一致。 #[must_use] pub fn monogram(&self) -> String { self.name @@ -66,7 +63,7 @@ impl AccountView { .unwrap_or_else(|| "?".to_string()) } - /// 首字母块的取色索引(0..6):由 `profile_id` 稳定推导,同一个人每次都是同一个色块。 + /// 首字母块取色索引(0..6):由 `profile_id` 稳定推导,同一个人每次同色。 #[must_use] pub fn monogram_tone(&self) -> u8 { let mut hash: u32 = 2_166_136_261; @@ -78,10 +75,8 @@ impl AccountView { } } -/// 一张皮肤原图。 -/// -/// **停在「原图」这一层是刻意的**:presenter 不引入图片解码依赖(`presenter-no-ui` + -/// 依赖极简),裁剪/缩放/取首字母块都由 UI 层自己做。离线轨永远没有它——没有皮肤就不给假皮肤。 +/// 一张皮肤原图。**停在「原图」这层是刻意的**:presenter 不引图片解码依赖,裁剪/缩放归 UI 层; +/// 离线轨永远没有它——没有皮肤就不给假皮肤。 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SkinImage { /// PNG 字节(Minecraft 皮肤:64×64 或旧版 64×32) @@ -127,8 +122,7 @@ impl DeviceLoginPhase { /// 设备码登录的可见状态。 /// -/// `user_code` / `verification_uri` 来自 `core::auth::microsoft::DeviceCodePrompt`—— -/// 它们本来就是给用户看的,不是凭据。 +/// `user_code` / `verification_uri` 来自 `core::auth::microsoft::DeviceCodePrompt`(给用户看的,不是凭据)。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DeviceLoginView { /// 阶段 @@ -137,7 +131,7 @@ pub struct DeviceLoginView { pub user_code: String, /// 验证地址(`https://www.microsoft.com/link`) pub verification_uri: String, - /// 二维码内容(= 带码的链接;GUI 用文本展示,终端可直接打开) + /// 二维码内容(= 带码的链接) pub qr_link: String, /// 有效期(秒;0 = 未知) pub expires_in: u64, @@ -146,6 +140,9 @@ pub struct DeviceLoginView { /// 失败原因码(`None` = 不是失败态 / 未分类) #[serde(default, skip_serializing_if = "Option::is_none")] pub failure: Option, + /// 浏览器登录没走成、已回退设备码的说明码(M178;`None` = 没这回事) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub browser_note: Option, /// 成功后入库的账户展示名 pub account_name: String, } @@ -167,6 +164,7 @@ impl DeviceLoginView { expires_in: 0, message: String::new(), failure: None, + browser_note: None, account_name: String::new(), } } @@ -194,14 +192,14 @@ impl DeviceLoginView { expires_in ), failure: None, + browser_note: None, account_name: String::new(), } } /// 授权码流:本地回调已就绪、浏览器已拉起(`present` 回调时刻)。 /// - /// 面板不展示用户码,改展示**可复制的授权链接**(用户可能需要在另一台设备/ - /// 另一个浏览器里手动打开);`warning` 是「浏览器没拉起来」这类非致命提示。 + /// 面板不展示用户码,改展示**可复制的授权链接**;`warning` 是「浏览器没拉起来」这类非致命提示。 pub fn browser_waiting( authorization_url: &str, expires_in: u64, @@ -222,10 +220,18 @@ impl DeviceLoginView { expires_in, message, failure: None, + browser_note: None, account_name: String::new(), } } + /// 附一条回退说明码(M178)。与 `message`(原文只进日志)分开:页面只拿码去查 i18n。 + #[must_use] + pub fn with_browser_note(mut self, note: BrowserLoginNote) -> Self { + self.browser_note = Some(note); + self + } + /// 失败态(文案在入口就脱敏:错误原文里可能带着服务端回显的 token)。 pub fn failed(message: impl Into) -> Self { Self::failed_as(LoginFailure::Other, message) @@ -256,8 +262,7 @@ impl DeviceLoginView { /// 第三方轨(authlib-injector)登录请求。 /// -/// **凭据红线**:`password` 只是「用户这一次输入的请求值」,从页面流到 host 的 -/// `AuthlibAuth::authenticate` 调用栈里,host 取走即弃——不入账户库、不进日志、 +/// **凭据红线**:`password` 只是「这一次输入的请求值」,host 取走即弃——不入账户库、不进日志、 /// 不进任何回程 DTO。所以本类型只有一个方向(UI → host),回程只有 [`AuthlibLoginView`]。 #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthlibRequest { @@ -305,14 +310,14 @@ impl AuthlibRequest { } } -/// 第三方轨登录的可见状态(阶段复用 [`DeviceLoginPhase`]:都是「申请 → 等待 → 成/败」)。 +/// 第三方轨登录的可见状态(阶段复用 [`DeviceLoginPhase`]:「申请 → 等待 → 成/败」)。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthlibLoginView { /// 阶段 pub phase: DeviceLoginPhase, /// 认证服务器地址(规范化后的 API 根) pub server: String, - /// 服务发现里的服务器名(校验通过后才有;空 = 没校验/没名字) + /// 服务发现里的服务器名(校验通过后才有;空 = 没校验) pub server_name: String, /// 状态/错误文案(**失败时是原始诊断,只进日志,不上屏**) pub message: String, diff --git a/crates/presenter/src/dto/accounts/failure.rs b/crates/presenter/src/dto/accounts/failure.rs index ac07437..2ecd2b1 100644 --- a/crates/presenter/src/dto/accounts/failure.rs +++ b/crates/presenter/src/dto/accounts/failure.rs @@ -75,8 +75,34 @@ impl LoginFailure { } } -/// 账户页的**一次性横幅**(添加/删除/切换的结果;页面据 `code` 选 i18n)。 +/// 「浏览器登录没走成,已自动改用设备码」的说明码(M178)。 /// +/// 为什么与 [`LoginFailure`] 分开:这不是登录失败——设备码流接上了,用户照常在等码。 +/// 它解释的是「为什么面板变成了设备码」以及**要不要去改应用注册**,所以单独一个码族, +/// 页面画成一行提示而不是错误横幅。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BrowserLoginNote { + /// 应用注册里没有回环重定向 URI(授权端点当场拒绝)——**唯一带「怎么修」的一句** + RedirectNotRegistered, + /// 预检没问出结论(网络/超时),只能先用设备码 + PreflightUnavailable, + /// 预检通过、授权码流仍然失败(超时 / 换 token 被拒 / 端口绑不上) + AttemptFailed, +} + +impl BrowserLoginNote { + /// 稳定 ascii code(日志/测试用,**不随语言变**)。 + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::RedirectNotRegistered => "redirect_not_registered", + Self::PreflightUnavailable => "preflight_unavailable", + Self::AttemptFailed => "attempt_failed", + } + } +} + +/// 账户页的**一次性横幅**(添加/删除/切换的结果;页面据 `code` 选 i18n)。 /// 为什么不是 toast:app-iced 不渲染 toast(复审 r1 P2-2 实锤),发 toast 等于用户什么都看不到。 /// 横幅是页面上的真实控件,用户点了「Create profile」失败后一定看得见。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index b8710b2..a5d6ea6 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -35,8 +35,8 @@ mod versions; pub use accounts::{ looks_like_jwt, redact, valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode, - AccountView, AuthlibLoginView, AuthlibRequest, DeviceLoginPhase, DeviceLoginView, LoginFailure, - SkinImage, REDACTED, + AccountView, AuthlibLoginView, AuthlibRequest, BrowserLoginNote, DeviceLoginPhase, + DeviceLoginView, LoginFailure, SkinImage, REDACTED, }; pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView}; pub use content::{ diff --git a/crates/presenter/src/host/accounts.rs b/crates/presenter/src/host/accounts.rs index 3edcca3..3398d8f 100644 --- a/crates/presenter/src/host/accounts.rs +++ b/crates/presenter/src/host/accounts.rs @@ -3,9 +3,10 @@ //! 凭据红线(D9):`Credentials` / `MicrosoftSession` 只在本模块与 `launch.rs` 里出现, //! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。 //! -//! 两条登录流:授权码([`start_browser_login`],GUI 默认:拉起系统浏览器 + 本地回调, -//! `research/08` §3 方案 A)与设备码([`start_login`],无浏览器/CLI 场景)。除了「怎么拿到 -//! MS access_token」之外完全共用:同一个取消槽位、同一段账户入库、同一个刷新链。 +//! 两条登录流(M178 改了默认):**设备码是默认**([`start_login`]:不依赖任何应用注册项), +//! 授权码([`start_browser_login`],浏览器 + 本地回调)降级成「用户显式要求才试」的加速路, +//! 且带回环注册预检 + 失败自动回退设备码。除了「怎么拿到 MS access_token」之外完全共用: +//! 同一个取消槽位、同一段账户入库、同一个刷新链。 //! //! 取消语义:轮询/等回调都跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时 //! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。 @@ -17,7 +18,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use unlucky_core::auth::account::Credentials; -use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth, MicrosoftSession}; +use unlucky_core::auth::microsoft::{ + now_ms, BrowserFallbackReason, MicrosoftAuth, MicrosoftSession, +}; use unlucky_core::auth::offline::offline_meta; use unlucky_core::auth::{AccountStore, AuthError, AuthResult}; use unlucky_core::download::blocking::block_on; @@ -25,7 +28,7 @@ use unlucky_core::download::blocking::block_on; use crate::dto::CoreEvent; use crate::dto::{ valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode, AccountView, - DeviceLoginView, LoginFailure, + BrowserLoginNote, DeviceLoginView, LoginFailure, }; use super::env::HostEnv; @@ -183,7 +186,11 @@ impl LoginJob { } } -/// 起一次设备码登录(后台线程;`present` 时刻发 Waiting 事件)。 +/// 起一次微软登录(M178:**GUI 的默认路**——设备码)。 +/// +/// 设备码流不依赖任何应用注册项(Azure 那边只需要一个公共客户端 id),所以它是唯一 +/// 「在官方产物上开箱可用」的路。它**一个本地监听口都不开**,也就不会撞上 +/// `redirect_uri is not valid` 那张 AAD 错误页(守卫断言见 `callback::bind_attempts`)。 pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { spawn("msa-login", move || { let auth = match microsoft_auth(&env) { @@ -203,30 +210,69 @@ pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { }); } -/// 起一次授权码登录(后台线程:绑本地端口 → 拉起系统默认浏览器 → 等回调 → 换链)。 +/// 起一次「浏览器优先、失败自动回退设备码」的登录(M178:可选加速路)。 +/// +/// 只在用户**显式要求**时才走到这里(账户页的「用浏览器登录」)。即使如此也不盲试: +/// `core` 先做回环重定向 URI 的注册预检,没登记就**不绑端口、不弹浏览器**,直接设备码; +/// 试了但失败(超时/被拒/端口绑不上)同样回退。两条路都不满意时用户还能在面板上看到 +/// 一句「为什么改用设备码 + 怎么修」(结构化码 → i18n,原文只进日志)。 /// /// 与设备码流共用同一个 [`LoginJob`] 取消槽位、同一段账户入库逻辑,所以两条流的 /// 凭据/刷新语义完全一致(`core` 侧也共用 [`unlucky_core::auth::MicrosoftSession`])。 pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) { - spawn("msa-authcode", move || { + spawn("msa-browser", move || { let auth = match microsoft_auth(&env) { Ok(auth) => auth, Err(error) => return fail(&tx, auth_failure(&error), error.to_string()), }; - let tx_prompt = tx.clone(); - let result = block_on(auth.authcode_login(|prompt| { - // 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。 - let opened = opener::open_url(&prompt.authorization_url); - let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting( - &prompt.authorization_url, - prompt.timeout.as_secs(), - opened.err().as_deref(), - )))); - })); - finish(&env, &tx, &job, result); + let tx_browser = tx.clone(); + let tx_device = tx.clone(); + let result = block_on(auth.browser_then_device_login( + |prompt| { + // 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。 + let opened = opener::open_url(&prompt.authorization_url); + let _ = + tx_browser.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting( + &prompt.authorization_url, + prompt.timeout.as_secs(), + opened.err().as_deref(), + )))); + }, + |prompt, fallback| { + let view = DeviceLoginView::waiting( + &prompt.user_code, + &prompt.verification_uri, + prompt.expires_in.as_secs(), + &format!("{}#{}", prompt.verification_uri, prompt.user_code), + ); + let view = match fallback { + Some(fallback) => { + // 回退原文只进日志;面板只看码。 + eprintln!( + "[auth] 浏览器登录未走成({}),已回退设备码:{}", + fallback.reason.code(), + fallback.detail + ); + view.with_browser_note(browser_note(fallback.reason)) + } + None => view, + }; + let _ = tx_device.send(HostTask::Login(Box::new(view))); + }, + )); + finish(&env, &tx, &job, result.map(|outcome| outcome.session)); }); } +/// 回退原因码 → 页面说明码(两边各留一个枚举:presenter 的 DTO 不依赖 core 的中文诊断)。 +fn browser_note(reason: BrowserFallbackReason) -> BrowserLoginNote { + match reason { + BrowserFallbackReason::RedirectNotRegistered => BrowserLoginNote::RedirectNotRegistered, + BrowserFallbackReason::PreflightUnavailable => BrowserLoginNote::PreflightUnavailable, + BrowserFallbackReason::AttemptFailed => BrowserLoginNote::AttemptFailed, + } +} + /// 生产入口的 `MicrosoftAuth`:client id 取环境/构建期,失败日志落 `<数据根>/logs/`。 fn microsoft_auth(env: &HostEnv) -> AuthResult { MicrosoftAuth::from_env().map(|auth| { diff --git a/crates/presenter/src/host/accounts/tests.rs b/crates/presenter/src/host/accounts/tests.rs index cce5d36..e7679ac 100644 --- a/crates/presenter/src/host/accounts/tests.rs +++ b/crates/presenter/src/host/accounts/tests.rs @@ -185,3 +185,34 @@ fn login_job_cancel_flag_is_shared() { job.cancel(); assert!(clone.is_cancelled()); } + +/// **M178**:core 的回退原因码 → 页面的说明码。两边的 ascii 码必须逐字相同—— +/// 日志里看到的码就是页面上 `Code:` 那一行,对不上就没法用用户回报的码定位。 +/// +/// 抓得住:core 加/改一个回退原因而 presenter 忘了接(`browser_note` 是 `match`, +/// 新增变体会编译失败;这里再把**码的字符串**钉住,防的是两侧各改各的)。 +#[test] +fn browser_fallback_reasons_map_to_the_page_notes_with_the_same_codes() { + for (reason, note) in [ + ( + BrowserFallbackReason::RedirectNotRegistered, + BrowserLoginNote::RedirectNotRegistered, + ), + ( + BrowserFallbackReason::PreflightUnavailable, + BrowserLoginNote::PreflightUnavailable, + ), + ( + BrowserFallbackReason::AttemptFailed, + BrowserLoginNote::AttemptFailed, + ), + ] { + assert_eq!(browser_note(reason), note); + assert_eq!(note.code(), reason.code()); + } + // 「没登记回环」是唯一带「怎么修」的那一条,码也必须稳定(排障脚本按它 grep 日志)。 + assert_eq!( + BrowserLoginNote::RedirectNotRegistered.code(), + "redirect_not_registered" + ); +}