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::{
+65 -19
View File
@@ -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> {
MicrosoftAuth::from_env().map(|auth| {
@@ -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"
);
}