fastmcp/tests/server/auth/test_oidc_proxy.py
Jeremiah Lowin 3a9717e6be
Publish docs for v3.2.0 (#3713)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com>
Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com>
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Co-authored-by: nightcityblade <nightcityblade@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com>
Co-authored-by: Eric Robinson <ericrobinson@indeed.com>
Co-authored-by: Martim Santos <martimfasantos@gmail.com>
Co-authored-by: d 🔹 <liusway405@gmail.com>
Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com>
Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com>
Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com>
Co-authored-by: nightcityblade <jackchen@haloailabs.com>
Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
Co-authored-by: Bill Easton <williamseaston@gmail.com>
Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com>
Co-authored-by: Rushabh Doshi <radoshi@gmail.com>
Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp>
Co-authored-by: Jeremy Simon <simonjer805@gmail.com>
Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com>
Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com>
Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com>
Fix auto-close MRE script posting comment without closing (#3386)
Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407)
Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413)
Fix transform arg collisions with passthrough params (#3431)
Fix get_* returning None when latest version is disabled (#3439)
Fix get_* returning None when latest version is disabled (#3421)
Fix server lifespan overlap teardown (#3415)
Fix $ref output schema object detection regression (#3420)
resolved annotations (#3429)
Fix async partial callables rejected by iscoroutinefunction (#3438)
Fix async partial callables rejected by iscoroutinefunction (#3423)
fix: add version to components (#3458)
fix: use intent-based flag for OIDC scope patch in load_access_token (#3465)
Fixes #3461
fix: normalize Google scope shorthands and surface valid_scopes (#3477)
fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481)
fix: shield lifespan teardown from cancellation (#3480)
fix: forward custom_route endpoints from mounted servers (#3462)
fix updates _get_additional_http_routes() to traverse providers,
Fixes #3457
fix: remove hardcoded version from CLI help text (#3456)
fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468)
fix: task test teardown hanging 5s per test (#3499)
Closes #3498
fix: validate workspace path is a directory before cursor install (#3440)
Fixes #3426
fix: handle re.error from malformed URI templates in build_regex (#3501)
fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503)
fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502)
fix warnings and timeouts (#3504)
close upgrade check issue when build passes (#3505)
Closes #3484
fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507)
fix: prevent path traversal in skill download (#3493)
fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492)
fix: remove unrelated transform and http.py changes from PR scope
fix: remove forced follow_redirects from httpx_client_factory calls (#3496)
fix: stop passing follow_redirects to httpx_client_factory
fix: restore follow_redirects=True for custom httpx client factories
Closes #3509
fix: CSRF double-submit cookie check in consent flow (#3519)
fix: validate server names in install commands (#3522)
fix: use raw strings for regex in pytest.raises match (#3523)
fix: reject refresh tokens used as Bearer access tokens (#3524)
fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495)
fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540)
fix: filter warnings by message in KEY_PREFIX test (#3549)
fix: suppress output schema for ToolResult subclass annotations (#3548)
fix: increase sleep duration in proxy cache tests (#3567)
fix: store absolute token expiry to prevent stale expires_in on reload (#3572)
fix: preserve tool properties named 'title' during schema compression (#3582)
Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589)
Fix app tool routing: visibility check and middleware propagation (#3591)
Fix query parameter serialization to respect OpenAPI explode/style settings (#3595)
Fix dev apps form: union types, textarea support, JSON parsing (#3597)
fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603)
fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609)
fix(docs): correct misleading stateless_http header (#3622)
fix: filesystem provider import machinery (#3626)
Closes #3625 (issues 2, 3, 6)
fix: recover StdioTransport after subprocess exits (#3630)
fix(server): preserve mounted tool task metadata (#3632)
fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649)
fix imports, add PrefabAppConfig (#3650)
fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651)
Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652)
closed by default (#3657)
Fix prompt caching middleware missing wrap/unwrap round-trip (#3666)
fix: serialize object query params per OpenAPI style/explode rules (#3662)
Fixes #2857
fix: HTTP request headers not accessible in background task workers (#3631)
fix: restore HTTP headers in worker execution path for background tasks (#3681)
fix: strip discriminator after dereferencing schemas (#3682)
fix: remove stale ty:ignore directives for ty 0.0.26 (#3684)
Fix docs gaps in app provider pages (#3690)
fix: dev apps log panel UX improvements (#3698)
fix dev server empty string args (#3700)
2026-03-30 16:48:30 -04:00

882 lines
33 KiB
Python

"""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.introspection import IntrospectionTokenVerifier
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 = AnyHttpUrl("https://example.com/.well-known/openid-configuration")
TEST_CLIENT_ID = "test-client-id"
TEST_CLIENT_SECRET = "test-client-secret"
TEST_BASE_URL = AnyHttpUrl("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://<tenant>.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_explicit_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_configuration 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=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 str(call_args[0][0]) == str(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=TEST_CONFIG_URL,
strict=False,
timeout_seconds=10,
)
mock_get.assert_called_once()
call_args = mock_get.call_args
assert str(call_args[0][0]) == str(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]) == str(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 is not None
assert proxy._upstream_client_secret.get_secret_value() == TEST_CLIENT_SECRET
assert str(proxy.base_url) == str(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,
jwt_signing_key="test-secret",
)
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,
jwt_signing_key="test-secret",
)
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"],
jwt_signing_key="test-secret",
)
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",
jwt_signing_key="test-secret",
)
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",
jwt_signing_key="test-secret",
)
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,
jwt_signing_key="test-secret",
)
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="Either client_secret or jwt_signing_key must be provided",
):
OIDCProxy(
config_url=TEST_CONFIG_URL,
client_id=TEST_CLIENT_ID,
client_secret=None,
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
)
def test_custom_token_verifier_initialization(self, valid_oidc_configuration_dict):
"""Test initialization with custom token verifier."""
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
# Create custom verifier for opaque tokens
custom_verifier = IntrospectionTokenVerifier(
introspection_url="https://example.com/oauth/introspect",
client_id="introspection-client",
client_secret="introspection-secret",
required_scopes=["custom", "scopes"],
)
proxy = OIDCProxy(
config_url=TEST_CONFIG_URL,
client_id=TEST_CLIENT_ID,
client_secret=TEST_CLIENT_SECRET,
base_url=TEST_BASE_URL,
token_verifier=custom_verifier,
jwt_signing_key="test-secret",
)
validate_proxy(mock_get, proxy, oidc_config)
# Verify the custom verifier is used
assert proxy._token_validator is custom_verifier
assert isinstance(proxy._token_validator, IntrospectionTokenVerifier)
# Verify required_scopes are properly loaded from the custom verifier
assert proxy.required_scopes == ["custom", "scopes"]
def test_custom_token_verifier_with_algorithm_raises_error(
self, valid_oidc_configuration_dict
):
"""Test that providing algorithm with custom verifier raises error."""
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
custom_verifier = IntrospectionTokenVerifier(
introspection_url="https://example.com/oauth/introspect",
client_id="introspection-client",
client_secret="introspection-secret",
)
with pytest.raises(
ValueError,
match="Cannot specify 'algorithm' when providing a custom token_verifier",
):
OIDCProxy(
config_url=TEST_CONFIG_URL,
client_id=TEST_CLIENT_ID,
client_secret=TEST_CLIENT_SECRET,
base_url=TEST_BASE_URL,
token_verifier=custom_verifier,
algorithm="RS256", # This should cause an error
jwt_signing_key="test-secret",
)
def test_custom_token_verifier_with_required_scopes_raises_error(
self, valid_oidc_configuration_dict
):
"""Test that providing required_scopes with custom verifier raises error."""
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
custom_verifier = IntrospectionTokenVerifier(
introspection_url="https://example.com/oauth/introspect",
client_id="introspection-client",
client_secret="introspection-secret",
)
with pytest.raises(
ValueError,
match="Cannot specify 'required_scopes' when providing a custom token_verifier",
):
OIDCProxy(
config_url=TEST_CONFIG_URL,
client_id=TEST_CLIENT_ID,
client_secret=TEST_CLIENT_SECRET,
base_url=TEST_BASE_URL,
token_verifier=custom_verifier,
required_scopes=["read", "write"], # This should cause an error
jwt_signing_key="test-secret",
)
def test_custom_token_verifier_with_audience_allowed(
self, valid_oidc_configuration_dict
):
"""Test that providing audience with custom verifier is allowed (for OAuth flow)."""
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
custom_verifier = IntrospectionTokenVerifier(
introspection_url="https://example.com/oauth/introspect",
client_id="introspection-client",
client_secret="introspection-secret",
)
# This should NOT raise an error - audience is for OAuth flow
proxy = OIDCProxy(
config_url=TEST_CONFIG_URL,
client_id=TEST_CLIENT_ID,
client_secret=TEST_CLIENT_SECRET,
base_url=TEST_BASE_URL,
token_verifier=custom_verifier,
audience="test-audience", # Should be allowed for OAuth flow
jwt_signing_key="test-secret",
)
validate_proxy(mock_get, proxy, oidc_config)
assert proxy._extra_authorize_params == {"audience": "test-audience"}
assert proxy._extra_token_params == {"audience": "test-audience"}
def test_extra_authorize_params_initialization(self, valid_oidc_configuration_dict):
"""Test extra authorize params 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,
jwt_signing_key="test-secret",
extra_authorize_params={
"prompt": "consent",
"access_type": "offline",
},
)
validate_proxy(mock_get, proxy, oidc_config)
assert proxy._extra_authorize_params == {
"prompt": "consent",
"access_type": "offline",
}
# Token params should be empty since we didn't set them
assert proxy._extra_token_params == {}
def test_extra_token_params_initialization(self, valid_oidc_configuration_dict):
"""Test extra token params 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,
jwt_signing_key="test-secret",
extra_token_params={"custom_param": "custom_value"},
)
validate_proxy(mock_get, proxy, oidc_config)
# Authorize params should be empty since we didn't set them
assert proxy._extra_authorize_params == {}
assert proxy._extra_token_params == {"custom_param": "custom_value"}
def test_extra_params_merge_with_audience(self, valid_oidc_configuration_dict):
"""Test that extra params merge with audience, with user params taking precedence."""
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="original-audience",
jwt_signing_key="test-secret",
extra_authorize_params={
"prompt": "consent",
"audience": "overridden-audience", # Should override the audience param
},
extra_token_params={"custom": "value"},
)
validate_proxy(mock_get, proxy, oidc_config)
# User's extra_authorize_params should override audience
assert proxy._extra_authorize_params == {
"audience": "overridden-audience",
"prompt": "consent",
}
# Token params should have both audience (from audience param) and custom
assert proxy._extra_token_params == {
"audience": "original-audience",
"custom": "value",
}