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:
1 parent
329c529bea
commit
f82bd395f8
23 files changed
+1373
-131
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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::{
|
||||
|
||||
Reference in new issue
Block a user