deer-flow/backend/tests/test_oidc_auth.py
jswSpace 62a5b6cc26
fix(auth): preserve OIDC authorization endpoint query parameters (#6831)
* fix(auth): preserve OIDC authorization endpoint query parameters

* docs(auth): keep updated guidance within size budget

* docs(auth): clarify OIDC endpoint query configuration

---------

Co-authored-by: jswSpace <198684035+jswSpace@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-14 08:19:22 +08:00

608 lines
23 KiB
Python

import time
from unittest.mock import AsyncMock
from urllib.parse import parse_qs, urlsplit
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi import HTTPException
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")