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

@@ -60,23 +60,32 @@ pub(super) fn on_screen(page: &Page) -> String {
DevicePanel::Code {
code,
link,
remaining,
expired,
countdown,
note,
..
} => {
out.push_str(code);
out.push_str(link);
if *expired {
out.push_str(catalog.text(Key::AccountDeviceExpired));
} else if let Some(secs) = remaining {
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
out.push_str(&catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)]));
// 倒计时文案由模型拼好(M178:owned 文字路径画不出中文)。
out.push_str(countdown);
// M178:浏览器登录没走成时那一句「为什么 + 怎么修」也在这条取样线上。
if let Some(note) = note {
out.push_str(catalog.text(super::model::note_key(*note)));
}
}
DevicePanel::Browser { link, .. } => out.push_str(link),
DevicePanel::Failed { failure } => {
DevicePanel::Browser {
link, countdown, ..
} => {
out.push_str(link);
out.push_str(countdown);
}
DevicePanel::Failed { failure, note } => {
out.push_str(catalog.text(failure_title_key(*failure)));
out.push_str(catalog.text(failure_body_key(*failure)));
out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())]));
if let Some(note) = note {
out.push_str(catalog.text(super::model::note_key(*note)));
}
}
DevicePanel::Succeeded { name } => {
out.push_str(&catalog.render(Key::AccountLoginSucceeded, &[("name", name)]));