Files
2026-07-13 11:59:58 +08:00

408 lines
14 KiB
Python

from unittest.mock import AsyncMock
import pytest
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
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"