"""Comprehensive tests for OIDC Proxy Provider functionality.""" import json from unittest.mock import MagicMock, patch import pytest from httpx import Response from pydantic import AnyHttpUrl from fastmcp.server.auth.oidc_proxy import OIDCConfiguration, OIDCProxy from fastmcp.server.auth.providers.jwt import JWTVerifier TEST_ISSUER = "https://example.com" TEST_AUTHORIZATION_ENDPOINT = "https://example.com/authorize" TEST_TOKEN_ENDPOINT = "https://example.com/oauth/token" TEST_CONFIG_URL = "https://example.com/.well-known/openid-configuration" TEST_CLIENT_ID = "test-client-id" TEST_CLIENT_SECRET = "test-client-secret" TEST_BASE_URL = "https://example.com:8000/" # ============================================================================= # Test Fixtures # ============================================================================= @pytest.fixture def valid_oidc_configuration_dict(): """Create a valid OIDC configuration dict for testing.""" return { "issuer": TEST_ISSUER, "authorization_endpoint": TEST_AUTHORIZATION_ENDPOINT, "token_endpoint": TEST_TOKEN_ENDPOINT, "jwks_uri": "https://example.com/.well-known/jwks.json", "response_types_supported": ["code"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256"], } @pytest.fixture def invalid_oidc_configuration_dict(): """Create an invalid OIDC configuration dict for testing.""" return { "issuer": TEST_ISSUER, "authorization_endpoint": TEST_AUTHORIZATION_ENDPOINT, "token_endpoint": TEST_TOKEN_ENDPOINT, "jwks_uri": "https://example.com/.well-known/jwks.json", } @pytest.fixture def valid_google_oidc_configuration_dict(): """Create a valid Google OIDC configuration dict for testing. See: https://accounts.google.com/.well-known/openid-configuration """ google_config_str = """ { "issuer": "https://accounts.google.com", "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth", "device_authorization_endpoint": "https://oauth2.googleapis.com/device/code", "token_endpoint": "https://oauth2.googleapis.com/token", "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo", "revocation_endpoint": "https://oauth2.googleapis.com/revoke", "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs", "response_types_supported": [ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token", "none" ], "response_modes_supported": [ "query", "fragment", "form_post" ], "subject_types_supported": [ "public" ], "id_token_signing_alg_values_supported": [ "RS256" ], "scopes_supported": [ "openid", "email", "profile" ], "token_endpoint_auth_methods_supported": [ "client_secret_post", "client_secret_basic" ], "claims_supported": [ "aud", "email", "email_verified", "exp", "family_name", "given_name", "iat", "iss", "name", "picture", "sub" ], "code_challenge_methods_supported": [ "plain", "S256" ], "grant_types_supported": [ "authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", "urn:ietf:params:oauth:grant-type:jwt-bearer" ] } """ return json.loads(google_config_str) @pytest.fixture def valid_auth0_oidc_configuration_dict(): """Create a valid Auth0 OIDC configuration dict for testing. See: https://.us.auth0.com/.well-known/openid-configuration """ auth0_config_str = """ { "issuer": "https://example.us.auth0.com/", "authorization_endpoint": "https://example.us.auth0.com/authorize", "token_endpoint": "https://example.us.auth0.com/oauth/token", "device_authorization_endpoint": "https://example.us.auth0.com/oauth/device/code", "userinfo_endpoint": "https://example.us.auth0.com/userinfo", "mfa_challenge_endpoint": "https://example.us.auth0.com/mfa/challenge", "jwks_uri": "https://example.us.auth0.com/.well-known/jwks.json", "registration_endpoint": "https://example.us.auth0.com/oidc/register", "revocation_endpoint": "https://example.us.auth0.com/oauth/revoke", "scopes_supported": [ "openid", "profile", "offline_access", "name", "given_name", "family_name", "nickname", "email", "email_verified", "picture", "created_at", "identities", "phone", "address" ], "response_types_supported": [ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" ], "code_challenge_methods_supported": [ "S256", "plain" ], "response_modes_supported": [ "query", "fragment", "form_post" ], "subject_types_supported": [ "public" ], "token_endpoint_auth_methods_supported": [ "client_secret_basic", "client_secret_post", "private_key_jwt", "tls_client_auth", "self_signed_tls_client_auth" ], "token_endpoint_auth_signing_alg_values_supported": [ "RS256", "RS384", "PS256" ], "claims_supported": [ "aud", "auth_time", "created_at", "email", "email_verified", "exp", "family_name", "given_name", "iat", "identities", "iss", "name", "nickname", "phone_number", "picture", "sub" ], "request_uri_parameter_supported": false, "request_parameter_supported": true, "id_token_signing_alg_values_supported": [ "HS256", "RS256", "PS256" ], "tls_client_certificate_bound_access_tokens": true, "request_object_signing_alg_values_supported": [ "RS256", "RS384", "PS256" ], "backchannel_logout_supported": true, "backchannel_logout_session_supported": true, "end_session_endpoint": "https://example.us.auth0.com/oidc/logout", "backchannel_authentication_endpoint": "https://example.us.auth0.com/bc-authorize", "backchannel_token_delivery_modes_supported": [ "poll" ], "global_token_revocation_endpoint": "https://example.us.auth0.com/oauth/global-token-revocation/connection/{connectionName}", "global_token_revocation_endpoint_auth_methods_supported": [ "global-token-revocation+jwt" ] } """ return json.loads(auth0_config_str) # ============================================================================= # Test Classes # ============================================================================= def validate_config(config, source_dict): """Validate an OIDC configuration against the source dict.""" for source_key, source_value in source_dict.items(): config_value = getattr(config, source_key, None) if not hasattr(config, source_key): continue config_value = getattr(config, source_key, None) if isinstance(config_value, AnyHttpUrl): config_value = str(config_value) assert config_value == source_value class TestOIDCConfiguration: """Tests for OIDC configuration.""" def test_default_configuration(self, valid_oidc_configuration_dict): """Test default configuration with valid dict.""" config = OIDCConfiguration.model_validate(valid_oidc_configuration_dict) validate_config(config, valid_oidc_configuration_dict) def test_default_configuration_with_issuer_trailing_slash( self, valid_oidc_configuration_dict ): """Test default configuration with valid dict and issuer trailing slash.""" valid_oidc_configuration_dict["issuer"] += "/" config = OIDCConfiguration.model_validate(valid_oidc_configuration_dict) validate_config(config, valid_oidc_configuration_dict) def test_explicit_strict_configuration(self, valid_oidc_configuration_dict): """Test default configuration with explicit True strict setting and valid dict.""" valid_oidc_configuration_dict["strict"] = True config = OIDCConfiguration.model_validate(valid_oidc_configuration_dict) validate_config(config, valid_oidc_configuration_dict) def test_explicit_strict_configuration_with_issuer_trailing_slash( self, valid_oidc_configuration_dict ): """Test default configuration with explicit True strict setting, valid dict and issuer trailing slash.""" valid_oidc_configuration_dict["issuer"] += "/" config = OIDCConfiguration.model_validate(valid_oidc_configuration_dict) validate_config(config, valid_oidc_configuration_dict) def test_default_configuration_raises_error(self, invalid_oidc_configuration_dict): """Test default configuration with invalid dict.""" with pytest.raises(ValueError, match="Missing required configuration metadata"): OIDCConfiguration.model_validate(invalid_oidc_configuration_dict) def test_explicit_strict_configuration_raises_error( self, invalid_oidc_configuration_dict ): """Test default configuration with explicit True strict setting and invalid dict.""" invalid_oidc_configuration_dict["strict"] = True with pytest.raises(ValueError, match="Missing required configuration metadata"): OIDCConfiguration.model_validate(invalid_oidc_configuration_dict) def test_bad_url_raises_error(self, valid_oidc_configuration_dict): """Test default configuration with bad URL setting.""" valid_oidc_configuration_dict["issuer"] = "not-a-URL" with pytest.raises(ValueError, match="Invalid URL for configuration metadata"): OIDCConfiguration.model_validate(valid_oidc_configuration_dict) def test_explict_strict_with_bad_url_raises_error( self, valid_oidc_configuration_dict ): """Test default configuration with explicit True strict setting and bad URL setting.""" valid_oidc_configuration_dict["strict"] = True valid_oidc_configuration_dict["issuer"] = "not-a-URL" with pytest.raises(ValueError, match="Invalid URL for configuration metadata"): OIDCConfiguration.model_validate(valid_oidc_configuration_dict) def test_not_strict_configuration(self): """Test default configuration with explicit False strict setting.""" config = OIDCConfiguration.model_validate({"strict": False}) assert config.issuer is None assert config.authorization_endpoint is None assert config.token_endpoint is None assert config.jwks_uri is None assert config.response_types_supported is None assert config.subject_types_supported is None assert config.id_token_signing_alg_values_supported is None def test_not_strict_configuration_with_invalid_config( self, invalid_oidc_configuration_dict ): """Test default configuration with explicit False strict setting.""" invalid_oidc_configuration_dict["strict"] = False config = OIDCConfiguration.model_validate(invalid_oidc_configuration_dict) validate_config(config, invalid_oidc_configuration_dict) def test_not_strict_configuration_with_bad_url(self, valid_oidc_configuration_dict): """Test default configuration with explicit False strict setting.""" valid_oidc_configuration_dict["strict"] = False valid_oidc_configuration_dict["issuer"] = "not-a-url" config = OIDCConfiguration.model_validate(valid_oidc_configuration_dict) validate_config(config, valid_oidc_configuration_dict) def test_google_configuration(self, valid_google_oidc_configuration_dict): """Test Google configuration.""" config = OIDCConfiguration.model_validate(valid_google_oidc_configuration_dict) validate_config(config, valid_google_oidc_configuration_dict) def test_auth0_configuration(self, valid_auth0_oidc_configuration_dict): """Test Auth0 configuration.""" config = OIDCConfiguration.model_validate(valid_auth0_oidc_configuration_dict) validate_config(config, valid_auth0_oidc_configuration_dict) def validate_get_oidc_configuration(oidc_configuration, strict, timeout_seconds): """Validate get_oidc_configuation call.""" with patch("httpx.get") as mock_get: mock_response = MagicMock(spec=Response) mock_response.json.return_value = oidc_configuration mock_get.return_value = mock_response config = OIDCConfiguration.get_oidc_configuration( config_url=AnyHttpUrl(TEST_CONFIG_URL), strict=strict, timeout_seconds=timeout_seconds, ) validate_config(config, oidc_configuration) mock_get.assert_called_once() call_args = mock_get.call_args assert call_args[0][0] == TEST_CONFIG_URL return call_args class TestGetOIDCConfiguration: """Tests for getting OIDC configuration.""" def test_get_oidc_configuration(self, valid_oidc_configuration_dict): """Test with valid response and explicit timeout.""" call_args = validate_get_oidc_configuration( valid_oidc_configuration_dict, True, 10 ) assert call_args[1]["timeout"] == 10 def test_get_oidc_configuration_no_timeout(self, valid_oidc_configuration_dict): """Test with valid response and no timeout.""" call_args = validate_get_oidc_configuration( valid_oidc_configuration_dict, True, None ) assert "timeout" not in call_args[1] def test_get_oidc_configuration_raises_error( self, invalid_oidc_configuration_dict ) -> None: """Test with invalid response.""" with pytest.raises(ValueError, match="Missing required configuration metadata"): validate_get_oidc_configuration(invalid_oidc_configuration_dict, True, 10) def test_get_oidc_configuration_not_strict( self, invalid_oidc_configuration_dict ) -> None: """Test with invalid response and strict set to False.""" with patch("httpx.get") as mock_get: mock_response = MagicMock(spec=Response) mock_response.json.return_value = invalid_oidc_configuration_dict mock_get.return_value = mock_response OIDCConfiguration.get_oidc_configuration( config_url=AnyHttpUrl(TEST_CONFIG_URL), strict=False, timeout_seconds=10, ) mock_get.assert_called_once() call_args = mock_get.call_args assert call_args[0][0] == TEST_CONFIG_URL def validate_proxy(mock_get, proxy, oidc_config): """Validate OIDC proxy.""" mock_get.assert_called_once() call_args = mock_get.call_args assert str(call_args[0][0]) == TEST_CONFIG_URL assert proxy._upstream_authorization_endpoint == TEST_AUTHORIZATION_ENDPOINT assert proxy._upstream_token_endpoint == TEST_TOKEN_ENDPOINT assert proxy._upstream_client_id == TEST_CLIENT_ID assert proxy._upstream_client_secret.get_secret_value() == TEST_CLIENT_SECRET assert str(proxy.base_url) == TEST_BASE_URL assert proxy.oidc_config == oidc_config class TestOIDCProxyInitialization: """Tests for OIDC proxy initialization.""" def test_default_initialization(self, valid_oidc_configuration_dict): """Test default initialization.""" with patch( "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" ) as mock_get: oidc_config = OIDCConfiguration.model_validate( valid_oidc_configuration_dict ) mock_get.return_value = oidc_config proxy = OIDCProxy( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, base_url=TEST_BASE_URL, ) validate_proxy(mock_get, proxy, oidc_config) def test_timeout_seconds_initialization(self, valid_oidc_configuration_dict): """Test timeout seconds initialization.""" with patch( "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" ) as mock_get: oidc_config = OIDCConfiguration.model_validate( valid_oidc_configuration_dict ) mock_get.return_value = oidc_config proxy = OIDCProxy( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, base_url=TEST_BASE_URL, timeout_seconds=12, ) validate_proxy(mock_get, proxy, oidc_config) call_args = mock_get.call_args assert call_args[1]["timeout_seconds"] == 12 def test_token_verifier_initialization(self, valid_oidc_configuration_dict): """Test token verifier initialization.""" with patch( "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" ) as mock_get: oidc_config = OIDCConfiguration.model_validate( valid_oidc_configuration_dict ) mock_get.return_value = oidc_config proxy = OIDCProxy( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, base_url=TEST_BASE_URL, algorithm="RS256", audience="oidc-proxy-test-audience", required_scopes=["required", "scopes"], ) validate_proxy(mock_get, proxy, oidc_config) assert isinstance(proxy._token_validator, JWTVerifier) assert proxy._token_validator.algorithm == "RS256" assert proxy._token_validator.audience == "oidc-proxy-test-audience" assert proxy._token_validator.required_scopes == ["required", "scopes"] def test_extra_parameters_initialization(self, valid_oidc_configuration_dict): """Test other parameters initialization.""" with patch( "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" ) as mock_get: oidc_config = OIDCConfiguration.model_validate( valid_oidc_configuration_dict ) mock_get.return_value = oidc_config proxy = OIDCProxy( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, base_url=TEST_BASE_URL, audience="oidc-proxy-test-audience", ) validate_proxy(mock_get, proxy, oidc_config) assert proxy._extra_authorize_params == { "audience": "oidc-proxy-test-audience" } assert proxy._extra_token_params == {"audience": "oidc-proxy-test-audience"} def test_other_parameters_initialization(self, valid_oidc_configuration_dict): """Test other parameters initialization.""" with patch( "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" ) as mock_get: oidc_config = OIDCConfiguration.model_validate( valid_oidc_configuration_dict ) mock_get.return_value = oidc_config proxy = OIDCProxy( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, base_url=TEST_BASE_URL, redirect_path="/oidc/proxy", allowed_client_redirect_uris=["http://localhost:*"], token_endpoint_auth_method="client_secret_post", ) validate_proxy(mock_get, proxy, oidc_config) assert proxy._redirect_path == "/oidc/proxy" assert proxy._allowed_client_redirect_uris == ["http://localhost:*"] assert proxy._token_endpoint_auth_method == "client_secret_post" def test_no_config_url_initialization_raises_error( self, valid_oidc_configuration_dict ): """Test no config URL initialization.""" with patch( "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" ) as mock_get: oidc_config = OIDCConfiguration.model_validate( valid_oidc_configuration_dict ) mock_get.return_value = oidc_config with pytest.raises(ValueError, match="Missing required config URL"): OIDCProxy( config_url=None, # type: ignore client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, base_url=TEST_BASE_URL, ) def test_no_client_id_initialization_raises_error( self, valid_oidc_configuration_dict ): """Test no client id initialization.""" with patch( "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" ) as mock_get: oidc_config = OIDCConfiguration.model_validate( valid_oidc_configuration_dict ) mock_get.return_value = oidc_config with pytest.raises(ValueError, match="Missing required client id"): OIDCProxy( config_url=TEST_CONFIG_URL, client_id=None, # type: ignore client_secret=TEST_CLIENT_SECRET, base_url=TEST_BASE_URL, ) def test_no_client_secret_initialization_raises_error( self, valid_oidc_configuration_dict ): """Test no client secret initialization.""" with patch( "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" ) as mock_get: oidc_config = OIDCConfiguration.model_validate( valid_oidc_configuration_dict ) mock_get.return_value = oidc_config with pytest.raises(ValueError, match="Missing required client secret"): OIDCProxy( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=None, # type: ignore base_url=TEST_BASE_URL, ) def test_no_base_url_initialization_raises_error( self, valid_oidc_configuration_dict ): """Test no base URL initialization.""" with patch( "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" ) as mock_get: oidc_config = OIDCConfiguration.model_validate( valid_oidc_configuration_dict ) mock_get.return_value = oidc_config with pytest.raises(ValueError, match="Missing required base URL"): OIDCProxy( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, base_url=None, # type: ignore )