fix(auth): map malformed OIDC provider bodies to OIDCError (#7258)

* fix(auth): map malformed OIDC provider bodies to OIDCError

The SSO login route answers OIDCError with 502 and the callback with
an sso_failed redirect, but oidc.py raised OIDCError only for transport
and HTTP status errors. A provider answering 200 with an HTML page or
any non-object JSON raised JSONDecodeError, KeyError or AttributeError
instead, so both routes failed with a 500. A malformed UserInfo body
also aborted the login, although UserInfo is best-effort.

Decode discovery, token, JWKS and UserInfo bodies through one helper
that requires a JSON object, require string endpoints and issuer, reject
a non-list JWKS keys field, skip non-object JWK entries, and wrap a
malformed ID token header as OIDCValidationError. Build discovery
metadata before caching it so a rejected document is not served from
the cache.

* docs(changelog): reference #7258 for the OIDC malformed body fix

* fix(auth): report a falsy non-string OIDC issuer as wrong-typed

Check the issuer's type before its presence so 0, false and [] get the
non-string message instead of "missing the issuer field"; an absent or
empty issuer still reports missing.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Hyeonsang Cho 2026-10-22 20:35:43 +09:00 committed by GitHub
parent c0ba948825
commit 66508d7522
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 299 additions and 10 deletions

View File

@ -1397,6 +1397,15 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**.
### Fixed
- **auth:** SSO login and callback no longer fail with a `500` when the OIDC
provider answers `200` with a body that is not the expected JSON object, such
as a maintenance HTML page. Discovery, token, JWKS and UserInfo bodies, a
malformed ID token header and a discovery document without a usable endpoint
now take the existing `502` / `/login?error=sso_failed` path, and a rejected
discovery document is no longer cached. A malformed UserInfo body is skipped
like a failed UserInfo request instead of aborting a login whose ID token is
valid. ([#7258])
- **mcp:** MCP servers using the OAuth `refresh_token` grant keep working
across Gateway restarts, tool-cache resets, repeated personal-connection runs
and multiple Gateway instances when the provider rotates refresh tokens
@ -13004,3 +13013,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#7207]: https://github.com/bytedance/deer-flow/pull/7207
[#7217]: https://github.com/bytedance/deer-flow/pull/7217
[#7255]: https://github.com/bytedance/deer-flow/pull/7255
[#7258]: https://github.com/bytedance/deer-flow/pull/7258

View File

@ -1112,6 +1112,13 @@
### 修复
- **认证:** OIDC 提供方以 `200` 返回非预期 JSON 对象的响应体(例如维护中的 HTML
页面)时,SSO 登录与回调不再以 `500` 失败。discovery、token、JWKS 与 UserInfo
响应体、格式错误的 ID token 头以及缺少可用端点的 discovery 文档,现在都走既有的
`502` / `/login?error=sso_failed` 路径,被拒绝的 discovery 文档也不再被缓存。
格式错误的 UserInfo 响应体会像 UserInfo 请求失败一样被跳过,不再让 ID token
有效的登录中断。([#7258])
- **MCP:** 使用 OAuth `refresh_token` 授权的 MCP 服务器,在提供方轮换 refresh token
(Auth0、Okta、Google)时,跨 Gateway 重启、工具缓存重置、个人连接的多次运行以及多个
Gateway 实例都能继续工作。此前令牌只保存在进程内存中,每个新进程或新 manager 都从配置里
@ -10380,3 +10387,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#7207]: https://github.com/bytedance/deer-flow/pull/7207
[#7217]: https://github.com/bytedance/deer-flow/pull/7217
[#7255]: https://github.com/bytedance/deer-flow/pull/7255
[#7258]: https://github.com/bytedance/deer-flow/pull/7258

View File

@ -69,6 +69,7 @@ and OIDC callbacks. A small `HttpOnly` preference cookie preserves the choice.
Logout clears auth cookies and suppresses CSRF re-issue.
OIDC redirects retain existing endpoint query bytes (RFC 6749 section 3.1).
Malformed IdP bodies raise `OIDCError` (502/`sso_failed`).
`tests/test_oidc_auth.py` pins query, state-cookie, nonce and PKCE agreement.
Personal Access Tokens (`app.gateway.auth.pat`, `Authorization: Bearer dfp_...`) run as their owning user: an invalid Bearer is a hard 401 with no cookie fallback, which keeps `CSRFMiddleware`'s Bearer skip safe (origin checks still run). Scopes narrow within the allowlisted threads/runs/projects routes (including `POST /api/threads/{id}/move`); every other authenticated route 403s PAT callers (admin included). PAT management and `/change-password` require session auth; only SHA-256 digests are stored (`0017`).

View File

@ -66,6 +66,22 @@ class OIDCUserInfoMismatch(OIDCError):
"""UserInfo sub does not match ID token sub."""
def _json_object(resp: httpx.Response, what: str) -> dict[str, Any]:
"""Decode a provider response body that must be a JSON object.
A provider in maintenance can answer 200 with an HTML page, and a broken
one with any JSON value. Both become ``OIDCError`` so routes keep their
502 / ``sso_failed`` contract instead of failing with a raw 500.
"""
try:
data = resp.json()
except ValueError as exc:
raise OIDCError(f"{what} is not valid JSON") from exc
if not isinstance(data, dict):
raise OIDCError(f"{what} is not a JSON object")
return data
# ── Service ────────────────────────────────────────────────────────────────
@ -109,13 +125,17 @@ class OIDCService:
try:
resp = await self._http.get(discovery_url)
resp.raise_for_status()
data: dict[str, Any] = resp.json()
except httpx.HTTPStatusError as exc:
raise OIDCError(f"OIDC discovery failed for issuer {issuer}: HTTP {exc.response.status_code}") from exc
except httpx.RequestError as exc:
raise OIDCError(f"OIDC discovery failed for issuer {issuer}: {exc}") from exc
data = _json_object(resp, f"OIDC discovery response from {issuer}")
discovered_issuer = data.get("issuer")
# Type before presence: a falsy non-string (0, false, []) is a
# wrong-typed field, not a missing one.
if discovered_issuer is not None and not isinstance(discovered_issuer, str):
raise OIDCError(f"OIDC discovery response from {issuer} has a non-string issuer field")
if not discovered_issuer:
raise OIDCError(f"OIDC discovery response from {issuer} is missing the issuer field")
@ -126,18 +146,30 @@ class OIDCService:
if discovered_issuer.rstrip("/") != issuer.rstrip("/"):
raise OIDCError(f"OIDC discovered issuer '{discovered_issuer}' does not match configured issuer '{issuer}'")
# Build before caching so a document missing an endpoint is not
# served from the cache for the rest of the TTL.
metadata = self._metadata_from_dict(data, overrides)
self._metadata_cache[issuer] = (now, data)
return self._metadata_from_dict(data, overrides)
return metadata
def _metadata_from_dict(self, data: dict[str, Any], overrides: dict[str, str | None] | None) -> OIDCMetadata:
"""Build OIDCMetadata from a discovery dict, applying endpoint overrides."""
overrides = overrides or {}
def endpoint(key: str, *, required: bool = True) -> str | None:
value = overrides.get(key) or data.get(key)
if not value and not required:
return None
if not isinstance(value, str) or not value:
raise OIDCError(f"OIDC discovery metadata has no usable {key}")
return value
return OIDCMetadata(
issuer=data["issuer"],
authorization_endpoint=overrides.get("authorization_endpoint") or data["authorization_endpoint"],
token_endpoint=overrides.get("token_endpoint") or data["token_endpoint"],
userinfo_endpoint=overrides.get("userinfo_endpoint") or data.get("userinfo_endpoint"),
jwks_uri=overrides.get("jwks_uri") or data["jwks_uri"],
authorization_endpoint=endpoint("authorization_endpoint"),
token_endpoint=endpoint("token_endpoint"),
userinfo_endpoint=endpoint("userinfo_endpoint", required=False),
jwks_uri=endpoint("jwks_uri"),
)
# ── Authorization URL ──────────────────────────────────────────────────
@ -209,7 +241,6 @@ class OIDCService:
try:
resp = await self._http.post(metadata.token_endpoint, data=data, headers=headers)
resp.raise_for_status()
return resp.json()
except httpx.HTTPStatusError as exc:
body = "unknown"
try:
@ -219,6 +250,7 @@ class OIDCService:
raise OIDCError(f"Token exchange failed: HTTP {exc.response.status_code} — {body}") from exc
except httpx.RequestError as exc:
raise OIDCError(f"Token exchange failed: {exc}") from exc
return _json_object(resp, "Token response")
# ── JWKS loading ───────────────────────────────────────────────────────
@ -235,11 +267,13 @@ class OIDCService:
try:
resp = await self._http.get(jwks_uri)
resp.raise_for_status()
data: dict[str, Any] = resp.json()
except httpx.HTTPStatusError as exc:
raise OIDCError(f"JWKS fetch failed: HTTP {exc.response.status_code}") from exc
except httpx.RequestError as exc:
raise OIDCError(f"JWKS fetch failed: {exc}") from exc
data = _json_object(resp, "JWKS response")
if not isinstance(data.get("keys", []), list):
raise OIDCError("JWKS response keys field is not a list")
self._jwks_cache[jwks_uri] = (now, data)
return data
@ -258,6 +292,9 @@ class OIDCService:
logs a warning so a single bad entry does not crash validation.
"""
for jwk_dict in jwks_data.get("keys", []):
if not isinstance(jwk_dict, dict):
logger.warning("Skipping non-object JWK entry from %s", jwks_uri)
continue
if kid and jwk_dict.get("kid") != kid:
continue
try:
@ -289,7 +326,10 @@ class OIDCService:
jwks_data = await self._load_jwks(metadata.jwks_uri)
# Resolve the signing key from the JWKS using the token's kid header
jwt_header = jwt.get_unverified_header(id_token)
try:
jwt_header = jwt.get_unverified_header(id_token)
except jwt.PyJWTError as exc:
raise OIDCValidationError(f"ID token is malformed: {exc}") from exc
kid = jwt_header.get("kid")
alg = jwt_header.get("alg", "RS256")
@ -361,11 +401,11 @@ class OIDCService:
try:
resp = await self._http.get(metadata.userinfo_endpoint, headers=headers)
resp.raise_for_status()
userinfo: dict[str, Any] = resp.json()
except httpx.HTTPStatusError as exc:
raise OIDCError(f"UserInfo fetch failed: HTTP {exc.response.status_code}") from exc
except httpx.RequestError as exc:
raise OIDCError(f"UserInfo fetch failed: {exc}") from exc
userinfo = _json_object(resp, "UserInfo response")
if userinfo.get("sub") and userinfo["sub"] != expected_sub:
raise OIDCUserInfoMismatch("UserInfo sub does not match ID token sub")

View File

@ -271,6 +271,17 @@ The backend also rejects password change requests for OAuth users:
| `GET /api/v1/auth/oauth/{provider}` | Initiates SSO login, redirects to the OIDC provider |
| `GET /api/v1/auth/callback/{provider}` | OIDC callback — exchanges code, creates session, redirects to frontend |
### Provider failures
An unreachable provider, an HTTP error, and a response body that is not the
expected JSON object (for example a `200` maintenance page) all fail the same
way. `GET /api/v1/auth/oauth/{provider}` answers `502`, and the callback
redirects to `/login?error=sso_failed`. A discovery document without a usable
authorization, token or JWKS endpoint counts as failed unless the provider
config overrides that endpoint, and it is not cached. A malformed UserInfo body
is skipped like a failed UserInfo request: the login continues with the ID
token claims. The cause stays in the Gateway log.
## Frontend Callback Flow
The frontend handles the post-SSO flow at `/auth/callback`:

View File

@ -1,11 +1,14 @@
import asyncio
import time
from unittest.mock import AsyncMock
from urllib.parse import parse_qs, urlsplit
import httpx
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi import HTTPException
from jwt.algorithms import RSAAlgorithm
from app.gateway.auth.models import User
from app.gateway.auth.oidc import OIDCError, OIDCIdentity, OIDCMetadata, OIDCService, OIDCValidationError
@ -605,3 +608,219 @@ async def test_id_token_nonce_claim_of_non_string_type_is_rejected_not_an_attrib
with pytest.raises(OIDCValidationError, match="nonce claim is not a string"):
await service.validate_id_token(metadata=_metadata(), client_id="deer-flow", id_token=_id_token(private_key, 12345), nonce="expected")
# ── Malformed provider bodies ─────────────────────────────────────────────
#
# A provider in maintenance can answer 200 with an HTML page, and a broken one
# with any JSON value. Every such body must surface as OIDCError so the login
# route keeps its 502 and the callback its sso_failed redirect; a raw
# JSONDecodeError / KeyError / AttributeError turned both into a 500.
_ISSUER = "https://issuer.example.com"
_DISCOVERY = {
"issuer": _ISSUER,
"authorization_endpoint": f"{_ISSUER}/auth",
"token_endpoint": f"{_ISSUER}/token",
"userinfo_endpoint": f"{_ISSUER}/userinfo",
"jwks_uri": f"{_ISSUER}/jwks",
}
_HTML_PAGE = httpx.Response(200, text="<html>Down for maintenance</html>", headers={"content-type": "text/html"})
def _service_answering(responses: dict[str, object]) -> OIDCService:
"""OIDCService whose HTTP client answers each URL path from ``responses``.
A value is either a ready ``httpx.Response`` or a JSON body served as 200.
"""
service = OIDCService()
def handler(request: httpx.Request) -> httpx.Response:
body = responses[request.url.path]
return body if isinstance(body, httpx.Response) else httpx.Response(200, json=body)
service._http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
return service
def _signed_provider() -> tuple[str, dict]:
"""Return an ID token for ``_ISSUER`` and the JWKS that verifies it."""
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
now = int(time.time())
id_token = jwt.encode(
{"iss": _ISSUER, "aud": "deer-flow", "sub": "s1", "exp": now + 300, "iat": now, "email": "user@example.com", "email_verified": True},
private_key,
algorithm="RS256",
headers={"kid": "k1"},
)
jwk = RSAAlgorithm.to_jwk(private_key.public_key(), as_dict=True)
return id_token, {"keys": [{**jwk, "kid": "k1"}]}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"document",
[
_HTML_PAGE,
["not", "an", "object"],
{key: value for key, value in _DISCOVERY.items() if key != "authorization_endpoint"},
{**_DISCOVERY, "issuer": 42},
{**_DISCOVERY, "jwks_uri": ["https://issuer.example.com/jwks"]},
{**_DISCOVERY, "userinfo_endpoint": 42},
],
ids=["html", "json-list", "missing-endpoint", "non-string-issuer", "non-string-endpoint", "non-string-userinfo"],
)
async def test_oidc_discover_wraps_malformed_document_and_does_not_cache_it(document):
service = _service_answering({"/.well-known/openid-configuration": document})
try:
with pytest.raises(OIDCError):
await service.discover(_ISSUER)
assert service._metadata_cache == {}
finally:
await service.close()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("discovered_issuer", "message"),
[
(42, "non-string issuer field"),
(0, "non-string issuer field"),
(False, "non-string issuer field"),
([], "non-string issuer field"),
("", "missing the issuer field"),
(None, "missing the issuer field"),
],
)
async def test_oidc_discover_reports_wrong_typed_issuer_apart_from_missing_one(discovered_issuer, message):
document = {key: value for key, value in _DISCOVERY.items() if key != "issuer"}
if discovered_issuer is not None:
document["issuer"] = discovered_issuer
service = _service_answering({"/.well-known/openid-configuration": document})
try:
with pytest.raises(OIDCError, match=message):
await service.discover(_ISSUER)
finally:
await service.close()
@pytest.mark.asyncio
async def test_oidc_discover_override_fills_an_endpoint_the_document_omits():
service = _service_answering({"/.well-known/openid-configuration": {key: value for key, value in _DISCOVERY.items() if key != "authorization_endpoint"}})
try:
metadata = await service.discover(_ISSUER, {"authorization_endpoint": f"{_ISSUER}/custom-auth"})
finally:
await service.close()
assert metadata.authorization_endpoint == f"{_ISSUER}/custom-auth"
@pytest.mark.asyncio
@pytest.mark.parametrize("body", [_HTML_PAGE, ["id_token"]], ids=["html", "json-list"])
async def test_oidc_exchange_code_wraps_malformed_token_response(body):
service = _service_answering({"/token": body})
try:
with pytest.raises(OIDCError, match="^Token response is not"):
await service.exchange_code(_metadata(), "deer-flow", "secret", "code", "https://app.example.com/callback")
finally:
await service.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("body", [_HTML_PAGE, [{"kty": "RSA"}], {"keys": "k1"}], ids=["html", "json-list", "keys-not-a-list"])
async def test_oidc_load_jwks_wraps_malformed_jwks_and_does_not_cache_it(body):
service = _service_answering({"/jwks": body})
try:
with pytest.raises(OIDCError, match="^JWKS response"):
await service._load_jwks(f"{_ISSUER}/jwks")
assert service._jwks_cache == {}
finally:
await service.close()
@pytest.mark.asyncio
async def test_oidc_signing_key_lookup_skips_non_object_jwks_entries():
id_token, jwks = _signed_provider()
service = _service_answering({"/jwks": {"keys": ["junk", 42, *jwks["keys"]]}})
try:
claims = await service.validate_id_token(_metadata(), "deer-flow", id_token)
finally:
await service.close()
assert claims["sub"] == "s1"
@pytest.mark.asyncio
@pytest.mark.parametrize("id_token", ["not-a-jwt", 12345], ids=["garbage", "non-string"])
async def test_oidc_validate_id_token_rejects_malformed_token_header(id_token):
_, jwks = _signed_provider()
service = _service_answering({"/jwks": jwks})
try:
with pytest.raises(OIDCValidationError, match="^ID token is malformed"):
await service.validate_id_token(_metadata(), "deer-flow", id_token)
finally:
await service.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("body", [_HTML_PAGE, ["sub"]], ids=["html", "json-list"])
async def test_oidc_callback_falls_back_to_id_token_when_userinfo_body_is_malformed(body):
"""UserInfo is best-effort: a malformed body must not abort the login."""
id_token, jwks = _signed_provider()
service = _service_answering({"/token": {"id_token": id_token, "access_token": "access"}, "/jwks": jwks, "/userinfo": body})
metadata = OIDCMetadata(**_DISCOVERY)
try:
identity = await service.authenticate_callback("keycloak", metadata, "deer-flow", "secret", "code", "https://app.example.com/callback")
finally:
await service.close()
assert identity.subject == "s1"
assert identity.email == "user@example.com"
def _oidc_router_client(monkeypatch, service: OIDCService):
from types import SimpleNamespace
from fastapi import FastAPI
from fastapi.testclient import TestClient
import deerflow.config.app_config as app_config_module
from app.gateway.auth import oidc_state
from app.gateway.auth.config import AuthConfig
from app.gateway.routers import auth as auth_router
oidc_config = SimpleNamespace(enabled=True, providers={"keycloak": _provider_config()}, frontend_base_url="")
monkeypatch.setattr(app_config_module, "get_app_config", lambda: SimpleNamespace(auth=SimpleNamespace(oidc=oidc_config)))
monkeypatch.setattr(auth_router, "_get_oidc_service", lambda: service)
monkeypatch.setattr(oidc_state, "get_auth_config", lambda: AuthConfig(jwt_secret="test-secret-for-oidc-malformed-bodies-min-32"))
app = FastAPI()
app.include_router(auth_router.router)
return TestClient(app, follow_redirects=False)
def test_oidc_login_returns_502_when_discovery_is_an_html_page(monkeypatch):
service = _service_answering({"/.well-known/openid-configuration": _HTML_PAGE})
client = _oidc_router_client(monkeypatch, service)
try:
response = client.get("/api/v1/auth/oauth/keycloak")
finally:
asyncio.run(service.close())
assert response.status_code == 502
assert response.json() == {"detail": "Failed to connect to SSO provider"}
def test_oidc_callback_redirects_sso_failed_when_token_response_is_an_html_page(monkeypatch):
from app.gateway.auth.oidc_state import OIDCStatePayload, _sign_state_payload
service = _service_answering({"/.well-known/openid-configuration": _DISCOVERY, "/token": _HTML_PAGE})
client = _oidc_router_client(monkeypatch, service)
client.cookies.set("df_oidc_state_keycloak", _sign_state_payload(OIDCStatePayload(provider="keycloak", state="expected-state")))
try:
response = client.get("/api/v1/auth/callback/keycloak", params={"code": "code", "state": "expected-state"})
finally:
asyncio.run(service.close())
assert response.status_code == 302
assert response.headers["location"] == "/login?error=sso_failed"