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::{
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user