mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* 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>
827 lines
32 KiB
Python
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"
|