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
@@ -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| {
|
||||
|
||||
Reference in new issue
Block a user