From b0fb2c3ae6afaddcbe0cb05ac2c1d761d7a5033f Mon Sep 17 00:00:00 2001 From: Tiago Surjus Kaneta <7189039+tiagoskaneta@users.noreply.github.com> Date: Wed, 20 May 2026 10:31:48 -0400 Subject: [PATCH] Add valid_scopes and extra_authorize_params to WorkOSProvider (#4135) --- .../fastmcp/server/auth/providers/workos.py | 14 ++++ tests/server/auth/providers/test_workos.py | 75 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/fastmcp_slim/fastmcp/server/auth/providers/workos.py b/fastmcp_slim/fastmcp/server/auth/providers/workos.py index fb7e969eb..470bfe886 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/workos.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/workos.py @@ -168,6 +168,7 @@ class WorkOSProvider(OAuthProxy): issuer_url: AnyHttpUrl | str | None = None, redirect_path: str | None = None, required_scopes: list[str] | None = None, + valid_scopes: list[str] | None = None, timeout_seconds: int = 10, allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, @@ -176,6 +177,7 @@ class WorkOSProvider(OAuthProxy): consent_csp_policy: str | None = None, forward_resource: bool = True, fallback_refresh_token_expiry_seconds: int | None = None, + extra_authorize_params: dict[str, str] | None = None, http_client: httpx.AsyncClient | None = None, enable_cimd: bool = True, ): @@ -192,6 +194,10 @@ class WorkOSProvider(OAuthProxy): to avoid 404s during discovery when mounting under a path. redirect_path: Redirect path configured in WorkOS (defaults to "/auth/callback") required_scopes: Required OAuth scopes (no default) + valid_scopes: All scopes that clients are allowed to request, advertised through + well-known endpoints. Defaults to required_scopes if not provided. Use this + when you want clients to be able to request additional scopes beyond the + required minimum. timeout_seconds: HTTP request timeout for WorkOS API calls (defaults to 10) allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. @@ -207,6 +213,9 @@ class WorkOSProvider(OAuthProxy): When "external", the built-in consent screen is skipped but no warning is logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. + extra_authorize_params: Additional parameters to forward to WorkOS's authorization endpoint. + Useful for forcing scopes like `offline_access` so WorkOS issues a refresh token, + e.g. ``{"scope": "openid profile email offline_access"}``. http_client: Optional httpx.AsyncClient for connection pooling in token verification. When provided, the client is reused across verify_token calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. @@ -221,6 +230,9 @@ class WorkOSProvider(OAuthProxy): scopes_final = ( parse_scopes(required_scopes) if required_scopes is not None else [] ) + valid_scopes_final = ( + parse_scopes(valid_scopes) if valid_scopes is not None else None + ) # Create WorkOS token verifier token_verifier = WorkOSTokenVerifier( @@ -248,6 +260,8 @@ class WorkOSProvider(OAuthProxy): consent_csp_policy=consent_csp_policy, forward_resource=forward_resource, fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds, + extra_authorize_params=extra_authorize_params, + valid_scopes=valid_scopes_final, enable_cimd=enable_cimd, ) diff --git a/tests/server/auth/providers/test_workos.py b/tests/server/auth/providers/test_workos.py index 2e87956c1..f93fc9265 100644 --- a/tests/server/auth/providers/test_workos.py +++ b/tests/server/auth/providers/test_workos.py @@ -103,6 +103,81 @@ class TestWorkOSProvider: assert provider._redirect_path == "/auth/callback" # WorkOS provider has no default scopes but we can't easily verify without accessing internals + def test_extra_authorize_params_default_none(self, memory_storage: MemoryStore): + """WorkOS doesn't set provider-specific defaults — empty unless caller opts in.""" + provider = WorkOSProvider( + client_id="test_client", + client_secret="test_secret", + authkit_domain="https://test.authkit.app", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + assert provider._extra_authorize_params == {} + + def test_extra_authorize_params_passed_through(self, memory_storage: MemoryStore): + """Caller-supplied params are forwarded to the upstream authorize URL. + + Common use case: force `offline_access` into the scope so WorkOS issues + a refresh token. + """ + provider = WorkOSProvider( + client_id="test_client", + client_secret="test_secret", + authkit_domain="https://test.authkit.app", + base_url="https://myserver.com", + jwt_signing_key="test-secret", + client_storage=memory_storage, + extra_authorize_params={ + "scope": "openid profile email offline_access", + }, + ) + + assert provider._extra_authorize_params == { + "scope": "openid profile email offline_access", + } + + def test_valid_scopes_passed_through(self, memory_storage: MemoryStore): + """valid_scopes is forwarded to OAuthProxy and advertised via DCR.""" + provider = WorkOSProvider( + client_id="test_client", + client_secret="test_secret", + authkit_domain="https://test.authkit.app", + base_url="https://myserver.com", + required_scopes=["openid"], + valid_scopes=["openid", "profile", "email", "offline_access"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + reg_options = provider.client_registration_options + assert reg_options is not None + assert reg_options.valid_scopes is not None + assert set(reg_options.valid_scopes) == { + "openid", + "profile", + "email", + "offline_access", + } + + def test_valid_scopes_defaults_to_required(self, memory_storage: MemoryStore): + """When valid_scopes is omitted, the OAuthProxy falls back to required_scopes.""" + provider = WorkOSProvider( + client_id="test_client", + client_secret="test_secret", + authkit_domain="https://test.authkit.app", + base_url="https://myserver.com", + required_scopes=["openid", "profile"], + jwt_signing_key="test-secret", + client_storage=memory_storage, + ) + + reg_options = provider.client_registration_options + assert reg_options is not None + assert reg_options.valid_scopes is not None + assert set(reg_options.valid_scopes) == {"openid", "profile"} + def test_oauth_endpoints_configured_correctly(self, memory_storage: MemoryStore): """Test that OAuth endpoints are configured correctly.""" provider = WorkOSProvider(