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

@@ -29,7 +29,9 @@ use super::chain::complete_chain;
use super::endpoints::MicrosoftEndpoints;
use super::flow::{oauth_error, tokens_from, OAuthTokens, SCOPE};
use super::now_ms;
use super::preflight::{self, RedirectRegistration};
use super::session::MicrosoftSession;
use super::{BrowserFallback, BrowserFallbackReason};
/// 授权码 grant 类型。
pub const AUTHORIZATION_CODE_GRANT: &str = "authorization_code";
@@ -194,6 +196,49 @@ where
complete_chain(client, endpoints, tokens, now_ms()).await
}
/// 一次「浏览器优先」尝试的结果(M178)。
pub(crate) enum AuthCodeAttempt {
/// 授权码流走通(凭据已换到,整链完成)。
LoggedIn(MicrosoftSession),
/// **没去试**:预检说这个回环 URI 没登记(或问不出来)。监听端口一次都没开。
Skipped(BrowserFallback),
}
/// 带预检的授权码登录:注册里有这个回环 URI 才真去绑端口、弹浏览器。
///
/// 顺序是刻意的——**预检在绑定之前**:没登记的机器上连监听口都不开(守卫断言
/// [`super::callback::bind_attempts`] 不涨),也就不会出现「浏览器里一张 AAD 错误页 +
/// 启动器干等 5 分钟」的那套现象(M178 用户实测的那个 bug)。
pub(crate) async fn login_checked<F>(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
policy: AuthCodePolicy,
present: F,
) -> AuthResult<AuthCodeAttempt>
where
F: FnOnce(&AuthCodePrompt),
{
let probe = RedirectUri::new(preflight::PROBE_PORT);
match preflight::check(client, endpoints, client_id, &probe).await {
RedirectRegistration::Registered => {}
RedirectRegistration::NotRegistered { detail } => {
return Ok(AuthCodeAttempt::Skipped(BrowserFallback::new(
BrowserFallbackReason::RedirectNotRegistered,
detail,
)))
}
RedirectRegistration::Unknown { detail } => {
return Ok(AuthCodeAttempt::Skipped(BrowserFallback::new(
BrowserFallbackReason::PreflightUnavailable,
detail,
)))
}
}
let session = login(client, endpoints, client_id, policy, present).await?;
Ok(AuthCodeAttempt::LoggedIn(session))
}
/// 每次登录一个 `state`(不用于加密,只用来把迟到的旧回调挡掉)。
///
/// 128 位(两个独立随机种子的 SipHash 输出拼接):RFC 6749 §10.10 建议 `state` 至少
@@ -252,4 +252,102 @@ mod tests {
assert!(resolve_client_id().is_none());
}
}
/// **M178 守卫**:client id 只能构建期/运行期注入,**微软轨源码里不许出现任何真实 id**。
///
/// 抓得住:为了让登录「开箱可用」而把一个真 client id 写进源码(或写进本模块的默认值)。
/// 抓不住:`.github/workflows` 里 Secret 的接线(那不在本 crate 的范围内,靠发布链自己的
/// `packaging/check_release_semantics.py` 与 Actions 日志看)。
#[test]
fn no_client_id_is_hardcoded_in_the_microsoft_sources() {
// 判据一:本模块的 id 只能来自 `option_env!`(构建期注入)。
let manifest = env!("CARGO_MANIFEST_DIR");
let source = std::fs::read_to_string(format!("{manifest}/src/auth/microsoft/client_id.rs"))
.expect("读 client_id.rs");
assert!(
source.contains("option_env!(\"ULL_MSA_CLIENT_ID\")"),
"client id 的构建期注入点不见了"
);
// 判据二:`auth/microsoft/` 下的任何源码都不许出现 UUID 形状的字面量
// (Azure 客户端 id 就是 UUID)。扫**全部文件**,所以把 id 挪到别的模块同样会红。
// 范围只到 `auth/microsoft/`:`auth/offline.rs` 里那个带连字符的 UUID 是离线名字的
// 测试向量,与 client id 无关。
let mut offenders = Vec::new();
let mut stack = vec![format!("{manifest}/src/auth/microsoft")];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path.display().to_string());
continue;
}
if path.extension().is_some_and(|ext| ext == "rs") {
let text = std::fs::read_to_string(&path).unwrap_or_default();
if contains_uuid_literal(&text) {
offenders.push(path.display().to_string());
}
}
}
}
assert!(
offenders.is_empty(),
"这些源文件里出现了 UUID 形状的字面量(client id 只能构建期注入):{offenders:?}"
);
}
/// 源码里有没有 `8-4-4-4-12` 的十六进制字面量(Azure 客户端 id 的形状)。
fn contains_uuid_literal(text: &str) -> bool {
fn hex(value: &[u8]) -> bool {
!value.is_empty() && value.iter().all(u8::is_ascii_hexdigit)
}
let bytes = text.as_bytes();
let dashes: Vec<usize> = bytes
.iter()
.enumerate()
.filter(|(_, byte)| **byte == b'-')
.map(|(index, _)| index)
.collect();
dashes.windows(4).any(|window| {
let (first, last) = (window[0], window[3]);
let gaps = [
window[1] - first,
window[2] - window[1],
window[3] - window[2],
];
// 4-4-4-12 的段间距(连字符本身没算进 gap):5-5-5-13。
if gaps != [5, 5, 5] || last + 13 > bytes.len() || first < 8 {
return false;
}
hex(&bytes[first - 8..first])
&& hex(&bytes[first + 1..window[1]])
&& hex(&bytes[window[1] + 1..window[2]])
&& hex(&bytes[window[2] + 1..last])
&& hex(&bytes[last + 1..last + 13])
})
}
/// 判据自检:`contains_uuid_literal` 认得出 UUID,也不会把普通代码当 UUID。
///
/// 没有这条,「源码里没有 UUID」可能因为判据本身坏掉而永远为真(假绿)。
/// 样本**运行时拼**:字面量写在这里会被上面的扫描抓成 offender(自指陷阱)。
#[test]
fn the_uuid_literal_detector_bites() {
let sample = format!(
"\"{}-{}-{}-{}-{}\"",
"83fd9b2f", "da89", "41f7", "b41b", "13742a73841c"
);
assert!(contains_uuid_literal(&sample), "{sample}");
assert!(!contains_uuid_literal("// 0000-test-client"));
assert!(!contains_uuid_literal("a-b-c-d"));
assert!(!contains_uuid_literal(
"http://localhost/auth?client_id=not-a-uuid"
));
// 段长不对的连字符串不算(`8-4-4-4-12` 之外一律放行)。
assert!(!contains_uuid_literal(
"abcdefgh-1234-5678-9012-12345678901"
));
}
}
+191
View File
@@ -0,0 +1,191 @@
//! 「浏览器优先、失败自动回退设备码」(M178)——GUI 那条可选加速路的编排。
//!
//! ## 为什么这条流必须自己会退
//!
//! 授权码(浏览器)流把用户送回 `http://localhost:{port}/auth`。随机端口只有在「回环 URI
//! 的端口不参与匹配」成立时才可能被接受——微软官方文档
//! ([*Redirect URI (reply URL) best practices and limitations*](https://learn.microsoft.com/en-us/entra/identity-platform/reply-url),
//! *Localhost exceptions*)原文:
//!
//! > "Due to ephemeral port ranges often required by native applications, the port component
//! > (for example, :5001 or :443) is ignored for the purposes of matching a localhost redirect URI."
//!
//! 也就是说**注册一次 `http://localhost/auth` 就能接住任意端口**;但本项目这个注册当初是为
//! device-code 建的,重定向 URI 一项都没登记(`DevDocs/ops/azure-msa-app.md`)。没登记时
//! 授权端点当场回 `invalid_request ... 'redirect_uri' is not valid`,**而且不会**再跳回本地
//! 回调——用户只看到一张 AAD 原始错误页,启动器干等到超时。这正是 2026-11-08 官方产物上
//! 「微软登录整体不可用」的根因。
//!
//! 于是这里的分工是:设备码当默认(不依赖任何注册项),浏览器流只在**用户显式要求**时试,
//! 而且试之前先预检([`super::preflight`]):没登记就一个监听口都不开。
//!
//! 回退说明有两层:结构化码 [`BrowserFallbackReason`](宿主据它出 i18n,**能说清怎么修**)
//! 与原始诊断 [`BrowserFallback::detail`](只进日志,不上屏)。
use crate::auth::error::{AuthError, AuthResult};
use super::authcode::{self, AuthCodeAttempt, AuthCodePrompt};
use super::chain;
use super::flow::{self, DeviceCodePrompt};
use super::now_ms;
use super::preflight;
use super::session::MicrosoftSession;
use super::MicrosoftAuth;
/// 「浏览器优先但没走成」的原因(**上屏只看码**,`detail` 只进日志)。
///
/// 三个码对应三种不同的下一步:
/// - [`BrowserFallbackReason::RedirectNotRegistered`]:应用注册里没登记回环重定向 URI。
/// 修法是**具体可执行的**(把 `http://localhost/auth` 加进「重定向 URI」,端口不参与匹配)。
/// - [`BrowserFallbackReason::PreflightUnavailable`]:没法确认(网络/超时),只能先用设备码。
/// - [`BrowserFallbackReason::AttemptFailed`]:试过了但失败(超时、换 token 被拒…)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrowserFallbackReason {
/// 授权端点当场拒绝了这个回环 `redirect_uri`(`invalid_request` / `AADSTS50011`)
RedirectNotRegistered,
/// 预检本身没问出结论(网络/超时/非预期应答)
PreflightUnavailable,
/// 预检通过、但授权码流仍然没走通
AttemptFailed,
}
impl BrowserFallbackReason {
/// 稳定的 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",
}
}
}
/// 回退到设备码时的说明:原因码 + 原始诊断(诊断只进日志,不上屏)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BrowserFallback {
/// 原因码
pub reason: BrowserFallbackReason,
/// 原始诊断(**只进日志**;截断到 240 字符)
pub detail: String,
}
impl BrowserFallback {
/// 构造(诊断串截断:只进日志,别把整张错误页灌进去)。
#[must_use]
pub fn new(reason: BrowserFallbackReason, detail: impl Into<String>) -> Self {
let detail = detail.into();
let detail = match detail.char_indices().nth(240) {
Some((index, _)) => format!("{}…", &detail[..index]),
None => detail,
};
Self { reason, detail }
}
}
/// 一次「浏览器优先」登录的完整结果(M178)。
///
/// `listeners_opened` 是**这次登录**为本地回调开的监听口个数(0 或 1)。它存在的唯一理由是
/// 守卫能直接数:「没登记回环」「走设备码」这两条路上必须是 0——用进程级全局计数器做不到
/// 这一点(并行跑的测试会互相搅动),所以计数跟着结果走。
#[derive(Debug, Clone)]
pub struct LoginOutcome {
/// 拿到的会话(两条流任意一条走通)
pub session: MicrosoftSession,
/// 回退说明(`None` = 没走浏览器 / 浏览器走通了)
pub browser: Option<BrowserFallback>,
/// 这次登录一共开了几个本地监听口(设备码路恒 0)
pub listeners_opened: usize,
}
impl LoginOutcome {
/// 浏览器(授权码)流是不是被跳过了。
#[must_use]
pub fn browser_was_skipped(&self) -> bool {
self.browser.is_some()
}
}
impl MicrosoftAuth {
/// 浏览器优先、失败自动回退设备码(M178:GUI 的「用浏览器登录」入口)。
///
/// 顺序与判据:
/// 1. 先预检回环注册([`authcode::login_checked`])。没登记 / 问不出来 → **不绑端口**、
/// 不弹浏览器,带一条结构化说明直接进设备码;
/// 2. 预检通过 → 真走授权码流(绑端口 + 弹浏览器);
/// 3. 授权码流失败(超时 / 换 token 被拒 / 端口绑不上)→ 同样回退设备码,
/// 说明码是 [`BrowserFallbackReason::AttemptFailed`](拒绝原因若属于「重定向 URI 没登记」
/// 则归一成 [`BrowserFallbackReason::RedirectNotRegistered`],好让用户看到怎么修);
/// 4. **用户自己取消不算失败**([`AuthError::Canceled`] 原样上抛):那是明确意图,
/// 不能偷偷换一条流继续问用户要码。
///
/// `present_device` 额外收到回退说明(`None` = 根本没走浏览器),宿主据此在设备码面板上
/// 画一句「为什么改用设备码 + 怎么修」。
///
/// # Errors
///
/// 用户取消、设备码流本身的失败(网络/超时/被拒/无拥有权/无档案),见 [`AuthError`]。
pub async fn browser_then_device_login<FB, FD>(
&self,
present_browser: FB,
mut present_device: FD,
) -> AuthResult<LoginOutcome>
where
FB: FnOnce(&AuthCodePrompt),
FD: FnMut(&DeviceCodePrompt, Option<&BrowserFallback>),
{
let mut fallback: Option<BrowserFallback> = None;
// 预检通过之后才会绑端口(`login_checked` 的顺序),所以只有「真去试了」才记 1。
let mut bound_listener = false;
if self.browser_first {
match authcode::login_checked(
&self.client,
&self.endpoints,
&self.client_id,
self.authcode.clone(),
present_browser,
)
.await
{
Ok(AuthCodeAttempt::LoggedIn(session)) => {
return Ok(LoginOutcome {
session,
browser: None,
listeners_opened: 1,
})
}
// 跳过:**没绑端口**(预检在绑定之前),所以计数仍是 0。
Ok(AuthCodeAttempt::Skipped(note)) => fallback = Some(note),
Err(AuthError::Canceled) => return Err(AuthError::Canceled),
Err(error) => {
bound_listener = true;
self.record_failure("authcode_login", &error);
let reason = if preflight::error_means_unregistered(&error) {
BrowserFallbackReason::RedirectNotRegistered
} else {
BrowserFallbackReason::AttemptFailed
};
fallback = Some(BrowserFallback::new(reason, error.to_string()));
}
}
}
let grant =
flow::request_device_code(&self.client, &self.endpoints, &self.client_id).await?;
present_device(&grant.prompt, fallback.as_ref());
let tokens = flow::poll_device_token(
&self.client,
&self.endpoints,
&self.client_id,
&grant,
self.poll,
)
.await?;
let session =
chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await?;
Ok(LoginOutcome {
session,
browser: fallback,
listeners_opened: usize::from(bound_listener),
})
}
}
+46 -12
View File
@@ -1,29 +1,39 @@
//! 微软轨:device-code 与授权码(浏览器)两条登录流 + 会话/刷新语义
//! (`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5、`research/08` §3)。
//!
//! 分六块,各管一件事:
//! - [`client_id`]:Azure 公共客户端 id 的**配置状态**(来源 / 配没配 / 启动日志那一行,零网络);
//! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测);
//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock);
//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层);
//! - [`authcode`] + [`callback`]:授权码流(授权 URL + 本地 HTTP 回调服务器 + code 换 token);
//! - [`endpoints`] + [`flow`]:端点表与 device-code 发起 / 轮询 / refresh(OAuth 层);
//! - [`authcode`] + [`callback`] + [`preflight`] + [`login`]:授权码流(授权 URL + 本地回调 +
//! 回环注册预检 + 回退设备码的编排);
//! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。
//!
//! 两条流只差「怎么拿到 MS access_token」:拿到之后都进同一个 [`chain::complete_chain`],
//! 落盘/刷新也共用 [`MicrosoftSession`],所以 GUI 账户页的两颗按钮不会长出两套凭据语义。
//! # 默认走哪条(M178)
//!
//! **设备码是默认**,授权码是可选加速路:授权码流要求应用注册里登记回环重定向 URI
//! (`http://localhost/auth`,端口由微软忽略),而本项目的注册当初只为 device-code 而建。
//! 没登记时授权端点当场拒掉且**不**把浏览器送回本地回调,用户只看到
//! `invalid_request ... 'redirect_uri' is not valid` 那张 AAD 原始错误页。所以:
//!
//! | 入口 | 走什么 |
//! |---|---|
//! | [`MicrosoftAuth::device_code_login`] | 设备码,**一个监听口都不开** |
//! | [`MicrosoftAuth::browser_then_device_login`] | 先预检回环注册:没登记就直接设备码;登记了走授权码,失败回退设备码 |
//! | [`MicrosoftAuth::authcode_login`] | 强制授权码(测试/排障用,不做预检) |
//!
//! # client id 从哪来
//!
//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(且租户是 `consumers`、
//! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的
//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由
//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(租户必须是 `consumers`)。
//! 授权码流额外要求注册里登记过回环重定向 URI(`http://localhost/auth`,端口不参与匹配;
//! 见 [`login`] 引的微软官方原文)。id 与 HMCL 同款模型:二进制里不硬编码,
//! 改由 `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`],文案是
//! [`client_id::MISSING_CLIENT_ID_MESSAGE`](「未配置」+ 怎么配,见 [`client_id`])——
//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。
//!
//! 措辞纪律(M117):id 缺失是**没配置**,不是「本期未支持」。判定与那行启动日志都在
//! [`client_id`],本模块只消费结论。
//! 措辞纪律(M117):id 缺失是**没配置**不是「本期未支持」,判定与启动日志都在 [`client_id`]。
pub mod authcode;
pub mod callback;
@@ -31,11 +41,15 @@ pub mod chain;
pub mod client_id;
pub mod endpoints;
pub mod flow;
pub mod login;
pub mod preflight;
pub mod session;
pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri};
pub use callback::CallbackServer;
// 回环重定向 URI 的注册预检(M178):`RedirectRegistration` 是宿主排障时唯一要看的结论。
pub use preflight::{RedirectRegistration, PROBE_PORT};
// client id 的判定与启动日志行留一层 re-export:调用方(CLI / GUI / 启动日志)只认
// `auth::microsoft::*` 这一条路径,不必知道它是单独一块(M117)。
pub use client_id::{
@@ -44,6 +58,7 @@ pub use client_id::{
};
pub use endpoints::MicrosoftEndpoints;
pub use flow::{DeviceCodePrompt, PollPolicy};
pub use login::{BrowserFallback, BrowserFallbackReason, LoginOutcome};
pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -63,6 +78,7 @@ pub struct MicrosoftAuth {
endpoints: MicrosoftEndpoints,
poll: PollPolicy,
authcode: AuthCodePolicy,
browser_first: bool,
journal: Option<AuthJournal>,
}
@@ -81,6 +97,7 @@ impl MicrosoftAuth {
endpoints: MicrosoftEndpoints::consumers(),
poll: PollPolicy::default(),
authcode: AuthCodePolicy::default(),
browser_first: false,
journal: None,
})
}
@@ -155,6 +172,22 @@ impl MicrosoftAuth {
self
}
/// 「浏览器优先」(M178):授权码流只是**可选加速路**,默认不走。
///
/// 只有宿主确认用户显式要求时才置位(GUI 的「用浏览器登录」按钮)。置位后仍然先做
/// 回环注册预检([`preflight`]):没登记就**不绑端口、不弹浏览器**,直接设备码。
#[must_use]
pub fn with_browser_first(mut self, enabled: bool) -> Self {
self.browser_first = enabled;
self
}
/// 是否允许尝试浏览器(授权码)流。
#[must_use]
pub fn browser_first(&self) -> bool {
self.browser_first
}
/// 当前端点表。
#[must_use]
pub fn endpoints(&self) -> &MicrosoftEndpoints {
@@ -354,13 +387,14 @@ mod tests {
.endpoints()
.profile
.contains("api.minecraftservices.com"));
// 默认**不是**浏览器优先(M178:设备码才是默认路)。
assert!(!auth.browser_first());
}
#[test]
fn now_ms_is_sane() {
// 2020-01-01 之后、2100 之前(防止本地时钟/单位抄错)。
let now = now_ms();
assert!(now > 1_577_836_800_000);
assert!(now < 4_102_444_800_000);
assert!(now > 1_577_836_800_000 && now < 4_102_444_800_000);
}
}
+245
View File
@@ -0,0 +1,245 @@
//! 授权码流的「回环重定向 URI 注册预检」(M178)。
//!
//! ## 为什么需要预检
//!
//! 授权码流把浏览器送回 `http://localhost:{port}/auth`。**随机端口**只有在「回环 URI 的
//! 端口不参与匹配」这件事成立时才可能被接受——微软官方文档写得很清楚
//! ([*Redirect URI (reply URL) best practices and limitations*](https://learn.microsoft.com/en-us/entra/identity-platform/reply-url),
//! 小节 *Localhost exceptions*):
//!
//! > "Due to ephemeral port ranges often required by native applications, the port component
//! > (for example, :5001 or :443) is ignored for the purposes of matching a localhost redirect URI.
//! > As a result, all of these URIs are considered equivalent: `http://localhost/MyApp`,
//! > `http://localhost:1234/MyApp`, `http://localhost:5000/MyApp`, `http://localhost:8080/MyApp`"
//!
//! 换句话说:**注册一次 `http://localhost/auth` 就能接住任意端口**;但**一次都没注册**时,
//! 任何端口的回环 URI 都会被授权端点当场拒掉(`invalid_request: ... 'redirect_uri' is not valid`),
//! 而且**不会**再跳回本地回调——用户只在浏览器里看到一张 AAD 错误页,启动器只能干等超时。
//! 本项目的 Azure 应用注册当初是为 device-code 建的,重定向 URI 一项都没登记
//! (`DevDocs/ops/azure-msa-app.md`),所以这条流在官方产物上必然失败。
//!
//! ## 预检做什么、不做什么
//!
//! 做:拿**即将使用的那个** `redirect_uri`(只有端口可以是任意的,所以用固定探测端口拼形状)
//! 去授权端点发一次**不带凭据的 GET**,看它是否当场报 `invalid_request` / `AADSTS50011`。
//! 不注册 → 不绑本地端口、不弹浏览器,直接回退设备码。这样「没登记回调地址」的机器上
//! 监听口一次都不开(守卫断言 `callback::bind_attempts()` 不涨)。
//!
//! 不做:预检失败**不等于**登录被拒——它只决定「要不要试浏览器」。
//! `Unknown`(网络问题)同样按「别试」处理:设备码流不依赖任何注册项,永远更稳。
use reqwest::Client;
use crate::auth::error::AuthError;
use super::authcode::{authorization_url, RedirectUri};
use super::endpoints::MicrosoftEndpoints;
/// 预检用的回环端口。
///
/// **不是要绑它**:端口对回环匹配不参与(见模块文档引的那段官方原文),所以随便一个
/// 「看起来像临时端口」的值就能代表真实绑定后的那个。固定值让日志与测试可复现。
pub const PROBE_PORT: u16 = 53_691;
/// 预检请求里的 `state`(不参与任何校验,只为拼出形状完整的授权 URL)。
const PROBE_STATE: &str = "ull-redirect-probe";
/// 授权端点对 `redirect_uri` 的态度。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RedirectRegistration {
/// 注册里有这个回环 URI(端口随意)→ 可以真的走授权码流。
Registered,
/// 明确没登记(`invalid_request` / `AADSTS50011`)→ 别试,直接设备码。
NotRegistered {
/// 诊断原文(只进日志)
detail: String,
},
/// 问不出来(网络/超时/非预期应答)→ 同样不试,但原因是「没确认」而不是「没配」。
Unknown {
/// 诊断原文(只进日志)
detail: String,
},
}
impl RedirectRegistration {
/// 是否值得继续走授权码流。
#[must_use]
pub fn is_registered(&self) -> bool {
matches!(self, Self::Registered)
}
}
/// 预检:注册里有没有这个回环重定向 URI。
///
/// 判据(顺序即优先级):
/// 1. 应答里出现 `invalid_request` / `AADSTS50011` / `redirect_uri is not valid` 一类字样 → 没登记;
/// 2. 非 2xx/3xx → 按「没登记」处理(AAD 对不合法的 redirect_uri 直接渲染错误页,状态码 400);
/// 3. 其余 2xx/3xx 且没有拒绝字样 → 认为已登记(那就是登录页本身);
/// 4. 连不上 → `Unknown`。
///
/// 阅读提示:这条判据**宁可错杀**——错过一次浏览器登录只是慢一步,误放行会让用户撞上
/// AAD 的原始错误页,而那正是本 mission 要消灭的现象。
pub(super) async fn check(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
redirect_uri: &RedirectUri,
) -> RedirectRegistration {
let url = authorization_url(endpoints, client_id, redirect_uri, PROBE_STATE);
let response = client.get(&url).send().await;
let response = match response {
Ok(response) => response,
Err(error) => {
return RedirectRegistration::Unknown {
detail: format!("预检请求失败:{error}"),
}
}
};
let status = response.status().as_u16();
// 登录页可能带上几十上百 KB 的脚本;拒绝页只有一小段。读整页是为了抓正文里的错误码,
// reqwest 的 `text()` 会把正文一次读尽(授权页不含任何凭据,不需要脱敏)。
let body = response.text().await.unwrap_or_default();
if rejects_redirect_uri(&body) {
return RedirectRegistration::NotRegistered {
detail: format!("HTTP {status}:{}", snippet(&body)),
};
}
if !(200..400).contains(&status) {
return RedirectRegistration::NotRegistered {
detail: format!("HTTP {status}:{}", snippet(&body)),
};
}
RedirectRegistration::Registered
}
/// 这个错误是不是「重定向 URI 没登记」那一类(`AuthError` 侧重判,回调/换 token 用)。
///
/// 与 [`rejects_redirect_uri`] 同源:一个看页面正文,一个看结构化错误。两者都覆盖
/// `invalid_request` 与 `AADSTS50011`,因为同一件事在两条流上的形状不一样。
#[must_use]
pub(super) fn error_means_unregistered(error: &AuthError) -> bool {
match error {
AuthError::Denied { code, description } => {
is_redirect_code(code) || redirects_rejected(description)
}
AuthError::Response(detail) => {
redirects_rejected(detail.server_message.as_deref().unwrap_or_default())
|| redirects_rejected(detail.digest.as_str())
}
_ => false,
}
}
fn is_redirect_code(code: &str) -> bool {
matches!(
code,
"invalid_request"
| "invalid_redirect_uri"
| "redirect_uri_mismatch"
| "unauthorized_client"
)
}
fn redirects_rejected(text: &str) -> bool {
let lowered = text.to_lowercase();
lowered.contains("aadsts50011")
|| lowered.contains("invalid_request")
|| lowered.contains("redirect_uri is not valid")
|| lowered.contains("redirect_uri+is+not+valid")
|| lowered.contains("reply url specified in the request does not match")
|| (lowered.contains("redirect") && lowered.contains("not valid"))
}
/// 页面正文里有没有「重定向 URI 不合法」的拒绝标记。
fn rejects_redirect_uri(body: &str) -> bool {
let lowered = body.to_lowercase();
lowered.contains("aadsts50011")
|| lowered.contains("invalid_request")
|| lowered.contains("redirect_uri is not valid")
|| lowered.contains("redirect_uri+is+not+valid")
|| (lowered.contains("redirect") && lowered.contains("not valid"))
}
/// 正文摘要(单行、截断):只进日志,不上屏。
fn snippet(body: &str) -> String {
let one_line: String = body
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let collapsed = one_line.split_whitespace().collect::<Vec<_>>().join(" ");
match collapsed.char_indices().nth(200) {
Some((index, _)) => format!("{}…", &collapsed[..index]),
None => collapsed,
}
}
#[cfg(test)]
mod tests {
use super::{error_means_unregistered, rejects_redirect_uri, snippet, RedirectRegistration};
use crate::auth::body::ResponseDetail;
use crate::auth::error::AuthError;
/// 用户 2026-11-08 实测的那张错误页(原文截取)必须被判成「没登记」。
#[test]
fn the_reported_aad_page_is_recognized_as_unregistered() {
let page = "error=invalid_request&error_description=The+provided+value+for+the+input+\
parameter+%27redirect_uri%27+is+not+valid.+The+expected+value+is+a+URI+which+matches+a+\
redirect+URI+registered+for+this+client+application.";
assert!(rejects_redirect_uri(page));
// 登录页本身(有脚本、有表单)不能被误判成拒绝。
assert!(!rejects_redirect_uri(
"<html><head><title>Sign in to your account</title></head><body>...</body></html>"
));
}
#[test]
fn aadsts50011_is_also_a_registration_problem() {
assert!(rejects_redirect_uri(
"AADSTS50011: The redirect URI 'http://localhost:61492/auth' specified in the request does not match"
));
}
/// 两条流上的同一件事:授权页拒绝(`Denied`)与 token 端点拒绝(`Response`)都要认。
#[test]
fn structured_errors_of_both_shapes_are_recognized() {
assert!(error_means_unregistered(&AuthError::Denied {
code: "invalid_request".to_string(),
description: "bad redirect".to_string(),
}));
assert!(error_means_unregistered(&AuthError::Response(Box::new(
ResponseDetail {
step: "token".to_string(),
what: "换 token".to_string(),
status: 400,
content_type: None,
kind: crate::auth::BodyKind::Json,
server_message: Some("AADSTS50011: reply url mismatch".to_string()),
digest: "digest".to_string(),
}
))));
// 不相干的失败不是注册问题(别把网络/白名单也归到这条)。
assert!(!error_means_unregistered(&AuthError::Canceled));
}
#[test]
fn registration_state_is_answered_by_one_predicate() {
assert!(RedirectRegistration::Registered.is_registered());
assert!(!RedirectRegistration::NotRegistered {
detail: String::new()
}
.is_registered());
assert!(!RedirectRegistration::Unknown {
detail: String::new()
}
.is_registered());
}
#[test]
fn snippet_is_single_line_and_capped() {
let long = format!("a\nb\t{}", "x".repeat(400));
let text = snippet(&long);
assert!(!text.contains('\n'), "{text}");
assert!(text.ends_with('…'), "{text}");
assert_eq!(text.chars().count(), 201);
}
}
+2
View File
@@ -7,6 +7,7 @@
//! - `microsoft_authcode`:授权码流(本地回调服务器 / 授权 URL 向量 / code 换链 / 403 诊断);
//! - `microsoft_diagnostics`:非 2xx 正文诊断(JSON/HTML/空体分流)+ 失败日志落盘(M38);
//! - `microsoft_refresh`:refresh 轮换、profile 变脸防线、validate 语义、404 二义;
//! - `microsoft_msalogin`:回环重定向 URI 没登记时的回退(M178:默认设备码、不白开监听口);
//! - `authlib_api`:Yggdrasil 四动作的报文级对接。
mod support;
@@ -16,6 +17,7 @@ mod authlib_api;
mod microsoft_authcode;
mod microsoft_diagnostics;
mod microsoft_flow;
mod microsoft_msalogin;
mod microsoft_refresh;
mod offline_uuid;
mod store_separation;
@@ -0,0 +1,267 @@
//! M178 守卫:微软登录在**官方产物**(应用注册里没有任何重定向 URI)上的行为。
//!
//! 用户 2026-11-08 实测的现象:GUI 直接走授权码流 → 授权端点回
//! `invalid_request ... 'redirect_uri' is not valid` → 浏览器里一张 AAD 原始错误页,
//! 启动器干等超时 ⇒ 官方产物上「微软登录」整体不可用。
//!
//! 本文件钉四件事(都是能给答案「能不能变红」的):
//!
//! | 守卫 | 用例 | 抓到什么 |
//! |---|---|---|
//! | ① 回退且提示可读 | [`unregistered_redirect_falls_back_to_the_device_code`] | 回退没发生 / 原因码丢了(页面就只能画原文) |
//! | ② 不白开监听口 | 同上 + [`device_code_default_never_opens_a_listening_port`] | 预检被挪到绑定之后(`listeners_opened` 会变 1) |
//! | ③ 注册过就仍能走浏览器 | [`registered_loopback_still_runs_the_auth_code_flow`] | 把加速路彻底焊死(一刀切回退) |
//! | ④ 取消不被偷偷换流 | [`user_cancel_on_the_browser_page_does_not_fall_back`] | 取消被当成失败的后退理由 |
//!
//! 另:**真微软登录没有在这里验**(要真人拿自己账号点一次),这里只证错误处理与回退路径。
use std::sync::{Arc, Mutex};
use std::time::Duration;
use unlucky_core::auth::microsoft::{
AuthCodePolicy, BrowserFallbackReason, MicrosoftAuth, MicrosoftEndpoints,
};
use unlucky_core::auth::AuthError;
use crate::support::{full_routes, AuthMock, MockReply};
const CLIENT_ID: &str = "test-client-id";
/// 授权端点对**没登记**的回环 redirect_uri 的应答(2026-11-08 实测那张页面的形状)。
const INVALID_REDIRECT_PAGE: &str =
"error=invalid_request&error_description=The+provided+value+for+the+input+\
parameter+%27redirect_uri%27+is+not+valid.+The+expected+value+is+a+URI+which+matches+a+\
+redirect+URI+registered+for+this+client+application.";
/// 授权端点对**已登记**回环 URI 的正常应答(登录页,无拒绝字样)。
const SIGN_IN_PAGE: &str = "<!DOCTYPE html><html><head><title>Sign in to your account</title>\
</head><body><form id=\"loginForm\"></form></body></html>";
/// 设备码面板里看到的 `(用户码, 回退原因码)`(跨闭包共享)。
type Seen = Arc<Mutex<Option<(String, Option<BrowserFallbackReason>)>>>;
fn policy(state: &str) -> AuthCodePolicy {
AuthCodePolicy {
callback_timeout: Duration::from_millis(2_000),
bind_attempts: 1,
port: None,
state: Some(state.to_string()),
}
}
/// 路由表 + 定向的 `/authorize` 应答(预检打的就是这个 path)。
fn routes_with_authorize(reply: MockReply) -> Vec<(&'static str, Vec<MockReply>)> {
let mut routes = full_routes();
routes.push(("/authorize", vec![reply]));
routes
}
fn auth(base: &str, browser_first: bool) -> MicrosoftAuth {
MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(base))
.with_poll_policy(crate::support::microsoft::fast_poll())
.with_authcode_policy(policy("state-abc"))
.with_browser_first(browser_first)
}
/// 守卫 ① + ②:没登记回环 → 回退设备码、原因码是「没登记」、**监听口一次都不开**。
#[tokio::test]
async fn unregistered_redirect_falls_back_to_the_device_code() {
let mock = AuthMock::start(routes_with_authorize(MockReply::text(
400,
INVALID_REDIRECT_PAGE,
)))
.await;
let seen: Seen = Arc::new(Mutex::new(None));
let seen_for_device = Arc::clone(&seen);
let outcome = auth(&mock.base(), true)
.browser_then_device_login(
|_prompt| panic!("没登记回环时**不许**弹浏览器(用户只会看到 AAD 错误页)"),
move |prompt, fallback| {
*seen_for_device.lock().unwrap() =
Some((prompt.user_code.clone(), fallback.map(|note| note.reason)));
},
)
.await
.expect("必须回退到设备码并成功");
// ① 回退确实发生,且宿主拿到的是**结构化原因码**(页面据此出可读文案,而不是画原文)
let (user_code, reason) = seen.lock().unwrap().clone().expect("设备码必须被展示");
assert_eq!(user_code, "ABCD-1234");
assert_eq!(reason, Some(BrowserFallbackReason::RedirectNotRegistered));
assert_eq!(
reason.map(BrowserFallbackReason::code),
Some("redirect_not_registered")
);
assert_eq!(outcome.session.profile_name, "Steve");
let note = outcome.browser.expect("必须带回退说明");
assert_eq!(note.reason, BrowserFallbackReason::RedirectNotRegistered);
// 原文只进日志:说明里的 detail 有内容,但页面只拿码(i18n 在 app-iced 侧)。
assert!(!note.detail.is_empty());
// ② 预检在绑定之前:这次登录一个监听口都没开(用户机上不会出现「占了端口却没人回调」)
assert_eq!(
outcome.listeners_opened, 0,
"没登记回环时不许绑本地端口(预检必须在绑定之前)"
);
mock.stop();
}
/// 守卫 ②(默认路那一半):设备码是默认,浏览器流根本没被启用 ⇒ 监听计数不涨。
#[tokio::test]
async fn device_code_default_never_opens_a_listening_port() {
let mock = AuthMock::start(full_routes()).await;
let device_seen = Arc::new(Mutex::new(None));
let device_flag = Arc::clone(&device_seen);
let outcome = auth(&mock.base(), false)
.browser_then_device_login(
|_| panic!("默认路不许碰浏览器回调"),
move |prompt, fallback| {
*device_flag.lock().unwrap() = Some(prompt.user_code.clone());
assert!(fallback.is_none(), "没走浏览器就不该有回退说明");
},
)
.await
.expect("设备码登录成功");
assert_eq!(
device_seen.lock().unwrap().clone().as_deref(),
Some("ABCD-1234")
);
assert!(outcome.browser.is_none());
assert_eq!(
outcome.session.profile_id,
crate::support::microsoft::PROFILE_ID
);
assert_eq!(outcome.listeners_opened, 0, "设备码流不许开任何本地监听口");
// 授权端点一次都没被请求(那正是会返回 invalid_request 的那一跳)
assert!(mock.bodies_on("/authorize").is_empty());
mock.stop();
}
/// 守卫 ③:回环**真的登记了**的时候,加速路必须还能走(否则就是把功能一刀切焊死)。
#[tokio::test]
async fn registered_loopback_still_runs_the_auth_code_flow() {
let mock = AuthMock::start(routes_with_authorize(MockReply::html(200, SIGN_IN_PAGE))).await;
let outcome = auth(&mock.base(), true)
.browser_then_device_login(
|prompt| {
let redirect = prompt.redirect_uri.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let url = format!("{redirect}?code=the-auth-code&state=state-abc");
let response = reqwest::get(&url).await.expect("浏览器回调");
assert_eq!(response.status(), 200);
});
},
|_prompt, _note| panic!("登记了回环就不该回退设备码"),
)
.await
.expect("授权码流应当走通");
assert_eq!(outcome.session.profile_name, "Steve");
assert!(outcome.browser.is_none(), "走通了就不该有回退说明");
assert_eq!(
outcome.listeners_opened, 1,
"登记了回环就应当真的绑端口(否则加速路名存实亡)"
);
// 换 token 用的是**授权码** grant(不是设备码那条)。
let token_bodies = mock.bodies_on("/token");
assert_eq!(token_bodies.len(), 1, "{token_bodies:?}");
assert!(token_bodies[0].contains("grant_type=authorization_code"));
mock.stop();
}
/// 守卫 ④:用户自己取消**不算**失败——不许偷偷换一条流继续要码。
#[tokio::test]
async fn user_cancel_on_the_browser_page_does_not_fall_back() {
let mock = AuthMock::start(routes_with_authorize(MockReply::html(200, SIGN_IN_PAGE))).await;
let error = auth(&mock.base(), true)
.browser_then_device_login(
|prompt| {
let redirect = prompt.redirect_uri.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let url = format!("{redirect}?error=access_denied&state=state-abc");
let _ = reqwest::get(&url).await;
});
},
|_prompt, _note| panic!("用户取消后不许自动换流"),
)
.await
.expect_err("取消应当是最终结果");
assert_eq!(error, AuthError::Canceled);
assert!(
mock.bodies_on("/devicecode").is_empty(),
"取消之后不该再去要设备码"
);
mock.stop();
}
/// 预检问不出结论(连不上)也要回退,且原因码是「没确认」而不是「没登记」。
#[tokio::test]
async fn an_unreachable_precheck_falls_back_with_the_unverified_reason() {
// 只有授权端点指到一个必然关闭的端口:预检必然失败,而设备码那条链照常打 mock。
let mock = AuthMock::start(full_routes()).await;
let mut endpoints = MicrosoftEndpoints::all_on(&mock.base());
endpoints.authorize = "http://127.0.0.1:1/authorize".to_string();
let reason = Arc::new(Mutex::new(None));
let reason_flag = Arc::clone(&reason);
let outcome = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(endpoints)
.with_poll_policy(crate::support::microsoft::fast_poll())
.with_authcode_policy(policy("state-abc"))
.with_browser_first(true)
.browser_then_device_login(
|_prompt| panic!("预检失败时不许弹浏览器"),
move |_prompt, fallback| {
*reason_flag.lock().unwrap() = fallback.map(|note| note.reason);
},
)
.await
.expect("预检失败不该拦住设备码");
assert_eq!(
*reason.lock().unwrap(),
Some(BrowserFallbackReason::PreflightUnavailable)
);
assert_eq!(
outcome.browser.map(|note| note.reason),
Some(BrowserFallbackReason::PreflightUnavailable)
);
assert_eq!(outcome.session.profile_name, "Steve");
assert_eq!(outcome.listeners_opened, 0, "预检失败时不该绑端口");
mock.stop();
}
/// 与上两例配对:`/authorize` 回 200 但正文是 AADSTS50011 的拒绝页,也不许当「已登记」。
#[tokio::test]
async fn aadsts50011_page_is_not_treated_as_registered() {
let mock = AuthMock::start(routes_with_authorize(MockReply::text(
200,
"AADSTS50011: The redirect URI specified in the request does not match the redirect URIs configured",
)))
.await;
let note = Arc::new(Mutex::new(None));
let note_flag = Arc::clone(&note);
let outcome = auth(&mock.base(), true)
.browser_then_device_login(
|_| panic!("AADSTS50011 的页面不许当已登记"),
move |_prompt, fallback| {
*note_flag.lock().unwrap() = fallback.map(|note| note.reason);
},
)
.await
.expect("回退设备码后应当成功");
assert_eq!(
*note.lock().unwrap(),
Some(BrowserFallbackReason::RedirectNotRegistered)
);
assert_eq!(
outcome.browser.map(|note| note.reason),
Some(BrowserFallbackReason::RedirectNotRegistered)
);
mock.stop();
}