deer-flow/backend/tests/test_oidc_auth.py
Hyeonsang Cho 66508d7522
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>
2026-10-22 19:35:43 +08:00

827 lines
32 KiB
Python

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
from app.gateway.auth.user_provisioning import get_or_provision_oidc_user
from deerflow.config.auth_config import OIDCProviderConfig
def _provider_config(**overrides):
return OIDCProviderConfig(
display_name="Test SSO",
issuer="https://issuer.example.com",
client_id="deer-flow",
**overrides,
)
def _identity(**overrides):
values = {
"provider": "keycloak",
"subject": "oidc-subject",
"email": "user@example.com",
"email_verified": True,
"name": "Test User",
"claims": {},
}
values.update(overrides)
return OIDCIdentity(**values)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"endpoint_query",
[
"",
"?",
"?prompt=login",
"?login_hint=user%2Btag%40example.com&empty=&resource=first&resource=second",
],
)
@pytest.mark.parametrize("with_pkce_and_nonce", [False, True])
async def test_oidc_authorization_url_preserves_endpoint_query(endpoint_query, with_pkce_and_nonce):
service = OIDCService()
metadata = OIDCMetadata(
issuer="https://issuer.example.com",
authorization_endpoint=f"https://issuer.example.com/auth{endpoint_query}",
token_endpoint="https://issuer.example.com/token",
userinfo_endpoint=None,
jwks_uri="https://issuer.example.com/jwks",
)
try:
url = service.build_authorization_url(
metadata=metadata,
client_id="deer-flow",
redirect_uri="https://app.example.com/api/auth/callback/test",
scopes=["openid", "email"],
state="state+with&reserved=characters",
nonce="test-nonce" if with_pkce_and_nonce else None,
code_challenge="test-challenge" if with_pkce_and_nonce else None,
)
finally:
await service.close()
parsed = urlsplit(url)
expected = parse_qs(endpoint_query.lstrip("?"), keep_blank_values=True)
expected.update(
response_type=["code"],
client_id=["deer-flow"],
redirect_uri=["https://app.example.com/api/auth/callback/test"],
scope=["openid email"],
state=["state+with&reserved=characters"],
)
if with_pkce_and_nonce:
expected.update(nonce=["test-nonce"], code_challenge=["test-challenge"], code_challenge_method=["S256"])
assert (parsed.scheme, parsed.netloc, parsed.path) == ("https", "issuer.example.com", "/auth")
assert parse_qs(parsed.query, keep_blank_values=True) == expected
if endpoint_query.lstrip("?"):
assert parsed.query.startswith(endpoint_query[1:] + "&")
@pytest.mark.asyncio
async def test_oidc_login_redirect_preserves_configured_endpoint_query(monkeypatch):
from types import SimpleNamespace
import httpx
from fastapi import FastAPI
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
service = OIDCService()
provider = _provider_config(authorization_endpoint="https://issuer.example.com/auth?prompt=login")
config = SimpleNamespace(auth=SimpleNamespace(oidc=SimpleNamespace(enabled=True, providers={"keycloak": provider})))
monkeypatch.setattr(app_config_module, "get_app_config", lambda: 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-query-parameters-min-32"))
async def discovery_get(url):
assert url == "https://issuer.example.com/.well-known/openid-configuration"
return httpx.Response(
200,
request=httpx.Request("GET", url),
json={
"issuer": provider.issuer,
"authorization_endpoint": "https://issuer.example.com/auth",
"token_endpoint": "https://issuer.example.com/token",
"jwks_uri": "https://issuer.example.com/jwks",
},
)
monkeypatch.setattr(service._http, "get", discovery_get)
app = FastAPI()
app.include_router(auth_router.router)
try:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="https://app.example.com") as client:
response = await client.get("/api/v1/auth/oauth/keycloak", follow_redirects=False)
finally:
await service.close()
assert response.status_code == 302
query = parse_qs(urlsplit(response.headers["location"]).query)
assert query["prompt"] == ["login"]
assert query["response_type"] == ["code"]
assert query["client_id"] == [provider.client_id]
assert query["code_challenge_method"] == ["S256"]
state = oidc_state._verify_state_signed(response.cookies["df_oidc_state_keycloak"])
assert state is not None
assert query["state"] == [state.state]
assert query["nonce"] == [state.nonce]
assert query["code_challenge"] == [oidc_state.compute_code_challenge(state.code_verifier)]
@pytest.mark.asyncio
async def test_oidc_existing_local_account_blocks_sso_login_even_when_unverified():
local_user = User(email="user@example.com", password_hash="hash")
local_provider = AsyncMock()
local_provider.get_user_by_oauth.return_value = None
local_provider.get_user_by_email.return_value = local_user
with pytest.raises(HTTPException) as exc_info:
await get_or_provision_oidc_user(
provider_id="keycloak",
provider_config=_provider_config(
require_verified_email=False,
auto_create_users=False,
),
identity=_identity(email_verified=False),
local_provider=local_provider,
)
assert exc_info.value.status_code == 409
local_provider.update_user.assert_not_called()
@pytest.mark.asyncio
async def test_oidc_existing_local_account_blocks_sso_login_even_when_verified():
local_user = User(email="user@example.com", password_hash="hash")
local_provider = AsyncMock()
local_provider.get_user_by_oauth.return_value = None
local_provider.get_user_by_email.return_value = local_user
with pytest.raises(HTTPException) as exc_info:
await get_or_provision_oidc_user(
provider_id="keycloak",
provider_config=_provider_config(auto_create_users=False),
identity=_identity(subject="verified-subject"),
local_provider=local_provider,
)
assert exc_info.value.status_code == 409
local_provider.update_user.assert_not_called()
local_provider.create_oauth_user.assert_not_called()
@pytest.mark.asyncio
async def test_oidc_auto_create_assigns_admin_role_from_configured_email():
local_provider = AsyncMock()
local_provider.get_user_by_oauth.return_value = None
local_provider.get_user_by_email.return_value = None
created_user = User(
email="admin@example.com",
password_hash=None,
system_role="admin",
oauth_provider="keycloak",
oauth_id="admin-subject",
)
local_provider.create_oauth_user.return_value = created_user
result = await get_or_provision_oidc_user(
provider_id="keycloak",
provider_config=_provider_config(admin_emails=["ADMIN@example.com"]),
identity=_identity(subject="admin-subject", email="admin@example.com"),
local_provider=local_provider,
)
assert result == {"user": created_user, "created": True}
local_provider.create_oauth_user.assert_awaited_once_with(
email="admin@example.com",
oauth_provider="keycloak",
oauth_id="admin-subject",
system_role="admin",
)
@pytest.mark.asyncio
async def test_oidc_validate_id_token_refreshes_jwks_once_on_kid_miss(monkeypatch):
service = OIDCService()
metadata = OIDCMetadata(
issuer="https://issuer.example.com",
authorization_endpoint="https://issuer.example.com/auth",
token_endpoint="https://issuer.example.com/token",
userinfo_endpoint=None,
jwks_uri="https://issuer.example.com/jwks",
)
load_calls = []
resolve_results = [None, "signing-key"]
async def load_jwks(jwks_uri, force_refresh=False):
load_calls.append(force_refresh)
return {"keys": []}
async def resolve_signing_key(jwks_data, kid, algorithm, jwks_uri):
return resolve_results.pop(0)
monkeypatch.setattr(service, "_load_jwks", load_jwks)
monkeypatch.setattr(service, "_resolve_signing_key", resolve_signing_key)
monkeypatch.setattr("app.gateway.auth.oidc.jwt.get_unverified_header", lambda token: {"kid": "new-kid", "alg": "RS256"})
monkeypatch.setattr(
"app.gateway.auth.oidc.jwt.decode",
lambda *args, **kwargs: {"iss": metadata.issuer, "sub": "subject", "aud": "deer-flow", "exp": 9999999999},
)
claims = await service.validate_id_token(metadata, "deer-flow", "id-token")
assert claims["sub"] == "subject"
assert load_calls == [False, True]
await service.close()
@pytest.mark.asyncio
async def test_oidc_validate_id_token_rejects_hmac_algorithms(monkeypatch):
service = OIDCService()
metadata = OIDCMetadata(
issuer="https://issuer.example.com",
authorization_endpoint="https://issuer.example.com/auth",
token_endpoint="https://issuer.example.com/token",
userinfo_endpoint=None,
jwks_uri="https://issuer.example.com/jwks",
)
async def load_jwks(jwks_uri, force_refresh=False):
return {"keys": [{"kid": "kid", "kty": "oct", "k": "secret"}]}
async def resolve_signing_key(jwks_data, kid, algorithm, jwks_uri):
return "secret"
def decode(*args, **kwargs):
assert "HS256" not in kwargs["algorithms"]
raise OIDCValidationError("HMAC algorithms must not be accepted")
monkeypatch.setattr(service, "_load_jwks", load_jwks)
monkeypatch.setattr(service, "_resolve_signing_key", resolve_signing_key)
monkeypatch.setattr("app.gateway.auth.oidc.jwt.get_unverified_header", lambda token: {"kid": "kid", "alg": "HS256"})
monkeypatch.setattr("app.gateway.auth.oidc.jwt.decode", decode)
with pytest.raises(OIDCValidationError, match="unsupported algorithm"):
await service.validate_id_token(metadata, "deer-flow", "id-token")
await service.close()
@pytest.mark.asyncio
async def test_oidc_existing_account_lookup_uses_normalized_email():
local_user = User(email="user@example.com", password_hash="hash")
local_provider = AsyncMock()
local_provider.get_user_by_oauth.return_value = None
local_provider.get_user_by_email.return_value = local_user
with pytest.raises(HTTPException) as exc_info:
await get_or_provision_oidc_user(
provider_id="keycloak",
provider_config=_provider_config(auto_create_users=False),
identity=_identity(email="User@Example.COM"),
local_provider=local_provider,
)
assert exc_info.value.status_code == 409
local_provider.get_user_by_email.assert_awaited_once_with("user@example.com")
@pytest.mark.asyncio
async def test_oidc_auto_create_uses_normalized_email():
local_provider = AsyncMock()
local_provider.get_user_by_oauth.return_value = None
local_provider.get_user_by_email.return_value = None
created_user = User(email="user@example.com", password_hash=None, oauth_provider="keycloak", oauth_id="subject")
local_provider.create_oauth_user.return_value = created_user
await get_or_provision_oidc_user(
provider_id="keycloak",
provider_config=_provider_config(),
identity=_identity(subject="subject", email="User@Example.COM"),
local_provider=local_provider,
)
local_provider.create_oauth_user.assert_awaited_once_with(
email="user@example.com",
oauth_provider="keycloak",
oauth_id="subject",
system_role="user",
)
@pytest.mark.asyncio
async def test_oidc_metadata_from_dict_accepts_missing_overrides():
service = OIDCService()
metadata = service._metadata_from_dict(
{
"issuer": "https://issuer.example.com",
"authorization_endpoint": "https://issuer.example.com/auth",
"token_endpoint": "https://issuer.example.com/token",
"userinfo_endpoint": "https://issuer.example.com/userinfo",
"jwks_uri": "https://issuer.example.com/jwks",
},
None,
)
assert metadata.jwks_uri == "https://issuer.example.com/jwks"
await service.close()
@pytest.mark.asyncio
async def test_oidc_authenticate_callback_treats_string_false_email_verified_as_unverified(monkeypatch):
service = OIDCService()
metadata = OIDCMetadata(
issuer="https://issuer.example.com",
authorization_endpoint="https://issuer.example.com/auth",
token_endpoint="https://issuer.example.com/token",
userinfo_endpoint=None,
jwks_uri="https://issuer.example.com/jwks",
)
async def exchange_code(**kwargs):
return {"id_token": "id-token"}
async def validate_id_token(**kwargs):
return {"sub": "subject", "email": "user@example.com", "email_verified": "false"}
monkeypatch.setattr(service, "exchange_code", exchange_code)
monkeypatch.setattr(service, "validate_id_token", validate_id_token)
identity = await service.authenticate_callback(
provider_id="keycloak",
metadata=metadata,
client_id="deer-flow",
client_secret=None,
code="code",
redirect_uri="https://app.example.com/callback",
)
assert identity.email_verified is False
await service.close()
@pytest.mark.asyncio
async def test_oidc_provision_recovers_existing_user_on_create_race():
"""A concurrent create that loses the unique index re-resolves to the winner's row."""
created_user = User(email="user@example.com", password_hash=None, oauth_provider="keycloak", oauth_id="subject")
local_provider = AsyncMock()
# First lookup (by oauth) misses, then create races and raises, then re-lookup wins.
local_provider.get_user_by_oauth.side_effect = [None, created_user]
local_provider.get_user_by_email.return_value = None
local_provider.create_oauth_user.side_effect = ValueError("Email already registered: user@example.com")
result = await get_or_provision_oidc_user(
provider_id="keycloak",
provider_config=_provider_config(),
identity=_identity(subject="subject"),
local_provider=local_provider,
)
assert result == {"user": created_user, "created": False}
assert local_provider.get_user_by_oauth.await_count == 2
@pytest.mark.asyncio
async def test_oidc_provision_create_race_on_email_only_raises_409():
"""A create race that collides on email (different identity) surfaces a clean 409, not a 500."""
local_provider = AsyncMock()
# No existing oauth link before or after the race (email collision, not same subject).
local_provider.get_user_by_oauth.return_value = None
local_provider.get_user_by_email.return_value = None
local_provider.create_oauth_user.side_effect = ValueError("Email already registered: user@example.com")
with pytest.raises(HTTPException) as exc_info:
await get_or_provision_oidc_user(
provider_id="keycloak",
provider_config=_provider_config(),
identity=_identity(subject="subject"),
local_provider=local_provider,
)
assert exc_info.value.status_code == 409
@pytest.mark.asyncio
async def test_oidc_discover_rejects_mismatched_issuer(monkeypatch):
service = OIDCService()
class _Resp:
def raise_for_status(self):
return None
def json(self):
return {
"issuer": "https://evil.example.com",
"authorization_endpoint": "https://issuer.example.com/auth",
"token_endpoint": "https://issuer.example.com/token",
"jwks_uri": "https://issuer.example.com/jwks",
}
async def fake_get(url):
return _Resp()
monkeypatch.setattr(service._http, "get", fake_get)
with pytest.raises(OIDCError, match="does not match configured issuer"):
await service.discover("https://issuer.example.com")
await service.close()
@pytest.mark.asyncio
async def test_oidc_discover_accepts_issuer_with_trailing_slash_difference(monkeypatch):
service = OIDCService()
class _Resp:
def raise_for_status(self):
return None
def json(self):
return {
"issuer": "https://issuer.example.com/",
"authorization_endpoint": "https://issuer.example.com/auth",
"token_endpoint": "https://issuer.example.com/token",
"jwks_uri": "https://issuer.example.com/jwks",
}
async def fake_get(url):
return _Resp()
monkeypatch.setattr(service._http, "get", fake_get)
metadata = await service.discover("https://issuer.example.com")
assert metadata.issuer == "https://issuer.example.com/"
await service.close()
def _redirect_request(headers: dict, scheme: str = "http", netloc: str = "localhost:8001"):
from unittest.mock import MagicMock
req = MagicMock()
req.headers = headers
req.url.scheme = scheme
req.url.netloc = netloc
return req
def test_oidc_redirect_uri_prefers_configured_value():
from app.gateway.routers.auth import _resolve_oidc_redirect_uri
cfg = _provider_config(redirect_uri="https://app.example.com/api/v1/auth/callback/keycloak")
req = _redirect_request({"host": "attacker.example.com"})
assert _resolve_oidc_redirect_uri(req, "keycloak", cfg) == "https://app.example.com/api/v1/auth/callback/keycloak"
def test_oidc_redirect_uri_fallback_uses_forwarded_headers_not_raw_host():
from app.gateway.routers.auth import _resolve_oidc_redirect_uri
cfg = _provider_config()
# Raw Host is attacker-controlled; proxy-set X-Forwarded-* must win.
req = _redirect_request(
{
"host": "attacker.example.com",
"x-forwarded-host": "app.example.com",
"x-forwarded-proto": "https",
}
)
result = _resolve_oidc_redirect_uri(req, "keycloak", cfg)
assert result == "https://app.example.com/api/v1/auth/callback/keycloak"
def test_oidc_redirect_uri_fallback_plain_host_when_no_proxy_headers():
from app.gateway.routers.auth import _resolve_oidc_redirect_uri
cfg = _provider_config()
req = _redirect_request({"host": "localhost:8001"})
result = _resolve_oidc_redirect_uri(req, "keycloak", cfg)
assert result == "http://localhost:8001/api/v1/auth/callback/keycloak"
def test_oidc_callback_rejects_non_ascii_state_as_mismatch(monkeypatch):
"""A percent-encoded non-ASCII ``state`` is a 403 mismatch, not a 500."""
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.config import AuthConfig, get_auth_config, set_auth_config
from app.gateway.auth.oidc_state import OIDCStatePayload, _sign_state_payload
from app.gateway.routers import auth as auth_router
previous_auth_config = get_auth_config()
set_auth_config(AuthConfig(jwt_secret="test-secret-key-for-oidc-state-mismatch-min-32"))
try:
oidc_config = SimpleNamespace(enabled=True, providers={"keycloak": _provider_config()})
monkeypatch.setattr(app_config_module, "get_app_config", lambda: SimpleNamespace(auth=SimpleNamespace(oidc=oidc_config)))
app = FastAPI()
app.include_router(auth_router.router)
client = TestClient(app)
client.cookies.set("df_oidc_state_keycloak", _sign_state_payload(OIDCStatePayload(provider="keycloak", state="expected-state")))
response = client.get("/api/v1/auth/callback/keycloak", params={"code": "code", "state": "expected-st\xe4te"})
finally:
set_auth_config(previous_auth_config)
assert response.status_code == 403
assert response.json()["detail"] == "OIDC state mismatch"
def _metadata() -> OIDCMetadata:
return OIDCMetadata(
issuer="https://issuer.example.com",
authorization_endpoint="https://issuer.example.com/auth",
token_endpoint="https://issuer.example.com/token",
userinfo_endpoint=None,
jwks_uri="https://issuer.example.com/jwks",
)
def _service_with_signing_key() -> tuple[OIDCService, object]:
service = OIDCService()
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
service._load_jwks = AsyncMock(return_value={})
service._resolve_signing_key = AsyncMock(return_value=private_key.public_key())
return service, private_key
def _id_token(private_key, nonce: str) -> str:
now = int(time.time())
return jwt.encode(
{"iss": "https://issuer.example.com", "aud": "deer-flow", "sub": "s1", "exp": now + 300, "iat": now, "nonce": nonce},
private_key,
algorithm="RS256",
headers={"kid": "k1"},
)
@pytest.mark.asyncio
async def test_id_token_nonce_mismatch_with_non_ascii_is_rejected_not_a_type_error():
"""The nonce claim is provider-controlled text, so a mismatch must reject
with OIDCValidationError; the raw str comparison raised TypeError instead,
turning the callback's sso_failed redirect into a 500 (#6076 fixed the
same five compare sites and left this one behind)."""
service, private_key = _service_with_signing_key()
with pytest.raises(OIDCValidationError, match="nonce does not match"):
await service.validate_id_token(metadata=_metadata(), client_id="deer-flow", id_token=_id_token(private_key, "nònce-é"), nonce="expected")
@pytest.mark.asyncio
async def test_id_token_nonce_match_with_non_ascii_is_accepted():
"""A matching non-ASCII nonce keeps its bytes: equality survives the comparison."""
service, private_key = _service_with_signing_key()
claims = await service.validate_id_token(metadata=_metadata(), client_id="deer-flow", id_token=_id_token(private_key, "nònce-é"), nonce="nònce-é")
assert claims["nonce"] == "nònce-é"
@pytest.mark.asyncio
async def test_id_token_nonce_claim_of_non_string_type_is_rejected_not_an_attribute_error():
"""A provider returning a truthy non-string nonce claim (e.g. an int) must
reject with OIDCValidationError like every other malformed nonce; reaching
the comparison helper raised AttributeError on .encode() and turned the
callback's sso_failed redirect into a 500."""
service, private_key = _service_with_signing_key()
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"