fix(auth): /auth/native/authorize 空 provider 自动选择不再统计会被拒绝的密码 provider

Fix #78906

当部署同时启用 basic 密码 provider 与一个 OAuth/OIDC session provider 时,
list_session_providers() 会把密码 provider 也计入 "exactly one candidate"
判断(密码 provider 虽是 session provider,但下一行就会因 supports_password
被原生 OAuth broker 流程拒绝),导致 len == 2、自动选择被跳过,桌面端
空 provider 登录返回 404 "Unknown provider: ''"。

修复:自动选择只在可 broker 的 provider(supports_session 且非
supports_password)中计数,与 /api/status 的 native_pkce 能力宣告使用同一
"brokerable" 定义;当没有任何可 broker provider 时保留原有选择逻辑,
让显式的 400 错误继续解释密码 provider 不支持原生 OAuth。

新增回归测试:basic+OIDC 并存时自动选中 OIDC、单 OAuth provider 自动
选中、多 OAuth provider 歧义 404、纯密码部署保留 400。
This commit is contained in:
x7peeps
2026-08-05 05:09:01 +08:00
committed by ethernet
parent f51aa6a9b5
commit ed5e17f4b8
2 changed files with 126 additions and 6 deletions
+23 -6
View File
@@ -315,14 +315,31 @@ async def auth_native_authorize(
raise HTTPException(status_code=400, detail="code_challenge required")
_validate_loopback_redirect_uri(redirect_uri)
# Resolve the provider. With exactly one session provider registered
# (the common hosted case) an empty ``provider`` selects it, mirroring
# the auto-SSO convenience so the desktop needn't hardcode the name.
# Resolve the provider. With exactly one brokerable session provider
# registered (the common hosted case) an empty ``provider`` selects it,
# mirroring the auto-SSO convenience so the desktop needn't hardcode the
# name. Password providers are session providers too, but they can never
# be the target of the native OAuth broker flow (rejected below), so they
# must not count toward "exactly one candidate": otherwise a normal
# SSO-with-password-fallback deployment (one OIDC provider + the bundled
# ``basic`` provider) would see two session providers, skip the
# auto-select, and fail desktop login with a misleading "Unknown provider".
p = get_provider(provider) if provider else None
if p is None and not provider:
sess_providers = list_session_providers()
if len(sess_providers) == 1:
p = sess_providers[0]
native_eligible = [
pp
for pp in list_session_providers()
if not getattr(pp, "supports_password", False)
]
if len(native_eligible) == 1:
p = native_eligible[0]
elif not native_eligible:
# No brokerable provider at all. Preserve the old behaviour of
# selecting a lone password provider so the explicit 400 below
# (rather than a 404) explains why native OAuth is unavailable.
sess_providers = list_session_providers()
if len(sess_providers) == 1:
p = sess_providers[0]
if p is None:
raise HTTPException(
status_code=404, detail=f"Unknown provider: {provider!r}"
@@ -51,6 +51,32 @@ def _make_pkce() -> tuple[str, str]:
return verifier, challenge
class _PasswordOnlyProvider(StubAuthProvider):
"""Mirrors the bundled ``basic`` provider's flags: a session provider
(``supports_session`` defaults True) that authenticates by username +
password and can never be the target of the native OAuth broker flow.
``start_login`` raises to prove the route must reject it before ever
attempting a redirect."""
name = "pwonly"
display_name = "Password Only (test)"
supports_password = True
def start_login(self, *, redirect_uri):
raise AssertionError(
"native authorize must reject a password provider before "
"calling start_login"
)
class _SecondStubProvider(StubAuthProvider):
"""A second brokerable OAuth provider, so tests can create an ambiguous
multi-provider deployment."""
name = "stub2"
display_name = "Stub IdP Two (test only)"
# ---------------------------------------------------------------------------
# native_flow broker unit tests
# ---------------------------------------------------------------------------
@@ -181,6 +207,83 @@ def test_native_authorize_rejects_non_loopback_redirect(gated_client):
assert "loopback" in r.json()["detail"].lower()
# ---------------------------------------------------------------------------
# Empty-provider auto-select (the desktop omits ``provider``; the gateway
# picks when there is exactly one brokerable candidate) — regression #78906
# ---------------------------------------------------------------------------
def _native_authorize_params(challenge, **overrides):
params = {
"code_challenge": challenge,
"code_challenge_method": "S256",
"redirect_uri": "http://127.0.0.1:53999/cb",
"state": "s",
}
params.update(overrides)
return params
def test_native_authorize_empty_provider_auto_selects_oauth_with_password_also_registered(
gated_client,
):
"""Regression for #78906: a password provider is a session provider but
can never be the target of the native OAuth broker flow, so it must not
count toward the empty-provider auto-select. With one OAuth provider +
one password provider (the normal SSO-with-password-fallback setup) the
desktop's empty-provider request must auto-select the OAuth provider
(302), not fail with ``Unknown provider: ''`` (404)."""
register_provider(_PasswordOnlyProvider())
_verifier, challenge = _make_pkce()
r = gated_client.get(
"/auth/native/authorize",
params=_native_authorize_params(challenge),
)
assert r.status_code == 302, r.text
assert "code=stub_code" in r.headers["location"]
def test_native_authorize_empty_provider_auto_selects_single_oauth(gated_client):
"""The common hosted case: exactly one brokerable provider; an empty
``provider`` auto-selects it (302), so the desktop needn't hardcode the
name."""
_verifier, challenge = _make_pkce()
r = gated_client.get(
"/auth/native/authorize",
params=_native_authorize_params(challenge),
)
assert r.status_code == 302, r.text
assert "code=stub_code" in r.headers["location"]
def test_native_authorize_empty_provider_ambiguous_multiple_oauth_404(gated_client):
"""Two brokerable providers: the empty-provider convenience cannot pick
unambiguously, so the request still fails — the desktop must pass
``?provider=`` explicitly."""
register_provider(_SecondStubProvider())
_verifier, challenge = _make_pkce()
r = gated_client.get(
"/auth/native/authorize",
params=_native_authorize_params(challenge),
)
assert r.status_code == 404
def test_native_authorize_empty_provider_password_only_rejected_400(gated_client):
"""Password-only deployment: an empty ``provider`` must still select the
lone session provider and fail with the explicit 400 explaining that
password providers have no native OAuth flow — not a bare 404."""
clear_providers()
register_provider(_PasswordOnlyProvider())
_verifier, challenge = _make_pkce()
r = gated_client.get(
"/auth/native/authorize",
params=_native_authorize_params(challenge),
)
assert r.status_code == 400
assert "does not support native OAuth login" in r.json()["detail"]
# ---------------------------------------------------------------------------
# Cookieless bearer auth of a gated route — the core deliverable
# ---------------------------------------------------------------------------