fix(msa): 微软登录默认走设备码,浏览器流降级为可选加速路

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

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

No files matched your search

+26 -21
View File
@@ -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<LoginFailure>,
/// 浏览器登录没走成、已回退设备码的说明码(M178;`None` = 没这回事)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub browser_note: Option<BrowserLoginNote>,
/// 成功后入库的账户展示名
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<String>) -> 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,
+27 -1
View File
@@ -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)]
+2 -2
View File
@@ -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::{