From 1ca53b4134a9047a5872ebdf71f83af34107a180 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:06:21 -0700 Subject: [PATCH] Add base_authority parameter to AzureProvider for Azure Government support (#2306) --- docs/integrations/azure.mdx | 10 ++ src/fastmcp/server/auth/providers/azure.py | 31 ++++++- tests/server/auth/providers/test_azure.py | 102 +++++++++++++++++++++ 3 files changed, 138 insertions(+), 5 deletions(-) diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index 1c6730f87..90dcfd1bc 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -128,6 +128,7 @@ auth_provider = AzureProvider( # Optional: request additional upstream scopes in the authorize request # additional_authorize_scopes=["User.Read", "offline_access", "openid", "email"], # redirect_path="/auth/callback" # Default value, customize if needed + # base_authority="login.microsoftonline.us" # For Azure Government (default: login.microsoftonline.com) ) mcp = FastMCP(name="Azure Secured App", auth=auth_provider) @@ -315,6 +316,15 @@ Comma-, space-, or JSON-separated list of additional scopes to include in the au Application ID URI used to prefix scopes during authorization. + + +Azure authority base URL. Override this to use Azure Government: + +- `login.microsoftonline.com` - Azure Public Cloud (default) +- `login.microsoftonline.us` - Azure Government + +This setting affects all Azure OAuth endpoints (authorization, token, issuer, JWKS). + Example `.env` file: diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 163585046..4c7cb8359 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -46,6 +46,7 @@ class AzureProviderSettings(BaseSettings): additional_authorize_scopes: list[str] | None = None allowed_client_redirect_uris: list[str] | None = None jwt_signing_key: str | None = None + base_authority: str = "login.microsoftonline.com" @field_validator("required_scopes", mode="before") @classmethod @@ -93,6 +94,7 @@ class AzureProvider(OAuthProxy): from fastmcp import FastMCP from fastmcp.server.auth.providers.azure import AzureProvider + # Standard Azure (Public Cloud) auth = AzureProvider( client_id="your-client-id", client_secret="your-client-secret", @@ -103,6 +105,16 @@ class AzureProvider(OAuthProxy): # identifier_uri defaults to api://{client_id} ) + # Azure Government + auth_gov = AzureProvider( + client_id="your-client-id", + client_secret="your-client-secret", + tenant_id="your-tenant-id", + required_scopes=["read", "write"], + base_authority="login.microsoftonline.us", # Override for Azure Gov + base_url="http://localhost:8000", + ) + mcp = FastMCP("My App", auth=auth) ``` """ @@ -123,6 +135,7 @@ class AzureProvider(OAuthProxy): client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | NotSetT = NotSet, require_authorization_consent: bool = True, + base_authority: str | NotSetT = NotSet, ) -> None: """Initialize Azure OAuth provider. @@ -138,6 +151,8 @@ class AzureProvider(OAuthProxy): issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. redirect_path: Redirect path configured in Azure App registration (defaults to "/auth/callback") + base_authority: Azure authority base URL (defaults to "login.microsoftonline.com"). + For Azure Government, use "login.microsoftonline.us". required_scopes: Custom API scope names WITHOUT prefix (e.g., ["read", "write"]). - Automatically prefixed with identifier_uri during initialization - Validated on all tokens @@ -180,6 +195,7 @@ class AzureProvider(OAuthProxy): "additional_authorize_scopes": additional_authorize_scopes, "allowed_client_redirect_uris": allowed_client_redirect_uris, "jwt_signing_key": jwt_signing_key, + "base_authority": base_authority, }.items() if v is not NotSet } @@ -218,9 +234,10 @@ class AzureProvider(OAuthProxy): tenant_id_final = settings.tenant_id # Always validate tokens against the app's API client ID using JWT - issuer = f"https://login.microsoftonline.com/{tenant_id_final}/v2.0" + base_authority_final = settings.base_authority + issuer = f"https://{base_authority_final}/{tenant_id_final}/v2.0" jwks_uri = ( - f"https://login.microsoftonline.com/{tenant_id_final}/discovery/v2.0/keys" + f"https://{base_authority_final}/{tenant_id_final}/discovery/v2.0/keys" ) # Azure returns unprefixed scopes in JWT tokens, so validate against unprefixed scopes @@ -239,10 +256,10 @@ class AzureProvider(OAuthProxy): # Build Azure OAuth endpoints with tenant authorization_endpoint = ( - f"https://login.microsoftonline.com/{tenant_id_final}/oauth2/v2.0/authorize" + f"https://{base_authority_final}/{tenant_id_final}/oauth2/v2.0/authorize" ) token_endpoint = ( - f"https://login.microsoftonline.com/{tenant_id_final}/oauth2/v2.0/token" + f"https://{base_authority_final}/{tenant_id_final}/oauth2/v2.0/token" ) # Initialize OAuth proxy with Azure endpoints @@ -262,11 +279,15 @@ class AzureProvider(OAuthProxy): require_authorization_consent=require_authorization_consent, ) + authority_info = "" + if base_authority_final != "login.microsoftonline.com": + authority_info = f" using authority {base_authority_final}" logger.info( - "Initialized Azure OAuth provider for client %s with tenant %s%s", + "Initialized Azure OAuth provider for client %s with tenant %s%s%s", settings.client_id, tenant_id_final, f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "", + authority_info, ) async def authorize( diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index b4b7428de..168384eb0 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -385,3 +385,105 @@ class TestAzureProvider: ) assert "Mail.Read" in upstream_url assert "User.Read" in upstream_url + + def test_base_authority_defaults_to_public_cloud(self): + """Test that base_authority defaults to login.microsoftonline.com.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="test-tenant", + required_scopes=["read"], + jwt_signing_key="test-secret", + ) + + assert ( + provider._upstream_authorization_endpoint + == "https://login.microsoftonline.com/test-tenant/oauth2/v2.0/authorize" + ) + assert ( + provider._upstream_token_endpoint + == "https://login.microsoftonline.com/test-tenant/oauth2/v2.0/token" + ) + assert ( + provider._token_validator.issuer # type: ignore[attr-defined] + == "https://login.microsoftonline.com/test-tenant/v2.0" + ) + assert ( + provider._token_validator.jwks_uri # type: ignore[attr-defined] + == "https://login.microsoftonline.com/test-tenant/discovery/v2.0/keys" + ) + + def test_base_authority_azure_government(self): + """Test Azure Government endpoints with login.microsoftonline.us.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="gov-tenant-id", + required_scopes=["read"], + base_authority="login.microsoftonline.us", + jwt_signing_key="test-secret", + ) + + assert ( + provider._upstream_authorization_endpoint + == "https://login.microsoftonline.us/gov-tenant-id/oauth2/v2.0/authorize" + ) + assert ( + provider._upstream_token_endpoint + == "https://login.microsoftonline.us/gov-tenant-id/oauth2/v2.0/token" + ) + assert ( + provider._token_validator.issuer # type: ignore[attr-defined] + == "https://login.microsoftonline.us/gov-tenant-id/v2.0" + ) + assert ( + provider._token_validator.jwks_uri # type: ignore[attr-defined] + == "https://login.microsoftonline.us/gov-tenant-id/discovery/v2.0/keys" + ) + + def test_base_authority_from_environment_variable(self): + """Test that base_authority can be set via environment variable.""" + with patch.dict( + os.environ, + { + "FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID": "env-client-id", + "FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET": "env-secret", + "FASTMCP_SERVER_AUTH_AZURE_TENANT_ID": "env-tenant-id", + "FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES": "read", + "FASTMCP_SERVER_AUTH_AZURE_BASE_AUTHORITY": "login.microsoftonline.us", + "FASTMCP_SERVER_AUTH_AZURE_JWT_SIGNING_KEY": "test-secret", + }, + ): + provider = AzureProvider() + + assert ( + provider._upstream_authorization_endpoint + == "https://login.microsoftonline.us/env-tenant-id/oauth2/v2.0/authorize" + ) + assert ( + provider._upstream_token_endpoint + == "https://login.microsoftonline.us/env-tenant-id/oauth2/v2.0/token" + ) + assert ( + provider._token_validator.issuer # type: ignore[attr-defined] + == "https://login.microsoftonline.us/env-tenant-id/v2.0" + ) + assert ( + provider._token_validator.jwks_uri # type: ignore[attr-defined] + == "https://login.microsoftonline.us/env-tenant-id/discovery/v2.0/keys" + ) + + def test_base_authority_with_special_tenant_values(self): + """Test that base_authority works with special tenant values like 'organizations'.""" + provider = AzureProvider( + client_id="test_client", + client_secret="test_secret", + tenant_id="organizations", + required_scopes=["read"], + base_authority="login.microsoftonline.us", + jwt_signing_key="test-secret", + ) + + parsed = urlparse(provider._upstream_authorization_endpoint) + assert parsed.netloc == "login.microsoftonline.us" + assert "/organizations/" in parsed.path