官方产物上点「微软登录」必失败: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 字面量)。
246 lines
10 KiB
Rust
246 lines
10 KiB
Rust
//! 授权码流的「回环重定向 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);
|
||
}
|
||
}
|