Skip to content

Commit 3ff1a95

Browse files
Merge branch 'main' into feat/keycloak-auth-provider
2 parents ceeca60 + b3bce0c commit 3ff1a95

File tree

5 files changed

+182
-6
lines changed

5 files changed

+182
-6
lines changed

.github/workflows/martian-test-failure.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Marvin Test Failure Analysis
22

33
on:
44
workflow_run:
5-
workflows: ["Run Tests"]
5+
workflows: ["Tests", "Run static analysis"]
66
types:
77
- completed
88

@@ -23,7 +23,7 @@ jobs:
2323
actions: read # Required for Claude to read CI results
2424
steps:
2525
- name: Checkout repository
26-
uses: actions/checkout@v4
26+
uses: actions/checkout@v5
2727
with:
2828
fetch-depth: 1
2929

@@ -35,7 +35,7 @@ jobs:
3535
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
3636

3737
- name: Set up Python 3.10
38-
uses: actions/setup-python@v5
38+
uses: actions/setup-python@v6
3939
with:
4040
python-version: "3.10"
4141

src/fastmcp/server/auth/oidc_proxy.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,9 @@ def __init__(
222222
token_endpoint_auth_method: str | None = None,
223223
# Consent screen configuration
224224
require_authorization_consent: bool = True,
225+
# Extra parameters
226+
extra_authorize_params: dict[str, str] | None = None,
227+
extra_token_params: dict[str, str] | None = None,
225228
) -> None:
226229
"""Initialize the OIDC proxy provider.
227230
@@ -259,6 +262,11 @@ def __init__(
259262
When True, users see a consent screen before being redirected to the upstream IdP.
260263
When False, authorization proceeds directly without user confirmation.
261264
SECURITY WARNING: Only disable for local development or testing environments.
265+
extra_authorize_params: Additional parameters to forward to the upstream authorization endpoint.
266+
Useful for provider-specific parameters like prompt=consent or access_type=offline.
267+
Example: {"prompt": "consent", "access_type": "offline"}
268+
extra_token_params: Additional parameters to forward to the upstream token endpoint.
269+
Useful for provider-specific parameters during token exchange.
262270
"""
263271
if not config_url:
264272
raise ValueError("Missing required config URL")
@@ -335,10 +343,24 @@ def __init__(
335343
if redirect_path:
336344
init_kwargs["redirect_path"] = redirect_path
337345

346+
# Build extra params, merging audience with user-provided params
347+
# User params override audience if there's a conflict
348+
final_authorize_params: dict[str, str] = {}
349+
final_token_params: dict[str, str] = {}
350+
338351
if audience:
339-
extra_params = {"audience": audience}
340-
init_kwargs["extra_authorize_params"] = extra_params
341-
init_kwargs["extra_token_params"] = extra_params
352+
final_authorize_params["audience"] = audience
353+
final_token_params["audience"] = audience
354+
355+
if extra_authorize_params:
356+
final_authorize_params.update(extra_authorize_params)
357+
if extra_token_params:
358+
final_token_params.update(extra_token_params)
359+
360+
if final_authorize_params:
361+
init_kwargs["extra_authorize_params"] = final_authorize_params
362+
if final_token_params:
363+
init_kwargs["extra_token_params"] = final_token_params
342364

343365
super().__init__(**init_kwargs) # ty: ignore[invalid-argument-type]
344366

src/fastmcp/server/auth/providers/google.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ def __init__(
225225
client_storage: AsyncKeyValue | None = None,
226226
jwt_signing_key: str | bytes | NotSetT = NotSet,
227227
require_authorization_consent: bool = True,
228+
extra_authorize_params: dict[str, str] | None = None,
228229
):
229230
"""Initialize Google OAuth provider.
230231
@@ -252,6 +253,10 @@ def __init__(
252253
When True, users see a consent screen before being redirected to Google.
253254
When False, authorization proceeds directly without user confirmation.
254255
SECURITY WARNING: Only disable for local development or testing environments.
256+
extra_authorize_params: Additional parameters to forward to Google's authorization endpoint.
257+
By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure
258+
refresh tokens are returned. You can override these defaults or add additional parameters.
259+
Example: {"prompt": "select_account"} to let users choose their Google account.
255260
"""
256261

257262
settings = GoogleProviderSettings.model_validate(
@@ -299,6 +304,18 @@ def __init__(
299304
settings.client_secret.get_secret_value() if settings.client_secret else ""
300305
)
301306

307+
# Set Google-specific defaults for extra authorize params
308+
# access_type=offline ensures refresh tokens are returned
309+
# prompt=consent forces consent screen to get refresh token (Google only issues on first auth otherwise)
310+
google_defaults = {
311+
"access_type": "offline",
312+
"prompt": "consent",
313+
}
314+
# User-provided params override defaults
315+
if extra_authorize_params:
316+
google_defaults.update(extra_authorize_params)
317+
extra_authorize_params_final = google_defaults
318+
302319
# Initialize OAuth proxy with Google endpoints
303320
super().__init__(
304321
upstream_authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth",
@@ -314,6 +331,7 @@ def __init__(
314331
client_storage=client_storage,
315332
jwt_signing_key=settings.jwt_signing_key,
316333
require_authorization_consent=require_authorization_consent,
334+
extra_authorize_params=extra_authorize_params_final,
317335
)
318336

319337
logger.debug(

tests/server/auth/providers/test_google.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,46 @@ def test_google_specific_scopes(self):
119119

120120
# Provider should initialize successfully with these scopes
121121
assert provider is not None
122+
123+
def test_extra_authorize_params_defaults(self):
124+
"""Test that Google-specific defaults are set for refresh token support."""
125+
provider = GoogleProvider(
126+
client_id="123456789.apps.googleusercontent.com",
127+
client_secret="GOCSPX-test123",
128+
jwt_signing_key="test-secret",
129+
)
130+
131+
# Should have Google-specific defaults for refresh token support
132+
assert provider._extra_authorize_params == {
133+
"access_type": "offline",
134+
"prompt": "consent",
135+
}
136+
137+
def test_extra_authorize_params_override_defaults(self):
138+
"""Test that user can override default extra authorize params."""
139+
provider = GoogleProvider(
140+
client_id="123456789.apps.googleusercontent.com",
141+
client_secret="GOCSPX-test123",
142+
jwt_signing_key="test-secret",
143+
extra_authorize_params={"prompt": "select_account"},
144+
)
145+
146+
# User override should replace the default
147+
assert provider._extra_authorize_params["prompt"] == "select_account"
148+
# But other defaults should remain
149+
assert provider._extra_authorize_params["access_type"] == "offline"
150+
151+
def test_extra_authorize_params_add_new_params(self):
152+
"""Test that user can add additional authorize params."""
153+
provider = GoogleProvider(
154+
client_id="123456789.apps.googleusercontent.com",
155+
client_secret="GOCSPX-test123",
156+
jwt_signing_key="test-secret",
157+
extra_authorize_params={"login_hint": "[email protected]"},
158+
)
159+
160+
# New param should be added
161+
assert provider._extra_authorize_params["login_hint"] == "[email protected]"
162+
# Defaults should still be present
163+
assert provider._extra_authorize_params["access_type"] == "offline"
164+
assert provider._extra_authorize_params["prompt"] == "consent"

tests/server/auth/test_oidc_proxy.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,3 +783,96 @@ def test_custom_token_verifier_with_audience_allowed(
783783
validate_proxy(mock_get, proxy, oidc_config)
784784
assert proxy._extra_authorize_params == {"audience": "test-audience"}
785785
assert proxy._extra_token_params == {"audience": "test-audience"}
786+
787+
def test_extra_authorize_params_initialization(self, valid_oidc_configuration_dict):
788+
"""Test extra authorize params initialization."""
789+
with patch(
790+
"fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration"
791+
) as mock_get:
792+
oidc_config = OIDCConfiguration.model_validate(
793+
valid_oidc_configuration_dict
794+
)
795+
mock_get.return_value = oidc_config
796+
797+
proxy = OIDCProxy(
798+
config_url=TEST_CONFIG_URL,
799+
client_id=TEST_CLIENT_ID,
800+
client_secret=TEST_CLIENT_SECRET,
801+
base_url=TEST_BASE_URL,
802+
jwt_signing_key="test-secret",
803+
extra_authorize_params={
804+
"prompt": "consent",
805+
"access_type": "offline",
806+
},
807+
)
808+
809+
validate_proxy(mock_get, proxy, oidc_config)
810+
811+
assert proxy._extra_authorize_params == {
812+
"prompt": "consent",
813+
"access_type": "offline",
814+
}
815+
# Token params should be empty since we didn't set them
816+
assert proxy._extra_token_params == {}
817+
818+
def test_extra_token_params_initialization(self, valid_oidc_configuration_dict):
819+
"""Test extra token params initialization."""
820+
with patch(
821+
"fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration"
822+
) as mock_get:
823+
oidc_config = OIDCConfiguration.model_validate(
824+
valid_oidc_configuration_dict
825+
)
826+
mock_get.return_value = oidc_config
827+
828+
proxy = OIDCProxy(
829+
config_url=TEST_CONFIG_URL,
830+
client_id=TEST_CLIENT_ID,
831+
client_secret=TEST_CLIENT_SECRET,
832+
base_url=TEST_BASE_URL,
833+
jwt_signing_key="test-secret",
834+
extra_token_params={"custom_param": "custom_value"},
835+
)
836+
837+
validate_proxy(mock_get, proxy, oidc_config)
838+
839+
# Authorize params should be empty since we didn't set them
840+
assert proxy._extra_authorize_params == {}
841+
assert proxy._extra_token_params == {"custom_param": "custom_value"}
842+
843+
def test_extra_params_merge_with_audience(self, valid_oidc_configuration_dict):
844+
"""Test that extra params merge with audience, with user params taking precedence."""
845+
with patch(
846+
"fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration"
847+
) as mock_get:
848+
oidc_config = OIDCConfiguration.model_validate(
849+
valid_oidc_configuration_dict
850+
)
851+
mock_get.return_value = oidc_config
852+
853+
proxy = OIDCProxy(
854+
config_url=TEST_CONFIG_URL,
855+
client_id=TEST_CLIENT_ID,
856+
client_secret=TEST_CLIENT_SECRET,
857+
base_url=TEST_BASE_URL,
858+
audience="original-audience",
859+
jwt_signing_key="test-secret",
860+
extra_authorize_params={
861+
"prompt": "consent",
862+
"audience": "overridden-audience", # Should override the audience param
863+
},
864+
extra_token_params={"custom": "value"},
865+
)
866+
867+
validate_proxy(mock_get, proxy, oidc_config)
868+
869+
# User's extra_authorize_params should override audience
870+
assert proxy._extra_authorize_params == {
871+
"audience": "overridden-audience",
872+
"prompt": "consent",
873+
}
874+
# Token params should have both audience (from audience param) and custom
875+
assert proxy._extra_token_params == {
876+
"audience": "original-audience",
877+
"custom": "value",
878+
}

0 commit comments

Comments
 (0)