From b076978baecd39cfd37f259a266366680601545a Mon Sep 17 00:00:00 2001 From: ryo Date: Mon, 13 Oct 2025 21:28:03 -0700 Subject: [PATCH 01/17] draft update to separate the /Users/ryo for mac --- .env.example | 5 +++++ docker-compose.yml | 24 ++++++++++++------------ registry/core/nginx_service.py | 8 +++++++- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 5722169c..ba6e2175 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,11 @@ # Copy this file to .env and update with your actual values # Never commit real credentials to version control +# ============================================================================= +# DOCKER CONFIGURATION +# ============================================================================= +APP_HOME=/opt + # ============================================================================= # REGISTRY CONFIGURATION # ============================================================================= diff --git a/docker-compose.yml b/docker-compose.yml index 5a507269..35691715 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,11 +39,11 @@ services: - "443:443" - "7860:7860" volumes: - - ${HOME}/mcp-gateway/servers:/app/registry/servers - - ${HOME}/mcp-gateway/models:/app/registry/models - - ${HOME}/mcp-gateway/logs:/app/logs - - ${HOME}/mcp-gateway/auth_server/scopes.yml:/app/auth_server/scopes.yml - - ${HOME}/mcp-gateway/ssl:/etc/ssl:ro + - ${APP_HOME}/mcp-gateway/servers:/app/registry/servers + - ${APP_HOME}/mcp-gateway/models:/app/registry/models + - ${APP_HOME}/mcp-gateway/logs:/app/logs + - ${APP_HOME}/mcp-gateway/auth_server/scopes.yml:/app/auth_server/scopes.yml + - ${APP_HOME}/mcp-gateway/ssl:/etc/ssl:ro depends_on: - auth-server - metrics-service @@ -71,7 +71,7 @@ services: - "9465:9465" # Prometheus metrics endpoint volumes: - metrics-db-data:/var/lib/sqlite - - ${HOME}/mcp-gateway/logs:/app/logs + - ${APP_HOME}/mcp-gateway/logs:/app/logs depends_on: - metrics-db restart: unless-stopped @@ -116,8 +116,8 @@ services: ports: - "8888:8888" volumes: - - ${HOME}/mcp-gateway/logs:/app/logs - - ${HOME}/mcp-gateway/auth_server/scopes.yml:/app/scopes.yml + - ${APP_HOME}/mcp-gateway/logs:/app/logs + - ${APP_HOME}/mcp-gateway/auth_server/scopes.yml:/app/scopes.yml restart: unless-stopped # Current Time MCP Server @@ -145,7 +145,7 @@ services: - PORT=8001 - SECRET_KEY=${SECRET_KEY} volumes: - - ${HOME}/mcp-gateway/secrets/fininfo/:/app/fininfo/ + - ${APP_HOME}/mcp-gateway/secrets/fininfo/:/app/fininfo/ ports: - "8001:8001" restart: unless-stopped @@ -163,9 +163,9 @@ services: - REGISTRY_USERNAME=${ADMIN_USER:-admin} - REGISTRY_PASSWORD=${ADMIN_PASSWORD} volumes: - - ${HOME}/mcp-gateway/servers:/app/registry/servers - - ${HOME}/mcp-gateway/models:/app/registry/models - - ${HOME}/mcp-gateway/auth_server/scopes.yml:/app/auth_server/scopes.yml + - ${APP_HOME}/mcp-gateway/servers:/app/registry/servers + - ${APP_HOME}/mcp-gateway/models:/app/registry/models + - ${APP_HOME}/mcp-gateway/auth_server/scopes.yml:/app/auth_server/scopes.yml ports: - "8003:8003" depends_on: diff --git a/registry/core/nginx_service.py b/registry/core/nginx_service.py index 4aabb323..a25a9c07 100644 --- a/registry/core/nginx_service.py +++ b/registry/core/nginx_service.py @@ -150,7 +150,13 @@ async def generate_config_async(self, servers: Dict[str, Dict[str, Any]]) -> boo # Replace placeholders in template config_content = template_content.replace("{{LOCATION_BLOCKS}}", "\n".join(location_blocks)) - config_content = config_content.replace("{{EC2_PUBLIC_DNS}}", ec2_public_dns) + # Only include EC2_PUBLIC_DNS in server_name if it exists + if ec2_public_dns: + config_content = config_content.replace("{{EC2_PUBLIC_DNS}}", ec2_public_dns) + else: + # Remove the placeholder entirely if EC2_PUBLIC_DNS is empty + config_content = config_content.replace(" {{EC2_PUBLIC_DNS}}", "") + config_content = config_content.replace("{{EC2_PUBLIC_DNS}}", "") # Write config file with open(settings.nginx_config_path, "w") as f: From 3374462acfecbf24407ee63917bd495c38cbb121 Mon Sep 17 00:00:00 2001 From: ryo Date: Thu, 16 Oct 2025 08:40:37 -0700 Subject: [PATCH 02/17] update as per owner's comment --- .env.example | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.env.example b/.env.example index ba6e2175..aa890a7b 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,9 @@ # ============================================================================= # DOCKER CONFIGURATION # ============================================================================= +#different options for the home directory e.g. Ubuntu set to /home/ubuntu, set to /opt for mac would require sudo during installation APP_HOME=/opt +# APP_HOME=/home/ubuntu # ============================================================================= # REGISTRY CONFIGURATION From af20b1720fbf3019b3f7d160374604d5f76c6311 Mon Sep 17 00:00:00 2001 From: "yulin.deng" <1016068291@qq.com> Date: Thu, 30 Oct 2025 16:08:07 +0800 Subject: [PATCH 03/17] Add Microsoft Entra ID (Azure AD) Authentication Provider Add Microsoft Entra ID (Azure AD) Authentication Provider --- .env.example | 38 ++ .gitignore | 2 +- auth_server/oauth2_providers.yml | 23 ++ auth_server/providers/__init__.py | 11 +- auth_server/providers/entra.py | 350 +++++++++++++++++ auth_server/providers/factory.py | 40 +- auth_server/server.py | 51 ++- credentials-provider/entra/generate_tokens.py | 368 ++++++++++++++++++ docker-compose.yml | 11 +- docs/configuration.md | 71 +++- docs/entra-id-implementation.md | 0 docs/entra-id-setup.md | 0 pyproject.toml | 3 + 13 files changed, 959 insertions(+), 9 deletions(-) create mode 100644 auth_server/providers/entra.py create mode 100644 credentials-provider/entra/generate_tokens.py create mode 100644 docs/entra-id-implementation.md create mode 100644 docs/entra-id-setup.md diff --git a/.env.example b/.env.example index 5722169c..47fc6663 100644 --- a/.env.example +++ b/.env.example @@ -99,6 +99,44 @@ COGNITO_CLIENT_ID=your_cognito_client_id_here # Get this from Amazon Cognito console > User Pools > App Integration > App clients COGNITO_CLIENT_SECRET=your_cognito_client_secret_here + +# ============================================================================= +# MICROSOFT ENTRA ID (AZURE AD) OAUTH2 CONFIGURATION (if AUTH_PROVIDER=entra_id) +# ============================================================================= + +# Microsoft Entra ID Tenant ID +# Get this from Azure Portal > Azure Active Directory > Overview > Tenant ID +# Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +# Or use special values: +# - 'common': Multi-tenant (any organizational or personal Microsoft account) +# - 'organizations': Multi-tenant (organizational accounts only) +# - 'consumers': Personal Microsoft accounts only +ENTRA_TENANT_ID=your_tenant_id_here + +# Azure AD Application (Client) ID +# Get this from Azure Portal > App Registrations > Your App > Overview > Application (client) ID +# Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +ENTRA_CLIENT_ID=your_application_client_id_here + +# Azure AD Client Secret +# Get this from Azure Portal > App Registrations > Your App > Certificates & secrets > Client secrets +# IMPORTANT: Copy the SECRET VALUE, not the Secret ID +# Format: xxx~xxxxxxxxxxxxxxxxxxxxxxxxxxxx +ENTRA_CLIENT_SECRET=your_client_secret_value_here + +# Optional: Custom Authority URL (for sovereign clouds) +# Leave commented for global Azure cloud (default) +# Uncomment and set if using sovereign clouds: +# +# US Government Cloud: +# ENTRA_AUTHORITY=https://login.microsoftonline.us/your_tenant_id_here +# +# China Cloud (operated by 21Vianet): +# ENTRA_AUTHORITY=https://login.chinacloudapi.cn/your_tenant_id_here +# +# Germany Cloud: +# ENTRA_AUTHORITY=https://login.microsoftonline.de/your_tenant_id_here + # ============================================================================= # APPLICATION SECURITY # ============================================================================= diff --git a/.gitignore b/.gitignore index bb147561..755f06df 100644 --- a/.gitignore +++ b/.gitignore @@ -178,7 +178,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ # Ruff stuff: .ruff_cache/ diff --git a/auth_server/oauth2_providers.yml b/auth_server/oauth2_providers.yml index c2376cd6..e9ac8b9d 100644 --- a/auth_server/oauth2_providers.yml +++ b/auth_server/oauth2_providers.yml @@ -70,6 +70,29 @@ providers: name_claim: "name" enabled: false # Disabled by default + entra_id: + display_name: "Microsoft Entra ID" + client_id: "${ENTRA_CLIENT_ID}" + client_secret: "${ENTRA_CLIENT_SECRET}" + # Tenant ID can be specific tenant or 'common' for multi-tenant + tenant_id: "${ENTRA_TENANT_ID}" + auth_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/authorize" + token_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token" + user_info_url: "https://graph.microsoft.com/v1.0/me" + logout_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/logout" + scopes: ["openid", "profile", "email", "User.Read"] + response_type: "code" + grant_type: "authorization_code" + # Entra ID specific claim mapping + username_claim: "preferred_username" + groups_claim: "groups" + email_claim: "email" + name_claim: "name" + # Optional: For sovereign clouds (Azure Government, Azure China, etc.) + # authority: "https://login.microsoftonline.us/${ENTRA_TENANT_ID}" # US Government + # authority: "https://login.chinacloudapi.cn/${ENTRA_TENANT_ID}" # China + enabled: true # Disabled by default + # Default session settings session: max_age_seconds: 28800 # 8 hours diff --git a/auth_server/providers/__init__.py b/auth_server/providers/__init__.py index b1d9ccf7..e25ac8f5 100644 --- a/auth_server/providers/__init__.py +++ b/auth_server/providers/__init__.py @@ -1,6 +1,15 @@ """Authentication provider package for MCP Gateway Registry.""" from .base import AuthProvider +from .cognito import CognitoProvider +from .entra import EntraIDProvider from .factory import get_auth_provider +from .keycloak import KeycloakProvider -__all__ = ["AuthProvider", "get_auth_provider"] \ No newline at end of file +__all__ = [ + "AuthProvider", + "CognitoProvider", + "EntraIDProvider", + "KeycloakProvider", + "get_auth_provider", +] \ No newline at end of file diff --git a/auth_server/providers/entra.py b/auth_server/providers/entra.py new file mode 100644 index 00000000..0094a7e7 --- /dev/null +++ b/auth_server/providers/entra.py @@ -0,0 +1,350 @@ +import logging +import time +import jwt +import requests +from typing import Any, Dict, Optional +from urllib.parse import urlencode +from .base import AuthProvider + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", +) + +logger = logging.getLogger(__name__) + + +class EntraIDProvider(AuthProvider): + """Microsoft Entra ID authentication provider implementation.""" + + def __init__( + self, + tenant_id: str, + client_id: str, + client_secret: str, + authority: Optional[str] = None + ): + """Initialize Entra ID provider. + + Args: + tenant_id: Azure AD tenant ID (or 'common' for multi-tenant) + client_id: Azure AD application (client) ID + client_secret: Azure AD client secret + authority: Optional custom authority URL (defaults to global Azure AD) + """ + self.tenant_id = tenant_id + self.client_id = client_id + self.client_secret = client_secret + + # Cache for JWKS + self._jwks_cache: Optional[Dict[str, Any]] = None + self._jwks_cache_time: float = 0 + self._jwks_cache_ttl: int = 3600 # 1 hour + + # Microsoft Entra ID endpoints + self.authority = authority or f"https://login.microsoftonline.com/{tenant_id}" + self.token_url = f"{self.authority}/oauth2/v2.0/token" + self.auth_url = f"{self.authority}/oauth2/v2.0/authorize" + self.jwks_url = f"{self.authority}/discovery/v2.0/keys" + self.logout_url = f"{self.authority}/oauth2/v2.0/logout" + self.userinfo_url = "https://graph.microsoft.com/v1.0/me" + self.issuer = f"https://login.microsoftonline.com/{tenant_id}/v2.0" + + logger.debug(f"Initialized Entra ID provider for tenant '{tenant_id}'") + + def validate_token( + self, + token: str, + **kwargs: Any + ) -> Dict[str, Any]: + """Validate Entra ID JWT token.""" + try: + logger.debug("Validating Entra ID JWT token") + + # Get JWKS for validation + jwks = self.get_jwks() + + # Decode token header to get key ID + unverified_header = jwt.get_unverified_header(token) + kid = unverified_header.get('kid') + + if not kid: + raise ValueError("Token missing 'kid' in header") + + # Find matching key + signing_key = None + for key in jwks.get('keys', []): + if key.get('kid') == kid: + from jwt import PyJWK + signing_key = PyJWK(key).key + break + + if not signing_key: + raise ValueError(f"No matching key found for kid: {kid}") + + # Validate and decode token + # Note: Entra ID tokens may have audience as the client_id or api://{client_id} + claims = jwt.decode( + token, + signing_key, + algorithms=['RS256'], + issuer=self.issuer, + audience=[self.client_id, f"api://{self.client_id}"], + options={ + "verify_exp": True, + "verify_iat": True, + "verify_aud": True + } + ) + + logger.debug(f"Token validation successful for user:" + f" {claims.get('preferred_username', claims.get('upn', 'unknown'))}") + + # Extract user info from claims + # Entra ID tokens can have different claim structures + username = ( + claims.get('preferred_username') or + claims.get('upn') or + claims.get('unique_name') or + claims.get('email') or + claims.get('sub') + ) + return { + 'valid': True, + 'username': username, + 'email': claims.get('email') or claims.get('upn') or claims.get('preferred_username'), + 'groups': claims.get('groups', []), + 'scopes': claims.get('scp', '').split() if claims.get('scp') else [], + 'client_id': claims.get('azp', claims.get('appid', self.client_id)), + 'method': 'entra_id', + 'data': claims + } + + except jwt.ExpiredSignatureError: + logger.warning("Token validation failed: Token has expired") + raise ValueError("Token has expired") + except jwt.InvalidTokenError as e: + logger.warning(f"Token validation failed: Invalid token - {e}") + raise ValueError(f"Invalid token: {e}") + except Exception as e: + logger.error(f"Entra ID token validation error: {e}") + raise ValueError(f"Token validation failed: {e}") + + def get_jwks(self) -> Dict[str, Any]: + """Get JSON Web Key Set from Entra ID with caching.""" + current_time = time.time() + # Check if cache is still valid + if (self._jwks_cache and + (current_time - self._jwks_cache_time) < self._jwks_cache_ttl): + logger.debug("Using cached JWKS") + return self._jwks_cache + + try: + logger.debug(f"Fetching JWKS from {self.jwks_url}") + response = requests.get(self.jwks_url, timeout=10) + response.raise_for_status() + + self._jwks_cache = response.json() + self._jwks_cache_time = current_time + + logger.debug("JWKS fetched and cached successfully") + return self._jwks_cache + + except Exception as e: + logger.error(f"Failed to retrieve JWKS from Entra ID: {e}") + raise ValueError(f"Cannot retrieve JWKS: {e}") + + def exchange_code_for_token( + self, + code: str, + redirect_uri: str + ) -> Dict[str, Any]: + """Exchange authorization code for access token.""" + try: + logger.debug("Exchanging authorization code for token") + + data = { + 'grant_type': 'authorization_code', + 'code': code, + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'redirect_uri': redirect_uri, + 'scope': 'openid profile email User.Read' + } + + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + + response = requests.post(self.token_url, data=data, headers=headers, timeout=10) + response.raise_for_status() + + token_data = response.json() + logger.debug("Token exchange successful") + return token_data + + except requests.RequestException as e: + logger.error(f"Failed to exchange code for token: {e}") + if hasattr(e, 'response') and e.response is not None: + try: + error_detail = e.response.json() + logger.error(f"Error details: {error_detail}") + except Exception: + logger.error(f"Response text: {e.response.text}") + raise ValueError(f"Token exchange failed: {e}") + + def get_user_info( + self, + access_token: str + ) -> Dict[str, Any]: + """Get user information from Microsoft Graph API.""" + try: + logger.debug("Fetching user info from Microsoft Graph") + + headers = {'Authorization': f'Bearer {access_token}'} + response = requests.get(self.userinfo_url, headers=headers, timeout=10) + response.raise_for_status() + + user_info = response.json() + logger.debug(f"User info retrieved for: {user_info.get('userPrincipalName', 'unknown')}") + + # Transform Microsoft Graph response to standard format + return { + 'username': user_info.get('userPrincipalName'), + 'email': user_info.get('mail') or user_info.get('userPrincipalName'), + 'name': user_info.get('displayName'), + 'given_name': user_info.get('givenName'), + 'family_name': user_info.get('surname'), + 'id': user_info.get('id'), + 'job_title': user_info.get('jobTitle'), + 'office_location': user_info.get('officeLocation') + } + + except requests.RequestException as e: + logger.error(f"Failed to get user info: {e}") + raise ValueError(f"User info retrieval failed: {e}") + + def get_auth_url( + self, + redirect_uri: str, + state: str, + scope: Optional[str] = None + ) -> str: + """Get Entra ID authorization URL.""" + logger.debug(f"Generating auth URL with redirect_uri: {redirect_uri}") + + params = {'client_id': self.client_id, + 'response_type': 'code', + 'scope': scope or 'openid profile email User.Read', + 'redirect_uri': redirect_uri, + 'state': state, + 'response_mode': 'query' + } + + auth_url = f"{self.auth_url}?{urlencode(params)}" + logger.debug(f"Generated auth URL: {auth_url}") + return auth_url + + def get_logout_url( + self, + redirect_uri: str + ) -> str: + """Get Entra ID logout URL.""" + logger.debug(f"Generating logout URL with redirect_uri: {redirect_uri}") + + params = {'post_logout_redirect_uri': redirect_uri} + logout_url = f"{self.logout_url}?{urlencode(params)}" + logger.debug(f"Generated logout URL: {logout_url}") + + return logout_url + + def refresh_token( + self, + refresh_token: str + ) -> Dict[str, Any]: + """Refresh an access token using a refresh token.""" + try: + logger.debug("Refreshing access token") + + data = { + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token, + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'scope': 'openid profile email User.Read offline_access' + } + + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + + response = requests.post(self.token_url, data=data, headers=headers, timeout=10) + response.raise_for_status() + + token_data = response.json() + logger.debug("Token refresh successful") + + return token_data + + except requests.RequestException as e: + logger.error(f"Failed to refresh token: {e}") + raise ValueError(f"Token refresh failed: {e}") + + def validate_m2m_token( + self, + token: str + ) -> Dict[str, Any]: + """Validate a machine-to-machine token.""" + # M2M tokens use the same validation as regular tokens in Entra ID + return self.validate_token(token) + + def get_m2m_token( + self, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + scope: Optional[str] = None + ) -> Dict[str, Any]: + """Get machine-to-machine token using client credentials. + + For Entra ID, the default scope for client credentials is '.default' + which requests all permissions configured for the app registration. + """ + try: + logger.debug("Requesting M2M token using client credentials") + # For Entra ID client credentials, use .default scope or specified scope + default_scope = f"https://graph.microsoft.com/.default" + + data = { + 'grant_type': 'client_credentials', + 'client_id': client_id or self.client_id, + 'client_secret': client_secret or self.client_secret, + 'scope': scope or default_scope + } + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + response = requests.post(self.token_url, data=data, headers=headers, timeout=10) + response.raise_for_status() + token_data = response.json() + logger.debug("M2M token generation successful") + return token_data + + except requests.RequestException as e: + logger.error(f"Failed to get M2M token: {e}") + if hasattr(e, 'response') and e.response is not None: + try: + error_detail = e.response.json() + logger.error(f"Error details: {error_detail}") + except Exception as e: + logger.error(f"Response text: {e.response.text}") + raise ValueError(f"M2M token generation failed: {e}") + + def get_provider_info(self) -> Dict[str, Any]: + """Get provider-specific information.""" + return { + 'provider_type': 'entra_id', + 'tenant_id': self.tenant_id, + 'client_id': self.client_id, + 'endpoints': { + 'auth': self.auth_url, + 'token': self.token_url, + 'userinfo': self.userinfo_url, + 'jwks': self.jwks_url, + 'logout': self.logout_url + }, + 'issuer': self.issuer + } diff --git a/auth_server/providers/factory.py b/auth_server/providers/factory.py index 7fda460d..e6bd5fd1 100644 --- a/auth_server/providers/factory.py +++ b/auth_server/providers/factory.py @@ -7,6 +7,7 @@ from .base import AuthProvider from .cognito import CognitoProvider from .keycloak import KeycloakProvider +from .entra import EntraIDProvider logging.basicConfig( level=logging.INFO, @@ -22,7 +23,7 @@ def get_auth_provider( """Factory function to get the appropriate auth provider. Args: - provider_type: Type of provider to create ('cognito' or 'keycloak'). + provider_type: Type of provider to create ('cognito', 'keycloak', or 'entra_id'). If None, uses AUTH_PROVIDER environment variable. Returns: @@ -39,6 +40,8 @@ def get_auth_provider( return _create_keycloak_provider() elif provider_type == 'cognito': return _create_cognito_provider() + elif provider_type == 'entra_id': + return _create_entra_id_provider() else: raise ValueError(f"Unknown auth provider: {provider_type}") @@ -121,6 +124,41 @@ def _create_cognito_provider() -> CognitoProvider: ) +def _create_entra_id_provider() -> EntraIDProvider: + """Create and configure Microsoft Entra ID provider.""" + # Required configuration + tenant_id = os.environ.get('ENTRA_TENANT_ID') + client_id = os.environ.get('ENTRA_CLIENT_ID') + client_secret = os.environ.get('ENTRA_CLIENT_SECRET') + + # Optional configuration + authority = os.environ.get('ENTRA_AUTHORITY') + + # Validate required configuration + missing_vars = [] + if not tenant_id: + missing_vars.append('ENTRA_TENANT_ID') + if not client_id: + missing_vars.append('ENTRA_CLIENT_ID') + if not client_secret: + missing_vars.append('ENTRA_CLIENT_SECRET') + + if missing_vars: + raise ValueError( + f"Missing required Entra ID configuration: {', '.join(missing_vars)}. " + "Please set these environment variables." + ) + + logger.info(f"Initializing Entra ID provider for tenant '{tenant_id}'") + + return EntraIDProvider( + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret, + authority=authority + ) + + def _get_provider_health_info() -> dict: """Get health information for the current provider.""" try: diff --git a/auth_server/server.py b/auth_server/server.py index 0565dbf7..8641ef5e 100644 --- a/auth_server/server.py +++ b/auth_server/server.py @@ -1657,7 +1657,7 @@ async def oauth2_callback( logger.info(f"Token data keys: {list(token_data.keys())}") # For Cognito and Keycloak, try to extract user info from JWT tokens - if provider in ["cognito", "keycloak"]: + if provider in ["cognito", "keycloak", "entra_id"]: try: if provider == "cognito": # Extract Cognito configuration from environment @@ -1707,7 +1707,39 @@ async def oauth2_callback( else: logger.warning("No ID token found in Keycloak response, falling back to userInfo") raise ValueError("Missing ID token") - + elif provider == "entra_id": + # For Entra ID, decode the ID token to get user information + if "id_token" in token_data: + import jwt + # Decode without verification (we trust the token since we just got it from token exchange) + id_token_claims = jwt.decode(token_data["id_token"], options={"verify_signature": False}) + logger.info(f"Entra ID token claims: {id_token_claims}") + + # Extract user info from ID token claims + # Entra ID uses different claim names than Keycloak + username = ( + id_token_claims.get("preferred_username") or + id_token_claims.get("upn") or + id_token_claims.get("unique_name") or + id_token_claims.get("email") or + id_token_claims.get("sub") + ) + # Retrieve the user's group separately, without using the ID group, + # to avoid manually reconfiguring in scopes.yml + user_member_groups = await get_user_member_of(token_data["access_token"]) + groups_name = [groups.get("displayName") for groups in user_member_groups.get("value")] + logger.info(f" groups_name: {groups_name}") + mapped_user = { + "username": username, + "email": id_token_claims.get("email") or id_token_claims.get("upn") or username, + "name": id_token_claims.get("name"), + "groups": groups_name + } + logger.info(f"User extracted from Entra ID token: {mapped_user}") + else: + logger.warning("No ID token found in Entra ID response, falling back to userInfo") + raise ValueError("Missing ID token") + except Exception as e: logger.warning(f"JWT token validation failed: {e}, falling back to userInfo endpoint") # Fallback to userInfo endpoint @@ -1799,6 +1831,21 @@ async def get_user_info(access_token: str, provider_config: dict) -> dict: response.raise_for_status() return response.json() + +async def get_user_member_of(access_token: str): + """Get user group information from OAuth2 provider""" + async with httpx.AsyncClient() as client: + headers = {"Authorization": f"Bearer {access_token}"} + url = ("https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.group?" + "$count=true&$select=id,displayName") + response = await client.get( + url, + headers=headers + ) + response.raise_for_status() + return response.json() + + def map_user_info(user_info: dict, provider_config: dict) -> dict: """Map provider-specific user info to our standard format""" mapped = { diff --git a/credentials-provider/entra/generate_tokens.py b/credentials-provider/entra/generate_tokens.py new file mode 100644 index 00000000..9dcd9426 --- /dev/null +++ b/credentials-provider/entra/generate_tokens.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +""" +Generate Microsoft Entra ID (Azure AD) tokens for testing and development. + +This script supports multiple authentication flows: +1. Client Credentials Flow (for M2M/service accounts) +2. Authorization Code Flow (requires browser interaction) + +Usage: + # Client credentials flow (M2M) + python generate_tokens.py --tenant-id --client-id \ + --client-secret --flow client_credentials + + # Save tokens to file + python generate_tokens.py --tenant-id --client-id \ + --output tokens.json + +Environment Variables: + ENTRA_TENANT_ID: Azure AD tenant ID + ENTRA_CLIENT_ID: Application (client) ID + ENTRA_CLIENT_SECRET: Client secret (for client credentials flow) +""" + +import argparse +import json +import os +import sys +import time +import requests +from typing import Dict, Any, Optional + + +class EntraTokenGenerator: + """Generate tokens from Microsoft Entra ID.""" + + def __init__( + self, + tenant_id: str, + client_id: str, + client_secret: Optional[str] = None, + authority: Optional[str] = None + ): + """Initialize token generator. + + Args: + tenant_id: Azure AD tenant ID + client_id: Application (client) ID + client_secret: Client secret (required for client credentials flow) + authority: Custom authority URL (defaults to global Azure AD) + """ + self.tenant_id = tenant_id + self.client_id = client_id + self.client_secret = client_secret + + self.authority = authority or f"https://login.microsoftonline.com/{tenant_id}" + self.token_url = f"{self.authority}/oauth2/v2.0/token" + self.device_code_url = f"{self.authority}/oauth2/v2.0/devicecode" + self.auth_url = f"{self.authority}/oauth2/v2.0/authorize" + + def get_device_code_token( + self, + scope: str = "openid profile email User.Read offline_access" + ) -> Dict[str, Any]: + """Get tokens using device code flow (interactive). + + This is the recommended flow for CLI tools and testing. + User will need to visit a URL and enter a code. + + Args: + scope: OAuth2 scopes to request + + Returns: + Dictionary containing access_token, refresh_token, etc. + """ + print("Starting device code flow...") + + # Request device code + data = { + 'client_id': self.client_id, + 'scope': scope + } + + response = requests.post(self.device_code_url, data=data) + response.raise_for_status() + device_code_data = response.json() + + # Display instructions to user + print("\n" + "=" * 70) + print("DEVICE CODE AUTHENTICATION") + print("=" * 70) + print(f"\n1. Visit: {device_code_data['verification_uri']}") + print(f"2. Enter code: {device_code_data['user_code']}") + print(f"\nWaiting for authentication (expires in {device_code_data['expires_in']} seconds)...") + print("=" * 70 + "\n") + + # Poll for token + device_code = device_code_data['device_code'] + interval = device_code_data.get('interval', 5) + expires_in = device_code_data['expires_in'] + start_time = time.time() + + while True: + if time.time() - start_time > expires_in: + raise TimeoutError("Device code expired before user completed authentication") + + time.sleep(interval) + + token_data = { + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'device_code': device_code, + 'client_id': self.client_id + } + + # Add client_secret if available (required for confidential clients) + if self.client_secret: + token_data['client_secret'] = self.client_secret + + response = requests.post(self.token_url, data=token_data) + result = response.json() + + if response.status_code == 200: + print("✓ Authentication successful!") + return result + + error = result.get('error') + if error == 'authorization_pending': + print(".", end="", flush=True) + continue + elif error == 'slow_down': + interval += 5 + continue + elif error in ['authorization_declined', 'bad_verification_code', 'expired_token']: + raise ValueError(f"Authentication failed: {error}") + else: + raise ValueError(f"Unexpected error: {error} - {result.get('error_description')}") + + def get_client_credentials_token( + self, + scope: str = "https://graph.microsoft.com/.default" + ) -> Dict[str, Any]: + """Get token using client credentials flow (M2M). + + This flow is for service-to-service authentication. + Requires client_secret to be set. + + Args: + scope: OAuth2 scope (use .default for all configured permissions) + + Returns: + Dictionary containing access_token + """ + if not self.client_secret: + raise ValueError("Client secret is required for client credentials flow") + + print("Requesting token using client credentials flow...") + + data = { + 'grant_type': 'client_credentials', + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'scope': scope + } + + response = requests.post(self.token_url, data=data) + response.raise_for_status() + + token_data = response.json() + print("✓ Token generated successfully!") + + return token_data + + def refresh_token( + self, + refresh_token: str, + scope: str = "openid profile email User.Read offline_access" + ) -> Dict[str, Any]: + """Refresh an access token. + + Args: + refresh_token: The refresh token + scope: OAuth2 scopes to request + + Returns: + Dictionary containing new access_token and refresh_token + """ + print("Refreshing access token...") + + data = { + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token, + 'client_id': self.client_id, + 'scope': scope + } + + if self.client_secret: + data['client_secret'] = self.client_secret + + response = requests.post(self.token_url, data=data) + response.raise_for_status() + + token_data = response.json() + print("✓ Token refreshed successfully!") + + return token_data + + def decode_token(self, token: str) -> Dict[str, Any]: + """Decode JWT token (without validation) to inspect claims. + + Args: + token: JWT token string + + Returns: + Dictionary containing token claims + """ + import base64 + + # Split token into parts + parts = token.split('.') + if len(parts) != 3: + raise ValueError("Invalid JWT token format") + + # Decode payload (add padding if needed) + payload = parts[1] + padding = 4 - len(payload) % 4 + if padding != 4: + payload += '=' * padding + + decoded_bytes = base64.urlsafe_b64decode(payload) + return json.loads(decoded_bytes) + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser( + description='Generate Microsoft Entra ID tokens', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + + parser.add_argument( + '--tenant-id', + default=os.environ.get('ENTRA_TENANT_ID'), + help='Azure AD tenant ID (or env: ENTRA_TENANT_ID)' + ) + + parser.add_argument( + '--client-id', + default=os.environ.get('ENTRA_CLIENT_ID'), + help='Application (client) ID (or env: ENTRA_CLIENT_ID)' + ) + + parser.add_argument( + '--client-secret', + default=os.environ.get('ENTRA_CLIENT_SECRET'), + help='Client secret (or env: ENTRA_CLIENT_SECRET)' + ) + + parser.add_argument( + '--flow', + choices=['device_code', 'client_credentials', 'refresh'], + default='device_code', + help='Authentication flow to use (default: device_code)' + ) + + parser.add_argument( + '--scope', + help='OAuth2 scopes (space-separated)' + ) + + parser.add_argument( + '--refresh-token', + help='Refresh token (for refresh flow)' + ) + + parser.add_argument( + '--output', + help='Output file path to save tokens (default: print to stdout)' + ) + + parser.add_argument( + '--decode', + action='store_true', + help='Decode and display token claims' + ) + + parser.add_argument( + '--authority', + help='Custom authority URL (for sovereign clouds)' + ) + + args = parser.parse_args() + + # Validate required arguments + if not args.tenant_id: + parser.error("--tenant-id is required (or set ENTRA_TENANT_ID)") + + if not args.client_id: + parser.error("--client-id is required (or set ENTRA_CLIENT_ID)") + + # Create token generator + generator = EntraTokenGenerator( + tenant_id=args.tenant_id, + client_id=args.client_id, + client_secret=args.client_secret, + authority=args.authority + ) + + # Generate tokens based on flow + token_data = None + try: + if args.flow == 'device_code': + scope = args.scope or "openid profile email User.Read offline_access" + token_data = generator.get_device_code_token(scope=scope) + + elif args.flow == 'client_credentials': + scope = args.scope or "https://graph.microsoft.com/.default" + token_data = generator.get_client_credentials_token(scope=scope) + + elif args.flow == 'refresh': + if not args.refresh_token: + parser.error("--refresh-token is required for refresh flow") + scope = args.scope or "openid profile email User.Read offline_access" + token_data = generator.refresh_token( + refresh_token=args.refresh_token, + scope=scope + ) + + # Add metadata + token_data['generated_at'] = time.time() + token_data['expires_at'] = time.time() + token_data.get('expires_in', 3600) + + # Decode token if requested + if args.decode and 'access_token' in token_data: + print("\n" + "=" * 70) + print("TOKEN CLAIMS") + print("=" * 70) + claims = generator.decode_token(token_data['access_token']) + print(json.dumps(claims, indent=2)) + print("=" * 70 + "\n") + + # Output tokens + if args.output: + with open(args.output, 'w') as f: + json.dump(token_data, f, indent=2) + print(f"\n✓ Tokens saved to: {args.output}") + else: + print("\n" + "=" * 70) + print("TOKENS") + print("=" * 70) + print(json.dumps(token_data, indent=2)) + print("=" * 70) + + # Display useful information + print("\nToken Information:") + print(f" Token Type: {token_data.get('token_type', 'Bearer')}") + print(f" Expires In: {token_data.get('expires_in', 'N/A')} seconds") + if 'scope' in token_data: + print(f" Scopes: {token_data['scope']}") + + return 0 + + except Exception as e: + print(f"\n✗ Error: {e}", file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/docker-compose.yml b/docker-compose.yml index e844ba1d..7f210cc9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -106,7 +106,7 @@ services: - METRICS_SERVICE_URL=http://metrics-service:8890 - METRICS_API_KEY=${METRICS_API_KEY_AUTH_SERVER} # Keycloak configuration - - AUTH_PROVIDER=${AUTH_PROVIDER:-cognito} # 'cognito' or 'keycloak' + - AUTH_PROVIDER=${AUTH_PROVIDER:-cognito} # 'cognito' or 'keycloak' or 'entra_id' - KEYCLOAK_ENABLED=${KEYCLOAK_ENABLED:-false} - KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080} - KEYCLOAK_EXTERNAL_URL=${KEYCLOAK_EXTERNAL_URL:-http://localhost:8080} @@ -115,6 +115,11 @@ services: - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} - KEYCLOAK_M2M_CLIENT_ID=${KEYCLOAK_M2M_CLIENT_ID:-mcp-gateway-m2m} - KEYCLOAK_M2M_CLIENT_SECRET=${KEYCLOAK_M2M_CLIENT_SECRET} + # Microsoft Entra ID (Azure AD) configuration + - ENTRA_TENANT_ID=${ENTRA_TENANT_ID} + - ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID} + - ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET} + - ENTRA_AUTHORITY=${ENTRA_AUTHORITY} ports: - "8888:8888" volumes: @@ -170,8 +175,8 @@ services: - ${HOME}/mcp-gateway/auth_server/scopes.yml:/app/auth_server/scopes.yml ports: - "8003:8003" - depends_on: - - registry +# depends_on: +# - registry restart: unless-stopped # Real Server Fake Tools MCP Server diff --git a/docs/configuration.md b/docs/configuration.md index e3d6d7a5..056b62ec 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,6 +27,7 @@ The MCP Gateway Registry supports multiple authentication providers. Choose one - **`keycloak`**: Enterprise-grade open-source identity and access management with individual agent audit trails - **`cognito`**: Amazon managed authentication service +- **`entra_id`**: Microsoft Entra ID (formerly Azure Active Directory) for Microsoft 365 and Azure integration Based on your selection, configure the corresponding provider-specific variables below. @@ -37,7 +38,7 @@ Based on your selection, configure the corresponding provider-specific variables | `REGISTRY_URL` | Public URL of the MCP Gateway Registry | `https://mcpgateway.ddns.net` | ✅ | | `ADMIN_USER` | Registry admin username | `admin` | ✅ | | `ADMIN_PASSWORD` | Registry admin password | `your-secure-password` | ✅ | -| `AUTH_PROVIDER` | Authentication provider (`cognito` or `keycloak`) | `keycloak` | ✅ | +| `AUTH_PROVIDER` | Authentication provider (`keycloak`, `cognito`, or `entra_id`) | `keycloak` | ✅ | | `AWS_REGION` | AWS region for services | `us-east-1` | ✅ | ### Keycloak Configuration (if AUTH_PROVIDER=keycloak) @@ -105,6 +106,74 @@ cat keycloak/setup/keycloak-client-secrets.txt | `COGNITO_CLIENT_SECRET` | Amazon Cognito App Client Secret | `85ps32t55df39hm61k966fqjurj...` | ✅ | | `COGNITO_DOMAIN` | Cognito domain (optional) | `auto` | Optional | +### Microsoft Entra ID Configuration (if AUTH_PROVIDER=entra_id) + +| Variable | Description | Example | Required | +|----------|-------------|---------|----------| +| `ENTRA_TENANT_ID` | Azure AD tenant ID (or 'common' for multi-tenant) | `12345678-1234-1234-1234-123456789012` | ✅ | +| `ENTRA_CLIENT_ID` | Azure AD application (client) ID | `87654321-4321-4321-4321-210987654321` | ✅ | +| `ENTRA_CLIENT_SECRET` | Azure AD client secret value | `abc123~XYZ...` | ✅ | +| `ENTRA_AUTHORITY` | Custom authority URL (for sovereign clouds) | `https://login.microsoftonline.com/{tenant_id}` | Optional | + +**Note: Getting Entra ID Credentials** + +To obtain these credentials from Azure Portal: + +1. Navigate to [Azure Portal](https://portal.azure.com) → **Azure Active Directory** (or **Microsoft Entra ID**) +2. Go to **App registrations** → Click **New registration** +3. Configure your application: + - **Name**: `MCP Gateway Registry` + - **Supported account types**: Choose based on your needs + - Single tenant: Only accounts in your organization + - Multi-tenant: Accounts in any organizational directory + - **Redirect URI**: + - Platform: `Web` + - URL: `https://your-registry-url/auth/callback` +4. After registration, copy the **Application (client) ID** → This is your `ENTRA_CLIENT_ID` +5. Copy the **Directory (tenant) ID** → This is your `ENTRA_TENANT_ID` +6. Go to **Certificates & secrets** → **Client secrets** → **New client secret** + - Add description: `MCP Gateway Secret` + - Choose expiration period + - Copy the **Value** (not the Secret ID) → This is your `ENTRA_CLIENT_SECRET` +7. Go to **API permissions**: + - Add **Microsoft Graph** permissions: + - `User.Read` (Delegated) - Read user profile + - `openid` (Delegated) - OpenID Connect sign-in + - `profile` (Delegated) - View users' basic profile + - `email` (Delegated) - View users' email address + - Click **Grant admin consent** if you have admin rights +8. Go to **Authentication**: + - Add redirect URI: `https://your-registry-url/auth/callback` + - Under **Implicit grant and hybrid flows**: Enable **ID tokens** + - Under **Advanced settings**: Set **Allow public client flows** to `Yes` (required for device code flow) + +**Sovereign Cloud Support** + +For non-global Azure clouds, set the `ENTRA_AUTHORITY` variable: + +```bash +# US Government Cloud +ENTRA_AUTHORITY=https://login.microsoftonline.us/{tenant_id} + +# China Cloud (operated by 21Vianet) +ENTRA_AUTHORITY=https://login.chinacloudapi.cn/{tenant_id} + +# Germany Cloud +ENTRA_AUTHORITY=https://login.microsoftonline.de/{tenant_id} +``` + +**Multi-Tenant Configuration** + +To support any Microsoft organizational account: + +```bash +ENTRA_TENANT_ID=common +# or for only organizational accounts (exclude personal accounts) +ENTRA_TENANT_ID=organizations +# or for only personal Microsoft accounts +ENTRA_TENANT_ID=consumers +``` + ### Optional Variables | Variable | Description | Example | Default | diff --git a/docs/entra-id-implementation.md b/docs/entra-id-implementation.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/entra-id-setup.md b/docs/entra-id-setup.md new file mode 100644 index 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml index bdd7ea53..254a4b56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,9 @@ dependencies = [ "aiohttp>=3.8.0", "rich>=13.0.0", "requests>=2.31.0", + "pytest>=8.4.2", + "faker>=37.11.0", + "factory-boy>=3.3.3", ] [project.optional-dependencies] From 5b4dcd3fda8923bf42d197354c673e2a08164731 Mon Sep 17 00:00:00 2001 From: "yulin.deng" <1016068291@qq.com> Date: Thu, 30 Oct 2025 21:59:59 +0800 Subject: [PATCH 04/17] update docs --- docs/auth.md | 5 + docs/entra-id-implementation.md | 391 ++++++++++++++++++++++++++++++++ docs/entra-id-setup.md | 173 ++++++++++++++ docs/index.md | 2 +- docs/installation.md | 5 +- mkdocs.yml | 2 + 6 files changed, 576 insertions(+), 2 deletions(-) diff --git a/docs/auth.md b/docs/auth.md index 6efd1ec8..3901224a 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -41,6 +41,11 @@ KEYCLOAK_M2M_CLIENT_SECRET=your_keycloak_m2m_client_secret # INGRESS_OAUTH_CLIENT_ID=your_cognito_client_id # INGRESS_OAUTH_CLIENT_SECRET=your_cognito_client_secret +# Alternative: Microsoft Entra ID (if AUTH_PROVIDER=entra_id) +# ENTRA_TENANT_ID=your-tenant-id-or-common +# ENTRA_CLIENT_ID=your-application-client-id +# ENTRA_CLIENT_SECRET=your-client-secret-value + # Egress Authentication (Optional - for external services) EGRESS_OAUTH_CLIENT_ID_1=your_external_provider_client_id EGRESS_OAUTH_CLIENT_SECRET_1=your_external_provider_client_secret diff --git a/docs/entra-id-implementation.md b/docs/entra-id-implementation.md index e69de29b..642009cb 100644 --- a/docs/entra-id-implementation.md +++ b/docs/entra-id-implementation.md @@ -0,0 +1,391 @@ +# Microsoft Entra ID Implementation + +This document provides technical details about the Microsoft Entra ID (Azure AD) authentication provider implementation in the MCP Gateway Registry. + +## Overview + +The `EntraIDProvider` class implements the `AuthProvider` interface to provide Microsoft Entra ID (Azure AD) authentication capabilities. It supports OAuth2 authorization code flow, JWT token validation, and integration with Microsoft Graph API. + +## Architecture + +### Class Hierarchy + +``` +AuthProvider (Abstract Base Class) +└── EntraIDProvider (Concrete Implementation) +``` + +### Dependencies + +- **Python-jose**: For JWT token validation and decoding +- **Requests**: For HTTP API calls to Microsoft endpoints +- **PyJWT**: For JWT header parsing and key handling + +## Implementation Details + +### Initialization + +The `EntraIDProvider` is initialized with the following parameters: + +```python +def __init__( + self, + tenant_id: str, + client_id: str, + client_secret: str, + authority: Optional[str] = None +): +``` + +**Parameters:** +- `tenant_id`: Azure AD tenant ID (use 'common' for multi-tenant) +- `client_id`: Azure AD application (client) ID +- `client_secret`: Azure AD client secret +- `authority`: Optional custom authority URL (defaults to global Azure AD) + +**Endpoints Configured:** +- `token_url`: `{authority}/oauth2/v2.0/token` +- `auth_url`: `{authority}/oauth2/v2.0/authorize` +- `jwks_url`: `{authority}/discovery/v2.0/keys` +- `logout_url`: `{authority}/oauth2/v2.0/logout` +- `userinfo_url`: `https://graph.microsoft.com/v1.0/me` +- `issuer`: `https://login.microsoftonline.com/{tenant_id}/v2.0` + +### Token Validation + +The `validate_token` method performs comprehensive JWT validation: + +```python +def validate_token(self, token: str, **kwargs: Any) -> Dict[str, Any]: +``` + +**Validation Steps:** +1. **JWKS Retrieval**: Fetches JSON Web Key Set from Microsoft with 1-hour caching +2. **Key Matching**: Matches token's `kid` header to the appropriate signing key +3. **JWT Decoding**: Validates using RS256 algorithm with multiple audience checks +4. **Claim Extraction**: Extracts user information from token claims + +**Supported Audiences:** +- `client_id` (e.g., `12345678-1234-1234-1234-123456789012`) +- `api://{client_id}` (e.g., `api://12345678-1234-1234-1234-123456789012`) + +**User Claim Resolution:** +The implementation attempts multiple claim fields for username resolution: +- `preferred_username` +- `upn` (User Principal Name) +- `unique_name` +- `email` +- `sub` (Subject) as fallback + +### JWKS Caching + +The implementation includes intelligent JWKS caching: + +```python +self._jwks_cache: Optional[Dict[str, Any]] = None +self._jwks_cache_time: float = 0 +self._jwks_cache_ttl: int = 3600 # 1 hour +``` + +**Features:** +- 1-hour TTL for JWKS cache +- Automatic cache refresh on expiration +- Error handling for JWKS retrieval failures + +### OAuth2 Flow Implementation + +#### Authorization URL Generation + +```python +def get_auth_url(self, redirect_uri: str, state: str, scope: Optional[str] = None) -> str: +``` + +**Default Scopes:** `openid profile email User.Read` +**Response Mode:** `query` + +#### Code Exchange + +```python +def exchange_code_for_token(self, code: str, redirect_uri: str) -> Dict[str, Any]: +``` + +**Request Parameters:** +- `grant_type`: `authorization_code` +- `code`: Authorization code +- `redirect_uri`: Must match the authorization request +- `scope`: `openid profile email User.Read` + +#### Token Refresh + +```python +def refresh_token(self, refresh_token: str) -> Dict[str, Any]: +``` + +**Request Parameters:** +- `grant_type`: `refresh_token` +- `refresh_token`: The refresh token +- `scope`: `openid profile email User.Read offline_access` + +### User Information Retrieval + +The implementation integrates with Microsoft Graph API to fetch user profile information: + +```python +def get_user_info(self, access_token: str) -> Dict[str, Any]: +``` + +**Graph API Endpoint:** `https://graph.microsoft.com/v1.0/me` + +**Returned Fields:** +- `username`: User Principal Name (UPN) +- `email`: Mail address or UPN +- `name`: Display name +- `given_name`: First name +- `family_name`: Last name +- `id`: Object ID +- `job_title`: Job title +- `office_location`: Office location + +### Machine-to-Machine (M2M) Support + +#### M2M Token Generation + +```python +def get_m2m_token( + self, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + scope: Optional[str] = None +) -> Dict[str, Any]: +``` + +**Client Credentials Flow:** +- `grant_type`: `client_credentials` +- Default scope: `https://graph.microsoft.com/.default` +- Supports custom client credentials for service accounts + +#### M2M Token Validation + +```python +def validate_m2m_token(self, token: str) -> Dict[str, Any]: +``` + +Uses the same validation logic as user tokens with appropriate audience checks. + +### Logout Implementation + +```python +def get_logout_url(self, redirect_uri: str) -> str: +``` + +Generates Microsoft Entra ID logout URL with post-logout redirect. + +## Configuration + +### Provider Configuration (oauth2_providers.yml) + +```yaml +entra_id: + display_name: "Microsoft Entra ID" + client_id: "${ENTRA_CLIENT_ID}" + client_secret: "${ENTRA_CLIENT_SECRET}" + tenant_id: "${ENTRA_TENANT_ID}" + auth_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/authorize" + token_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token" + user_info_url: "https://graph.microsoft.com/v1.0/me" + logout_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/logout" + scopes: ["openid", "profile", "email", "User.Read"] + response_type: "code" + grant_type: "authorization_code" + username_claim: "preferred_username" + groups_claim: "groups" + email_claim: "email" + name_claim: "name" + enabled: true +``` + +### Environment Variables + +```bash +# Required +ENTRA_CLIENT_ID=your-application-client-id +ENTRA_CLIENT_SECRET=your-client-secret-value +ENTRA_TENANT_ID=your-tenant-id-or-common + +# Optional - For sovereign clouds +ENTRA_AUTHORITY=https://login.microsoftonline.us # US Government +# or +ENTRA_AUTHORITY=https://login.chinacloudapi.cn # China +``` + +## Error Handling + +The implementation includes comprehensive error handling: + +### Token Validation Errors +- `jwt.ExpiredSignatureError`: Token has expired +- `jwt.InvalidTokenError`: Invalid token structure or signature +- `ValueError`: Missing key ID or no matching key found + +### API Call Errors +- `requests.RequestException`: Network or HTTP errors +- Detailed error logging with response bodies for debugging + +### JWKS Retrieval Errors +- Fallback handling for JWKS endpoint failures +- Graceful degradation with appropriate error messages + +## Logging + +The provider uses structured logging with the following patterns: + +```python +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", +) +``` + +**Key Log Events:** +- Token validation successes and failures +- JWKS cache hits and misses +- API call attempts and results +- User authentication events + +## Security Features + +### Token Security +- **Signature Validation**: Validates token signatures using Microsoft's JWKS +- **Expiration Checking**: Verifies token expiration timestamps +- **Audience Validation**: Checks token audience against client ID +- **Issuer Verification**: Validates token issuer against Microsoft endpoints + +### API Security +- **HTTPS Enforcement**: All Microsoft endpoints use HTTPS +- **Client Secret Protection**: Secrets are passed securely in token requests +- **Redirect URI Validation**: Ensures redirect URIs match configured endpoints + +### Caching Security +- **JWKS Cache TTL**: 1-hour cache with automatic refresh +- **No Sensitive Data**: Cache only contains public keys + +## Performance Considerations + +### JWKS Caching +- Reduces API calls to Microsoft endpoints +- 1-hour cache TTL balances performance and security +- Automatic cache refresh prevents stale key usage + +### Token Validation +- Efficient key lookup using key ID (kid) +- Supports multiple audience formats for compatibility +- Minimal overhead for token parsing and validation + +## Extensibility + +### Custom Authority URLs +Support for sovereign clouds: +- Azure US Government: `https://login.microsoftonline.us` +- Azure China: `https://login.chinacloudapi.cn` + +### Custom Scopes +Easily extendable to support additional Microsoft Graph permissions: + +```yaml +scopes: ["openid", "profile", "email", "User.Read", "Mail.Read", "Calendars.Read"] +``` + +### Multi-Tenant Support +- Use `tenant_id: "common"` for multi-tenant applications +- Automatic tenant discovery and validation + +## Testing + +### Unit Testing +The implementation can be tested with: +- Mock JWKS endpoints +- Mock Microsoft Graph API responses +- Test tokens with known signatures + +### Integration Testing +- End-to-end OAuth2 flow testing +- Token validation with real Microsoft endpoints +- Error scenario testing + +## Usage Examples + +### Basic Authentication Flow + +```python +from auth_server.providers.entra import EntraIDProvider + +# Initialize provider +provider = EntraIDProvider( + tenant_id="your-tenant-id", + client_id="your-client-id", + client_secret="your-client-secret" +) + +# Generate authorization URL +auth_url = provider.get_auth_url( + redirect_uri="https://your-app/callback", + state="security-token" +) + +# Exchange code for token +token_data = provider.exchange_code_for_token( + code="authorization-code", + redirect_uri="https://your-app/callback" +) + +# Validate token +user_info = provider.validate_token(token_data["access_token"]) + +# Get user profile +profile = provider.get_user_info(token_data["access_token"]) +``` + +### Machine-to-Machine Authentication + +```python +# Get M2M token +m2m_token = provider.get_m2m_token( + scope="https://graph.microsoft.com/.default" +) + +# Validate M2M token +validation_result = provider.validate_m2m_token(m2m_token["access_token"]) +``` + +## Troubleshooting + +### Common Issues + +1. **Token Validation Failures** + - Check audience and issuer configuration + - Verify JWKS endpoint accessibility + - Ensure token hasn't expired + +2. **API Permission Errors** + - Verify delegated permissions are granted + - Check admin consent for application permissions + - Validate scope configuration + +3. **Multi-Tenant Issues** + - Ensure app registration allows multi-tenant access + - Verify tenant ID is set to "common" for multi-tenant apps + +### Debug Mode + +Enable debug logging for detailed troubleshooting: + +```python +import logging +logging.getLogger().setLevel(logging.DEBUG) +``` + +## References + +- [Microsoft Identity Platform Documentation](https://docs.microsoft.com/en-us/azure/active-directory/develop/) +- [Microsoft Graph API Reference](https://docs.microsoft.com/en-us/graph/api/overview) +- [OAuth 2.0 Authorization Code Flow](https://oauth.net/2/grant-types/authorization-code/) diff --git a/docs/entra-id-setup.md b/docs/entra-id-setup.md index e69de29b..1357ffeb 100644 --- a/docs/entra-id-setup.md +++ b/docs/entra-id-setup.md @@ -0,0 +1,173 @@ +# Microsoft Entra ID (Azure AD) Setup Guide + +This guide provides step-by-step instructions for setting up Microsoft Entra ID (formerly Azure AD) as an authentication provider in the MCP Gateway Registry. + +## Prerequisites + +- An Azure subscription with Entra ID (Azure AD) tenant +- Access to the Azure Portal with administrative privileges +- MCP Gateway Registry deployed and accessible + +## Step 1: Create App Registration in Azure Portal + +1. **Navigate to Azure Portal** + - Go to [Azure Portal](https://portal.azure.com) + - Navigate to **Azure Active Directory** > **App registrations** + +2. **Create New Registration** + - Click **New registration** + - **Name**: `MCP Gateway Registry` (or your preferred name) + - **Supported account types**: + - For single tenant: *Accounts in this organizational directory only* + - For multi-tenant: *Accounts in any organizational directory* + - **Redirect URI**: + - Type: **Web** + - URI: `https://your-registry-domain/auth/callback` + - Replace `your-registry-domain` with your actual registry URL + +3. **Register the Application** + - Click **Register** + - Note down the **Application (client) ID** and **Directory (tenant) ID** + +## Step 2: Configure Authentication + +1. **Configure Platform Settings** + - In your app registration, go to **Authentication** + - Under **Platform configurations**, ensure your redirect URI is listed + - **Implicit grant**: Enable **ID tokens** (recommended) + +2. **Configure API Permissions** + - Go to **API permissions** + - Click **Add a permission** > **Microsoft Graph** > **Delegated permissions** + - Add the following permissions: + - `email` - Read user email address + - `openid` - Sign users in + - `profile` - Read user profile + - `User.Read` - Read user's full profile + - Click **Add permissions** + - **Grant admin consent** for the permissions + +## Step 3: Create Client Secret + +1. **Generate New Secret** + - In your app registration, go to **Certificates & secrets** + - Click **New client secret** + - **Description**: `MCP Gateway Registry Secret` + - **Expires**: Choose appropriate expiration (recommended: 12-24 months) + - Click **Add** + +2. **Copy the Secret Value** + - **Important**: Copy the secret value immediately - it won't be shown again + - Store this securely + +## Step 4: Environment Configuration + +Add the following environment variables to your MCP Gateway Registry deployment: + +```bash +# Microsoft Entra ID Configuration +ENTRA_CLIENT_ID=your-application-client-id +ENTRA_CLIENT_SECRET=your-client-secret-value +ENTRA_TENANT_ID=your-tenant-id-or-common + +# Optional: For sovereign clouds +# ENTRA_AUTHORITY=https://login.microsoftonline.us # US Government +# ENTRA_AUTHORITY=https://login.chinacloudapi.cn # China +``` + +## Step 5: Enable Entra ID Provider + +Ensure the Entra ID provider is enabled in the `auth_server/oauth2_providers.yml` configuration: + +```yaml +entra_id: + display_name: "Microsoft Entra ID" + client_id: "${ENTRA_CLIENT_ID}" + client_secret: "${ENTRA_CLIENT_SECRET}" + tenant_id: "${ENTRA_TENANT_ID}" + auth_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/authorize" + token_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token" + user_info_url: "https://graph.microsoft.com/v1.0/me" + logout_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/logout" + scopes: ["openid", "profile", "email", "User.Read"] + response_type: "code" + grant_type: "authorization_code" + username_claim: "preferred_username" + groups_claim: "groups" + email_claim: "email" + name_claim: "name" + enabled: true +``` + +## Step 6: Test the Setup + +1. **Restart Services** + - Restart the authentication server and registry services + +2. **Test Authentication Flow** + - Navigate to your registry login page + - Select "Microsoft Entra ID" as the authentication method + - Complete the Microsoft login process + - Verify successful authentication and user information retrieval + +## Step 7: Optional Configurations + +### Multi-Tenant Setup +For multi-tenant applications, set `ENTRA_TENANT_ID=common` and ensure the app registration is configured for multi-tenant access. + +### Machine-to-Machine (M2M) Authentication +For service accounts and automated processes: + +1. **Configure App Permissions** + - In your app registration, go to **API permissions** + - Add **Application permissions** (not delegated) as needed + - Grant admin consent + +2. **Use Client Credentials Flow** + - The implementation supports M2M token generation using client credentials + - See implementation documentation for usage details + +### Custom Scopes +Modify the `scopes` configuration in `oauth2_providers.yml` to include additional Microsoft Graph permissions as needed. + +## Troubleshooting + +### Common Issues + +1. **Invalid Redirect URI** + - Ensure the redirect URI in Azure matches exactly with your registry callback URL + - Check for trailing slashes and protocol (http vs https) + +2. **Insufficient Permissions** + - Verify all required API permissions are granted with admin consent + - Check that the user has appropriate permissions in Entra ID + +3. **Token Validation Failures** + - Verify client ID, tenant ID, and client secret are correct + - Check token audience and issuer configuration + +4. **Sovereign Cloud Issues** + - For Azure Government or China clouds, set the appropriate authority URL + - Ensure app registration is in the correct cloud environment + +### Logs and Debugging + +Enable debug logging to troubleshoot authentication issues: + +```bash +# Set log level to DEBUG in your environment +AUTH_LOG_LEVEL=DEBUG +``` + +Check authentication server logs for detailed error messages and token validation information. + +## Security Considerations + +- **Client Secrets**: Rotate client secrets regularly and store them securely +- **Token Validation**: The implementation validates token signatures, expiration, and audience +- **JWKS Caching**: JWKS are cached for 1 hour to reduce API calls while maintaining security +- **Multi-tenancy**: Use tenant-specific configurations when needed for enhanced security + +## Next Steps + +After successful setup, refer to the [Implementation Documentation](./entra-id-implementation.md) for technical details and advanced configuration options. diff --git a/docs/index.md b/docs/index.md index b64118b3..0f96d832 100644 --- a/docs/index.md +++ b/docs/index.md @@ -36,7 +36,7 @@ A comprehensive solution for managing, securing, and accessing Model Context Pro - **High Availability**: Production-ready deployment patterns ### Advanced Security & Authentication -- **OAuth 2.0 Integration**: Amazon Cognito, Google, GitHub, and custom providers +- **OAuth 2.0 Integration**: Keycloak, Amazon Cognito, Microsoft Entra ID, Google, GitHub, and custom providers - **Fine-Grained Access Control**: Role-based permissions with scope management - **JWT Token Vending**: Secure token generation and validation - **Audit Logging**: Comprehensive security event tracking diff --git a/docs/installation.md b/docs/installation.md index 1940f399..9f1d43b9 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -6,7 +6,10 @@ Complete installation instructions for the MCP Gateway & Registry on various pla - **Node.js 16+**: Required for building the React frontend - **Docker & Docker Compose**: Container runtime and orchestration -- **Amazon Cognito**: Identity provider for authentication (see [Cognito Setup Guide](cognito.md)) +- **Authentication Provider**: Choose one of the following: + - **Keycloak**: Open-source identity management (see [Keycloak Integration](keycloak-integration.md)) + - **Amazon Cognito**: AWS managed authentication (see [Cognito Setup Guide](cognito.md)) + - **Microsoft Entra ID**: Azure Active Directory (see [Entra ID Setup Guide](entra-id-setup.md)) - **SSL Certificate**: Optional for HTTPS deployment in production ## Quick Start (5 Minutes) diff --git a/mkdocs.yml b/mkdocs.yml index 7ec489e5..f4d8b6a8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -105,6 +105,8 @@ nav: - Authentication & Security: - Authentication Guide: auth.md - Amazon Cognito Setup: cognito.md + - Microsoft Entra ID Setup: entra-id-setup.md + - Microsoft Entra ID Implementation: entra-id-implementation.md - Access Control & Scopes: scopes.md - JWT Token Vending: jwt-token-vending.md - Security Policy: SECURITY.md From 2b7d94332d710e16b9a9f24ad85e3adbd3f5be02 Mon Sep 17 00:00:00 2001 From: ryo Date: Thu, 30 Oct 2025 20:43:40 -0400 Subject: [PATCH 05/17] add comment --- auth_server/oauth2_providers.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/auth_server/oauth2_providers.yml b/auth_server/oauth2_providers.yml index e9ac8b9d..9dac7df7 100644 --- a/auth_server/oauth2_providers.yml +++ b/auth_server/oauth2_providers.yml @@ -84,9 +84,12 @@ providers: response_type: "code" grant_type: "authorization_code" # Entra ID specific claim mapping + # Set to determine which user info property returned from OpenID Provider to store as the User's username username_claim: "preferred_username" + # Set to determine which group attribute returned from OpenID Provider to filter for group permission groups_claim: "groups" email_claim: "email" + # Set to determine which user info property returned from OpenID Provider to store as the User's name name_claim: "name" # Optional: For sovereign clouds (Azure Government, Azure China, etc.) # authority: "https://login.microsoftonline.us/${ENTRA_TENANT_ID}" # US Government From 3bd5a55dac52ff8bd02efaa45f5b74da6a8d90fc Mon Sep 17 00:00:00 2001 From: "yulin.deng" <1016068291@qq.com> Date: Fri, 31 Oct 2025 18:20:10 +0800 Subject: [PATCH 06/17] Update to dynamic parameters --- .env.example | 29 ++++++++- auth_server/oauth2_providers.yml | 11 ++-- auth_server/providers/entra.py | 93 ++++++++++++++++++++--------- auth_server/providers/factory.py | 74 +++++++++++++++++++++-- docker-compose.override.yml.example | 61 +++++++++++++++++++ docker-compose.yml | 5 ++ docs/entra-id-implementation.md | 38 ++++++++---- docs/entra-id-setup.md | 11 ++++ 8 files changed, 269 insertions(+), 53 deletions(-) create mode 100644 docker-compose.override.yml.example diff --git a/.env.example b/.env.example index 47fc6663..2202f6d5 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # ============================================================================= -# MCP Gateway Registry - Environment Configuration Sample +# MCP Gateway Registry - Environment Configuration Sample # ============================================================================= # Copy this file to .env and update with your actual values # Never commit real credentials to version control @@ -30,8 +30,8 @@ AUTH_SERVER_EXTERNAL_URL=https://your-domain.com # ============================================================================= # AUTHENTICATION PROVIDER CONFIGURATION # ============================================================================= -# Choose authentication provider: 'cognito' or 'keycloak' -AUTH_PROVIDER=keycloak +# Choose authentication provider: 'cognito' or 'keycloak' or 'entra_id' +AUTH_PROVIDER=entra_id # ============================================================================= # KEYCLOAK CONFIGURATION (if AUTH_PROVIDER=keycloak) @@ -137,6 +137,29 @@ ENTRA_CLIENT_SECRET=your_client_secret_value_here # Germany Cloud: # ENTRA_AUTHORITY=https://login.microsoftonline.de/your_tenant_id_here +# Entra ID specific claim mapping +# Set to determine which user info property returned from OpenID Provider to store as the User's username +ENTRA_USERNAME_CLAIM=preferred_username + +# Set to determine which group attribute returned from OpenID Provider to filter for group permission +ENTRA_GROUPS_CLAIM=groups + +# Set to determine which claim from Entra ID token to use as the User's email address +# - 'email': Standard email claim (requires email scope) +# - 'upn': User Principal Name, format: user@domain.com (recommended for hybrid/on-prem AD sync) +# - 'preferred_username': Preferred username, often same as UPN +# - 'unique_name': Legacy claim for backward compatibility +# Note: Not all Azure AD configurations return all claims. Choose based on your tenant settings. +ENTRA_EMAIL_CLAIM=upn + +# Set to determine which user info property returned from OpenID Provider to store as the User's name +ENTRA_NAME_CLAIM=name + +# Optional: OAuth2 configuration +#ENTRA_SCOPES= + +#ENTRA_GRANT_TYPE=authorization_code + # ============================================================================= # APPLICATION SECURITY # ============================================================================= diff --git a/auth_server/oauth2_providers.yml b/auth_server/oauth2_providers.yml index 9dac7df7..68b8e97c 100644 --- a/auth_server/oauth2_providers.yml +++ b/auth_server/oauth2_providers.yml @@ -84,13 +84,10 @@ providers: response_type: "code" grant_type: "authorization_code" # Entra ID specific claim mapping - # Set to determine which user info property returned from OpenID Provider to store as the User's username - username_claim: "preferred_username" - # Set to determine which group attribute returned from OpenID Provider to filter for group permission - groups_claim: "groups" - email_claim: "email" - # Set to determine which user info property returned from OpenID Provider to store as the User's name - name_claim: "name" + username_claim: "${ENTRA_USERNAME_CLAIM}" + groups_claim: "${ENTRA_GROUPS_CLAIM}" + email_claim: "${ENTRA_EMAIL_CLAIM}" + name_claim: "${ENTRA_NAME_CLAIM}" # Optional: For sovereign clouds (Azure Government, Azure China, etc.) # authority: "https://login.microsoftonline.us/${ENTRA_TENANT_ID}" # US Government # authority: "https://login.chinacloudapi.cn/${ENTRA_TENANT_ID}" # China diff --git a/auth_server/providers/entra.py b/auth_server/providers/entra.py index 0094a7e7..3f7293d2 100644 --- a/auth_server/providers/entra.py +++ b/auth_server/providers/entra.py @@ -22,7 +22,13 @@ def __init__( tenant_id: str, client_id: str, client_secret: str, - authority: Optional[str] = None + authority: Optional[str] = None, + scopes: Optional[list] = None, + grant_type: str = "authorization_code", + username_claim: str = "preferred_username", + groups_claim: str = "groups", + email_claim: str = "email", + name_claim: str = "name" ): """Initialize Entra ID provider. @@ -31,6 +37,12 @@ def __init__( client_id: Azure AD application (client) ID client_secret: Azure AD client secret authority: Optional custom authority URL (defaults to global Azure AD) + scopes: List of OAuth2 scopes (default: ['openid', 'profile', 'email', 'User.Read']) + grant_type: OAuth2 grant type (default: 'authorization_code') + username_claim: Claim to use for username (default: 'preferred_username') + groups_claim: Claim to use for groups (default: 'groups') + email_claim: Claim to use for email (default: 'email') + name_claim: Claim to use for display name (default: 'name') """ self.tenant_id = tenant_id self.client_id = client_id @@ -41,16 +53,28 @@ def __init__( self._jwks_cache_time: float = 0 self._jwks_cache_ttl: int = 3600 # 1 hour - # Microsoft Entra ID endpoints + # Microsoft Entra ID endpoints - construct from authority self.authority = authority or f"https://login.microsoftonline.com/{tenant_id}" - self.token_url = f"{self.authority}/oauth2/v2.0/token" self.auth_url = f"{self.authority}/oauth2/v2.0/authorize" + self.token_url = f"{self.authority}/oauth2/v2.0/token" self.jwks_url = f"{self.authority}/discovery/v2.0/keys" self.logout_url = f"{self.authority}/oauth2/v2.0/logout" self.userinfo_url = "https://graph.microsoft.com/v1.0/me" self.issuer = f"https://login.microsoftonline.com/{tenant_id}/v2.0" + + # OAuth2 configuration - injected via constructor + self.scopes = scopes or ['openid', 'profile', 'email', 'User.Read'] + self.grant_type = grant_type + + # Claim mappings configuration + self.username_claim = username_claim + self.groups_claim = groups_claim + self.email_claim = email_claim + self.name_claim = name_claim - logger.debug(f"Initialized Entra ID provider for tenant '{tenant_id}'") + logger.debug(f"Initialized Entra ID provider for tenant '{tenant_id}' with " + f"scopes={self.scopes}, grant_type={self.grant_type}, claims: " + f"username={username_claim}, email={email_claim}, groups={groups_claim}, name={name_claim}") def validate_token( self, @@ -97,23 +121,21 @@ def validate_token( } ) - logger.debug(f"Token validation successful for user:" - f" {claims.get('preferred_username', claims.get('upn', 'unknown'))}") - - # Extract user info from claims - # Entra ID tokens can have different claim structures - username = ( - claims.get('preferred_username') or - claims.get('upn') or - claims.get('unique_name') or - claims.get('email') or - claims.get('sub') - ) + # Extract user info from claims using configured claim mappings + username = claims.get(self.username_claim) or claims.get('sub') # Fallback to 'sub' as last resort + email = claims.get(self.email_claim) + + # Extract groups - handle both string and list claims + groups_raw = claims.get(self.groups_claim, []) + groups = groups_raw if isinstance(groups_raw, list) else [] + + logger.debug(f"Token validation successful for user: {username}") + return { 'valid': True, 'username': username, - 'email': claims.get('email') or claims.get('upn') or claims.get('preferred_username'), - 'groups': claims.get('groups', []), + 'email': email, + 'groups': groups, 'scopes': claims.get('scp', '').split() if claims.get('scp') else [], 'client_id': claims.get('azp', claims.get('appid', self.client_id)), 'method': 'entra_id', @@ -164,12 +186,12 @@ def exchange_code_for_token( logger.debug("Exchanging authorization code for token") data = { - 'grant_type': 'authorization_code', + 'grant_type': self.grant_type, 'code': code, 'client_id': self.client_id, 'client_secret': self.client_secret, 'redirect_uri': redirect_uri, - 'scope': 'openid profile email User.Read' + 'scope': ' '.join(self.scopes) } headers = {'Content-Type': 'application/x-www-form-urlencoded'} @@ -204,19 +226,27 @@ def get_user_info( response.raise_for_status() user_info = response.json() - logger.debug(f"User info retrieved for: {user_info.get('userPrincipalName', 'unknown')}") - - # Transform Microsoft Graph response to standard format - return { - 'username': user_info.get('userPrincipalName'), - 'email': user_info.get('mail') or user_info.get('userPrincipalName'), - 'name': user_info.get('displayName'), + + # Map Microsoft Graph response to configured claim names + username_value = user_info.get('userPrincipalName') + email_value = user_info.get('mail') or user_info.get('userPrincipalName') + name_value = user_info.get('displayName') + + logger.debug(f"User info retrieved for: {username_value}") + + # Transform Microsoft Graph response to standard format using configured mappings + result = { + 'username': username_value, + 'email': email_value, + 'name': name_value, 'given_name': user_info.get('givenName'), 'family_name': user_info.get('surname'), 'id': user_info.get('id'), 'job_title': user_info.get('jobTitle'), 'office_location': user_info.get('officeLocation') } + + return result except requests.RequestException as e: logger.error(f"Failed to get user info: {e}") @@ -233,7 +263,7 @@ def get_auth_url( params = {'client_id': self.client_id, 'response_type': 'code', - 'scope': scope or 'openid profile email User.Read', + 'scope': scope or ' '.join(self.scopes), 'redirect_uri': redirect_uri, 'state': state, 'response_mode': 'query' @@ -264,12 +294,17 @@ def refresh_token( try: logger.debug("Refreshing access token") + # Include offline_access scope for refresh tokens + refresh_scopes = self.scopes.copy() + if 'offline_access' not in refresh_scopes: + refresh_scopes.append('offline_access') + data = { 'grant_type': 'refresh_token', 'refresh_token': refresh_token, 'client_id': self.client_id, 'client_secret': self.client_secret, - 'scope': 'openid profile email User.Read offline_access' + 'scope': ' '.join(refresh_scopes) } headers = {'Content-Type': 'application/x-www-form-urlencoded'} diff --git a/auth_server/providers/factory.py b/auth_server/providers/factory.py index e6bd5fd1..0e61f2c2 100644 --- a/auth_server/providers/factory.py +++ b/auth_server/providers/factory.py @@ -2,7 +2,10 @@ import logging import os -from typing import Optional +import yaml +from pathlib import Path +from string import Template +from typing import Optional, Dict, Any from .base import AuthProvider from .cognito import CognitoProvider @@ -17,6 +20,49 @@ logger = logging.getLogger(__name__) +def _load_oauth2_config() -> Dict[str, Any]: + """Load OAuth2 providers configuration from oauth2_providers.yml. + + Returns: + Dict containing OAuth2 providers configuration + """ + try: + oauth2_file = Path(__file__).parent.parent / "oauth2_providers.yml" + with open(oauth2_file, 'r') as f: + config = yaml.safe_load(f) + # Substitute environment variables in configuration + processed_config = _substitute_env_vars(config) + logger.debug("Successfully loaded OAuth2 configuration") + return processed_config + except Exception as e: + logger.error(f"Failed to load OAuth2 configuration: {e}") + return {"providers": {}, "session": {}, "registry": {}} + + +def _substitute_env_vars(config: Any) -> Any: + """Recursively substitute environment variables in configuration. + + Args: + config: Configuration value (dict, list, or str) + + Returns: + Configuration with environment variables substituted + """ + if isinstance(config, dict): + return {k: _substitute_env_vars(v) for k, v in config.items()} + elif isinstance(config, list): + return [_substitute_env_vars(item) for item in config] + elif isinstance(config, str) and "${" in config: + try: + template = Template(config) + return template.substitute(os.environ) + except KeyError as e: + logger.warning(f"Environment variable not found for template {config}: {e}") + return config + else: + return config + + def get_auth_provider( provider_type: Optional[str] = None ) -> AuthProvider: @@ -126,7 +172,11 @@ def _create_cognito_provider() -> CognitoProvider: def _create_entra_id_provider() -> EntraIDProvider: """Create and configure Microsoft Entra ID provider.""" - # Required configuration + # Load OAuth2 configuration + oauth2_config = _load_oauth2_config() + entra_config = oauth2_config.get('providers', {}).get('entra_id', {}) + + # Required configuration from environment variables tenant_id = os.environ.get('ENTRA_TENANT_ID') client_id = os.environ.get('ENTRA_CLIENT_ID') client_secret = os.environ.get('ENTRA_CLIENT_SECRET') @@ -134,6 +184,16 @@ def _create_entra_id_provider() -> EntraIDProvider: # Optional configuration authority = os.environ.get('ENTRA_AUTHORITY') + # OAuth2 configuration from oauth2_providers.yml with fallbacks + scopes = entra_config.get('scopes') + grant_type = entra_config.get('grant_type') + + # Optional claim mappings from oauth2_providers.yml + username_claim = entra_config.get('username_claim') + groups_claim = entra_config.get('groups_claim') + email_claim = entra_config.get('email_claim') + name_claim = entra_config.get('name_claim') + # Validate required configuration missing_vars = [] if not tenant_id: @@ -149,13 +209,19 @@ def _create_entra_id_provider() -> EntraIDProvider: "Please set these environment variables." ) - logger.info(f"Initializing Entra ID provider for tenant '{tenant_id}'") + logger.info(f"Initializing Entra ID provider for tenant '{tenant_id}' with scopes={scopes}, grant_type={grant_type}") return EntraIDProvider( tenant_id=tenant_id, client_id=client_id, client_secret=client_secret, - authority=authority + authority=authority, + scopes=scopes, + grant_type=grant_type, + username_claim=username_claim, + groups_claim=groups_claim, + email_claim=email_claim, + name_claim=name_claim ) diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example new file mode 100644 index 00000000..0b1b9cd1 --- /dev/null +++ b/docker-compose.override.yml.example @@ -0,0 +1,61 @@ +# docker-compose.override.yml.example +# +# This file provides examples for overriding the default docker-compose.yml configuration. +# Copy this file to docker-compose.override.yml and customize it for your environment. +# +# Usage: +# cp docker-compose.override.yml.example docker-compose.override.yml +# # Edit docker-compose.override.yml with your settings +# docker-compose up -d +# +# Note: docker-compose.override.yml is automatically loaded by docker-compose +# and will override settings from docker-compose.yml + +services: + # Example: Override auth-server configuration for Microsoft Entra ID + auth-server: + environment: + # Set the authentication provider + - AUTH_PROVIDER=entra_id + + # Microsoft Entra ID configuration + - ENTRA_TENANT_ID=your-tenant-id-here + - ENTRA_CLIENT_ID=your-client-id-here + - ENTRA_CLIENT_SECRET=your-client-secret-here + + # Optional: Custom authority for sovereign clouds + # - ENTRA_AUTHORITY=https://login.microsoftonline.us/your-tenant-id # US Government + # - ENTRA_AUTHORITY=https://login.chinacloudapi.cn/your-tenant-id # China + + # Optional: Custom claim mappings + # These determine which claims from the ID token are used for user information + # - ENTRA_USERNAME_CLAIM=upn # Default: preferred_username + # - ENTRA_GROUPS_CLAIM=groups # Default: groups + # - ENTRA_EMAIL_CLAIM=upn # Default: email + # - ENTRA_NAME_CLAIM=name # Default: name + + # Example: Disable Keycloak if using Entra ID + # keycloak: + # profiles: + # - disabled + + # Example: Override registry configuration + # registry: + # environment: + # - REGISTRY_URL=https://your-custom-domain.com + + # Example: Override metrics service configuration + # metrics-service: + # environment: + # - DATABASE_URL=postgresql://user:pass@host:5432/metrics + + # Example: Add custom volumes + # auth-server: + # volumes: + # - ./custom-config:/app/custom-config:ro + + # Example: Expose additional ports + # auth-server: + # ports: + # - "9888:8888" # Expose on different host port + diff --git a/docker-compose.yml b/docker-compose.yml index 7f210cc9..3cc2f6bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -120,6 +120,11 @@ services: - ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID} - ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET} - ENTRA_AUTHORITY=${ENTRA_AUTHORITY} + # Optional: Claim mappings - customize based on your Entra ID setup + - ENTRA_USERNAME_CLAIM=${ENTRA_USERNAME_CLAIM:-preferred_username} + - ENTRA_GROUPS_CLAIM=${ENTRA_GROUPS_CLAIM:-groups} + - ENTRA_EMAIL_CLAIM=${ENTRA_EMAIL_CLAIM:-upn} + - ENTRA_NAME_CLAIM=${ENTRA_NAME_CLAIM:-name} ports: - "8888:8888" volumes: diff --git a/docs/entra-id-implementation.md b/docs/entra-id-implementation.md index 642009cb..75cefdb0 100644 --- a/docs/entra-id-implementation.md +++ b/docs/entra-id-implementation.md @@ -33,7 +33,13 @@ def __init__( tenant_id: str, client_id: str, client_secret: str, - authority: Optional[str] = None + authority: Optional[str] = None, + scopes: Optional[list] = None, + grant_type: str = "authorization_code", + username_claim: str = "preferred_username", + groups_claim: str = "groups", + email_claim: str = "email", + name_claim: str = "name" ): ``` @@ -42,6 +48,12 @@ def __init__( - `client_id`: Azure AD application (client) ID - `client_secret`: Azure AD client secret - `authority`: Optional custom authority URL (defaults to global Azure AD) +- `scopes`: List of OAuth2 scopes (default: `['openid', 'profile', 'email', 'User.Read']`) +- `grant_type`: OAuth2 grant type (default: `'authorization_code'`) +- `username_claim`: Claim to use for username (default: `'preferred_username'`) +- `groups_claim`: Claim to use for groups (default: `'groups'`) +- `email_claim`: Claim to use for email (default: `'email'`) +- `name_claim`: Claim to use for display name (default: `'name'`) **Endpoints Configured:** - `token_url`: `{authority}/oauth2/v2.0/token` @@ -70,12 +82,13 @@ def validate_token(self, token: str, **kwargs: Any) -> Dict[str, Any]: - `api://{client_id}` (e.g., `api://12345678-1234-1234-1234-123456789012`) **User Claim Resolution:** -The implementation attempts multiple claim fields for username resolution: -- `preferred_username` -- `upn` (User Principal Name) -- `unique_name` -- `email` -- `sub` (Subject) as fallback +The implementation uses configurable claim mappings for user information extraction: +- **Username**: Configurable via `username_claim` (default: `preferred_username`) +- **Email**: Configurable via `email_claim` (default: `email`) +- **Groups**: Configurable via `groups_claim` (default: `groups`) +- **Name**: Configurable via `name_claim` (default: `name`) + +The implementation handles both string and list claims for groups and falls back to 'sub' claim for username if the configured claim is not found. ### JWKS Caching @@ -213,9 +226,14 @@ ENTRA_CLIENT_SECRET=your-client-secret-value ENTRA_TENANT_ID=your-tenant-id-or-common # Optional - For sovereign clouds -ENTRA_AUTHORITY=https://login.microsoftonline.us # US Government -# or -ENTRA_AUTHORITY=https://login.chinacloudapi.cn # China +# ENTRA_AUTHORITY=https://login.microsoftonline.us # US Government +# ENTRA_AUTHORITY=https://login.chinacloudapi.cn # China + +# Optional - Custom claim mappings (defaults are shown) +ENTRA_USERNAME_CLAIM=preferred_username +ENTRA_GROUPS_CLAIM=groups +ENTRA_EMAIL_CLAIM=email +ENTRA_NAME_CLAIM=name ``` ## Error Handling diff --git a/docs/entra-id-setup.md b/docs/entra-id-setup.md index 1357ffeb..644a41b7 100644 --- a/docs/entra-id-setup.md +++ b/docs/entra-id-setup.md @@ -75,6 +75,17 @@ ENTRA_TENANT_ID=your-tenant-id-or-common # ENTRA_AUTHORITY=https://login.chinacloudapi.cn # China ``` +**Optional Claim Mapping Environment Variables:** +```bash +# Optional: Custom claim mappings (defaults are shown) +ENTRA_USERNAME_CLAIM=preferred_username +ENTRA_GROUPS_CLAIM=groups +ENTRA_EMAIL_CLAIM=upn # upn or email +ENTRA_NAME_CLAIM=name +``` + +**Note**: URLs, scopes, and default claim mappings are configured in `auth_server/oauth2_providers.yml`. Environment variables for claim mappings are only needed if you want to override the defaults. + ## Step 5: Enable Entra ID Provider Ensure the Entra ID provider is enabled in the `auth_server/oauth2_providers.yml` configuration: From 4868c3e66bc748dbfb271fcf583625fe230a5ddf Mon Sep 17 00:00:00 2001 From: ryo Date: Fri, 31 Oct 2025 13:16:36 -0400 Subject: [PATCH 07/17] add docker-compose.override.yml to gitignore --- .gitignore | 3 +++ docker-compose.override.yml.example | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 755f06df..cb065af8 100644 --- a/.gitignore +++ b/.gitignore @@ -139,6 +139,9 @@ celerybeat.pid .env.user .env.docker +# Docker artifacts +docker-compose.override.yml + # Configuration files with sensitive data credentials-provider/agentcore-auth/config.yaml credentials-provider/oauth/config.yaml diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example index 0b1b9cd1..a509bed0 100644 --- a/docker-compose.override.yml.example +++ b/docker-compose.override.yml.example @@ -38,6 +38,9 @@ services: # keycloak: # profiles: # - disabled + # keycloak-db: + # profiles: + # - disabled # Example: Override registry configuration # registry: From e053768daf43e59819023a323bcaff4b25020a05 Mon Sep 17 00:00:00 2001 From: "yulin.deng" <1016068291@qq.com> Date: Tue, 4 Nov 2025 13:48:53 +0800 Subject: [PATCH 08/17] update user info --- .env.example | 27 +- auth_server/__init__.py | 0 auth_server/oauth2_providers.yml | 10 +- auth_server/providers/base.py | 70 ++-- auth_server/providers/entra.py | 244 ++++++++++--- auth_server/providers/factory.py | 130 +++---- auth_server/server.py | 540 +++++++++++++--------------- auth_server/utils/__init__.py | 4 + auth_server/utils/config_loader.py | 261 ++++++++++++++ docker-compose.override.yml.example | 17 +- docker-compose.yml | 2 +- docs/auth.md | 3 + docs/configuration.md | 45 ++- docs/entra-id-implementation.md | 270 ++++++++++++-- docs/entra-id-setup.md | 82 ++++- uv.lock | 20 +- 16 files changed, 1193 insertions(+), 532 deletions(-) create mode 100644 auth_server/__init__.py create mode 100644 auth_server/utils/__init__.py create mode 100644 auth_server/utils/config_loader.py diff --git a/.env.example b/.env.example index 2202f6d5..2bbee80b 100644 --- a/.env.example +++ b/.env.example @@ -124,19 +124,6 @@ ENTRA_CLIENT_ID=your_application_client_id_here # Format: xxx~xxxxxxxxxxxxxxxxxxxxxxxxxxxx ENTRA_CLIENT_SECRET=your_client_secret_value_here -# Optional: Custom Authority URL (for sovereign clouds) -# Leave commented for global Azure cloud (default) -# Uncomment and set if using sovereign clouds: -# -# US Government Cloud: -# ENTRA_AUTHORITY=https://login.microsoftonline.us/your_tenant_id_here -# -# China Cloud (operated by 21Vianet): -# ENTRA_AUTHORITY=https://login.chinacloudapi.cn/your_tenant_id_here -# -# Germany Cloud: -# ENTRA_AUTHORITY=https://login.microsoftonline.de/your_tenant_id_here - # Entra ID specific claim mapping # Set to determine which user info property returned from OpenID Provider to store as the User's username ENTRA_USERNAME_CLAIM=preferred_username @@ -155,10 +142,16 @@ ENTRA_EMAIL_CLAIM=upn # Set to determine which user info property returned from OpenID Provider to store as the User's name ENTRA_NAME_CLAIM=name -# Optional: OAuth2 configuration -#ENTRA_SCOPES= +# Microsoft Graph API Base URL +# Used for accessing Microsoft Graph API endpoints (user info, groups, etc.) +#ENTRA_GRAPH_URL=https://graph.microsoft.com + +# M2M (Machine-to-Machine) Default Scope +#ENTRA_M2M_SCOPE=https://graph.microsoft.com/.default -#ENTRA_GRANT_TYPE=authorization_code +# id token: OIDC get user info, JWT +# access token: OAuth 2.0, Graph API +ENTRA_ROLE_TOKEN_KIND=id # ============================================================================= # APPLICATION SECURITY @@ -215,4 +208,4 @@ GITHUB_ORG=agentic-community # COGNITO_DOMAIN=your-custom-domain.auth.{region}.amazoncognito.com # Optional: Additional service-specific environment variables -# Add any additional configuration variables your deployment requires \ No newline at end of file +# Add any additional configuration variables your deployment requires diff --git a/auth_server/__init__.py b/auth_server/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/auth_server/oauth2_providers.yml b/auth_server/oauth2_providers.yml index 68b8e97c..c59bc999 100644 --- a/auth_server/oauth2_providers.yml +++ b/auth_server/oauth2_providers.yml @@ -78,6 +78,7 @@ providers: tenant_id: "${ENTRA_TENANT_ID}" auth_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/authorize" token_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token" + jwks_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/discovery/v2.0/keys" user_info_url: "https://graph.microsoft.com/v1.0/me" logout_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/logout" scopes: ["openid", "profile", "email", "User.Read"] @@ -88,10 +89,11 @@ providers: groups_claim: "${ENTRA_GROUPS_CLAIM}" email_claim: "${ENTRA_EMAIL_CLAIM}" name_claim: "${ENTRA_NAME_CLAIM}" - # Optional: For sovereign clouds (Azure Government, Azure China, etc.) - # authority: "https://login.microsoftonline.us/${ENTRA_TENANT_ID}" # US Government - # authority: "https://login.chinacloudapi.cn/${ENTRA_TENANT_ID}" # China - enabled: true # Disabled by default + # Microsoft Graph API base URL (for sovereign clouds) + graph_url: "${ENTRA_GRAPH_URL:-https://graph.microsoft.com}" + # M2M (Machine-to-Machine) default scope + m2m_scope: "${ENTRA_M2M_SCOPE:-https://graph.microsoft.com/.default}" + enabled: true # Default session settings session: diff --git a/auth_server/providers/base.py b/auth_server/providers/base.py index 33f9781e..70e7bd6f 100644 --- a/auth_server/providers/base.py +++ b/auth_server/providers/base.py @@ -14,12 +14,12 @@ class AuthProvider(ABC): """Abstract base class for authentication providers.""" - + @abstractmethod def validate_token( - self, - token: str, - **kwargs: Any + self, + token: str, + **kwargs: Any ) -> Dict[str, Any]: """Validate an access token and return user info. @@ -42,7 +42,7 @@ def validate_token( ValueError: If token validation fails """ pass - + @abstractmethod def get_jwks(self) -> Dict[str, Any]: """Get JSON Web Key Set for token validation. @@ -54,12 +54,12 @@ def get_jwks(self) -> Dict[str, Any]: ValueError: If JWKS cannot be retrieved """ pass - + @abstractmethod def exchange_code_for_token( - self, - code: str, - redirect_uri: str + self, + code: str, + redirect_uri: str ) -> Dict[str, Any]: """Exchange authorization code for access token. @@ -79,17 +79,19 @@ def exchange_code_for_token( ValueError: If code exchange fails """ pass - + @abstractmethod def get_user_info( - self, - access_token: str + self, + access_token: str, + id_token: Optional[str] = None ) -> Dict[str, Any]: """Get user information from access token. Args: - access_token: Valid access token - + access_token: OAuth2 access token (required for Graph API calls) + id_token: Optional ID token (preferred for user identity extraction) + Returns: Dictionary containing user information: - username: User's username @@ -101,13 +103,13 @@ def get_user_info( ValueError: If user info cannot be retrieved """ pass - + @abstractmethod def get_auth_url( - self, - redirect_uri: str, - state: str, - scope: Optional[str] = None + self, + redirect_uri: str, + state: str, + scope: Optional[str] = None ) -> str: """Get authorization URL for OAuth2 flow. @@ -120,11 +122,11 @@ def get_auth_url( Full authorization URL """ pass - + @abstractmethod def get_logout_url( - self, - redirect_uri: str + self, + redirect_uri: str ) -> str: """Get logout URL. @@ -135,11 +137,11 @@ def get_logout_url( Full logout URL """ pass - + @abstractmethod def refresh_token( - self, - refresh_token: str + self, + refresh_token: str ) -> Dict[str, Any]: """Refresh an access token using a refresh token. @@ -153,11 +155,11 @@ def refresh_token( ValueError: If token refresh fails """ pass - + @abstractmethod def validate_m2m_token( - self, - token: str + self, + token: str ) -> Dict[str, Any]: """Validate a machine-to-machine token. @@ -171,13 +173,13 @@ def validate_m2m_token( ValueError: If token validation fails """ pass - + @abstractmethod def get_m2m_token( - self, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, - scope: Optional[str] = None + self, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + scope: Optional[str] = None ) -> Dict[str, Any]: """Get a machine-to-machine token using client credentials. @@ -192,4 +194,4 @@ def get_m2m_token( Raises: ValueError: If token generation fails """ - pass \ No newline at end of file + pass diff --git a/auth_server/providers/entra.py b/auth_server/providers/entra.py index 3f7293d2..9910a638 100644 --- a/auth_server/providers/entra.py +++ b/auth_server/providers/entra.py @@ -1,4 +1,5 @@ import logging +import os import time import jwt import requests @@ -22,7 +23,13 @@ def __init__( tenant_id: str, client_id: str, client_secret: str, - authority: Optional[str] = None, + auth_url: str, + token_url: str, + jwks_url: str, + logout_url: str, + userinfo_url: str, + graph_url: Optional[str] = None, + m2m_scope: Optional[str] = None, scopes: Optional[list] = None, grant_type: str = "authorization_code", username_claim: str = "preferred_username", @@ -36,7 +43,13 @@ def __init__( tenant_id: Azure AD tenant ID (or 'common' for multi-tenant) client_id: Azure AD application (client) ID client_secret: Azure AD client secret - authority: Optional custom authority URL (defaults to global Azure AD) + auth_url: Authorization endpoint URL + token_url: Token endpoint URL + jwks_url: JWKS endpoint URL + logout_url: Logout endpoint URL + userinfo_url: User info endpoint URL + graph_url: Microsoft Graph API base URL (default: 'https://graph.microsoft.com') + m2m_scope: Default scope for M2M authentication (default: 'https://graph.microsoft.com/.default') scopes: List of OAuth2 scopes (default: ['openid', 'profile', 'email', 'User.Read']) grant_type: OAuth2 grant type (default: 'authorization_code') username_claim: Claim to use for username (default: 'preferred_username') @@ -53,19 +66,20 @@ def __init__( self._jwks_cache_time: float = 0 self._jwks_cache_ttl: int = 3600 # 1 hour - # Microsoft Entra ID endpoints - construct from authority - self.authority = authority or f"https://login.microsoftonline.com/{tenant_id}" - self.auth_url = f"{self.authority}/oauth2/v2.0/authorize" - self.token_url = f"{self.authority}/oauth2/v2.0/token" - self.jwks_url = f"{self.authority}/discovery/v2.0/keys" - self.logout_url = f"{self.authority}/oauth2/v2.0/logout" - self.userinfo_url = "https://graph.microsoft.com/v1.0/me" + # Microsoft Entra ID endpoints - from configuration + self.auth_url = auth_url + self.token_url = token_url + self.jwks_url = jwks_url + self.logout_url = logout_url + self.userinfo_url = userinfo_url + self.graph_url = graph_url or "https://graph.microsoft.com" + self.m2m_scope = m2m_scope or f"{self.graph_url}/.default" self.issuer = f"https://login.microsoftonline.com/{tenant_id}/v2.0" - + # OAuth2 configuration - injected via constructor self.scopes = scopes or ['openid', 'profile', 'email', 'User.Read'] self.grant_type = grant_type - + # Claim mappings configuration self.username_claim = username_claim self.groups_claim = groups_claim @@ -73,8 +87,8 @@ def __init__( self.name_claim = name_claim logger.debug(f"Initialized Entra ID provider for tenant '{tenant_id}' with " - f"scopes={self.scopes}, grant_type={self.grant_type}, claims: " - f"username={username_claim}, email={email_claim}, groups={groups_claim}, name={name_claim}") + f"scopes={self.scopes}, grant_type={self.grant_type}, graph_url={self.graph_url}, " + f"claims: username={username_claim}, email={email_claim}, groups={groups_claim}, name={name_claim}") def validate_token( self, @@ -124,11 +138,11 @@ def validate_token( # Extract user info from claims using configured claim mappings username = claims.get(self.username_claim) or claims.get('sub') # Fallback to 'sub' as last resort email = claims.get(self.email_claim) - + # Extract groups - handle both string and list claims groups_raw = claims.get(self.groups_claim, []) groups = groups_raw if isinstance(groups_raw, list) else [] - + logger.debug(f"Token validation successful for user: {username}") return { @@ -213,42 +227,186 @@ def exchange_code_for_token( logger.error(f"Response text: {e.response.text}") raise ValueError(f"Token exchange failed: {e}") - def get_user_info( + def _extract_user_info_from_token( + self, + token: str, + token_type: str + ) -> Optional[Dict[str, Any]]: + """Extract user information from JWT token. + + Args: + token: JWT token string + token_type: Type of token ('id' or 'access') + + Returns: + Dict with user info or None if extraction fails + """ + try: + logger.debug(f"Extracting user info from {token_type} token") + token_claims = jwt.decode(token, options={"verify_signature": False}) + logger.debug(f"Token claims extracted: {list(token_claims.keys())}") + + # Extract username with fallback chain + username = ( + token_claims.get(self.username_claim) or + token_claims.get('preferred_username') or + token_claims.get('upn') or + token_claims.get('unique_name') + ) + # Extract email + email = ( + token_claims.get(self.email_claim) or + token_claims.get('upn') + ) + # Extract name + name = (token_claims.get(self.name_claim) or + token_claims.get('displayName') or + token_claims.get('given_name')) + user_info = { + 'username': username, + 'email': email, + 'name': name, + 'id': token_claims.get('oid') or token_claims.get('sub'), + 'groups': [] + } + logger.info(f"User info extracted from {token_type} token: {username}") + return user_info + + except Exception as e: + logger.warning(f"Failed to extract user info from {token_type} token: {e}") + return None + + def _fetch_user_info_from_graph( self, access_token: str ) -> Dict[str, Any]: - """Get user information from Microsoft Graph API.""" + """Fetch user information from Microsoft Graph API. + + Args: + access_token: OAuth2 access token + + Returns: + Dict containing user information + + Raises: + ValueError: If Graph API request fails + """ try: - logger.debug("Fetching user info from Microsoft Graph") - + logger.debug("Fetching user info from Microsoft Graph API") headers = {'Authorization': f'Bearer {access_token}'} response = requests.get(self.userinfo_url, headers=headers, timeout=10) response.raise_for_status() + graph_data = response.json() - user_info = response.json() - - # Map Microsoft Graph response to configured claim names - username_value = user_info.get('userPrincipalName') - email_value = user_info.get('mail') or user_info.get('userPrincipalName') - name_value = user_info.get('displayName') - - logger.debug(f"User info retrieved for: {username_value}") - - # Transform Microsoft Graph response to standard format using configured mappings - result = { - 'username': username_value, - 'email': email_value, - 'name': name_value, - 'given_name': user_info.get('givenName'), - 'family_name': user_info.get('surname'), - 'id': user_info.get('id'), - 'job_title': user_info.get('jobTitle'), - 'office_location': user_info.get('officeLocation') + # Map Microsoft Graph response to standard format + username = graph_data.get('userPrincipalName') + email = graph_data.get('mail') or graph_data.get('userPrincipalName') + name = graph_data.get('displayName') + + user_info = { + 'username': username, + 'email': email, + 'name': name, + 'given_name': graph_data.get('givenName'), + 'family_name': graph_data.get('surname'), + 'id': graph_data.get('id'), + 'job_title': graph_data.get('jobTitle'), + 'office_location': graph_data.get('officeLocation'), + 'groups': [] } - - return result + + logger.info(f"User info retrieved from Graph API: {username}") + return user_info except requests.RequestException as e: + logger.error(f"Failed to fetch user info from Graph API: {e}") + raise ValueError(f"Graph API request failed: {e}") + + def get_user_groups( + self, + access_token: str + ) -> list: + """Get user's group memberships from Microsoft Graph API. + + Args: + access_token: OAuth2 access token + + Returns: + List of group display names + """ + try: + logger.debug("Fetching user groups from Graph API") + headers = {'Authorization': f'Bearer {access_token}'} + groups_url = ( + f"{self.graph_url}/v1.0/me/transitiveMemberOf/microsoft.graph.group?" + "$count=true&$select=id,displayName" + ) + response = requests.get(groups_url, headers=headers, timeout=10) + response.raise_for_status() + groups_data = response.json() + + # Extract group display names + groups = [group.get('displayName') for group in groups_data.get('value', [])] + logger.info(f"Retrieved {groups} groups for user") + return groups + + except Exception as e: + logger.warning(f"Failed to fetch user groups: {e}") + return [] + + def get_user_info( + self, + access_token: str, + id_token: Optional[str] = None + ) -> Dict[str, Any]: + """Get user information from token or Microsoft Graph API. + + This method supports flexible user info extraction: + 1. Extract from id_token (preferred) or access_token based on ENTRA_TOKEN_KIND config + 2. Fallback to Microsoft Graph API if token extraction fails + 3. Groups are automatically included (fetched from Graph API using access_token) + + Args: + access_token: OAuth2 access token (required for Graph API calls) + id_token: Optional ID token (preferred for user identity extraction) + + Returns: + Dict containing user information with keys: + - username: User's principal name or email + - email: User's email address + - name: User's display name + - id: User's unique identifier + - groups: List of group display names (from Graph API) + - Additional fields from Graph API (if fallback used) + """ + try: + token_kind = os.environ.get('ENTRA_TOKEN_KIND', 'id').lower() + user_info = None + + if token_kind == 'id' and id_token: + # Use ID token for user identity + logger.debug("Extracting user info from ID token") + user_info = self._extract_user_info_from_token(id_token, 'id') + elif token_kind == 'access' and access_token: + # Use access token + logger.debug("Extracting user info from access token") + user_info = self._extract_user_info_from_token(access_token, 'access') + else: + logger.warning(f"Token kind '{token_kind}' not available or token missing, falling back to Graph API") + + # Fallback to Microsoft Graph API if token extraction failed + if not user_info: + logger.info("Token extraction failed, using Graph API fallback") + user_info = self._fetch_user_info_from_graph(access_token) + + # Get user groups separately using access_token (required for Graph API) + groups = self.get_user_groups(access_token) + user_info["groups"] = groups + + logger.info(f"User info retrieved: {user_info.get('username')} with {len(groups)} groups") + return user_info + + except Exception as e: logger.error(f"Failed to get user info: {e}") raise ValueError(f"User info retrieval failed: {e}") @@ -342,14 +500,12 @@ def get_m2m_token( """ try: logger.debug("Requesting M2M token using client credentials") - # For Entra ID client credentials, use .default scope or specified scope - default_scope = f"https://graph.microsoft.com/.default" - + # Use configured M2M scope from parameters data = { 'grant_type': 'client_credentials', 'client_id': client_id or self.client_id, 'client_secret': client_secret or self.client_secret, - 'scope': scope or default_scope + 'scope': scope or self.m2m_scope } headers = {'Content-Type': 'application/x-www-form-urlencoded'} response = requests.post(self.token_url, data=data, headers=headers, timeout=10) diff --git a/auth_server/providers/factory.py b/auth_server/providers/factory.py index 0e61f2c2..3407bdbb 100644 --- a/auth_server/providers/factory.py +++ b/auth_server/providers/factory.py @@ -2,9 +2,8 @@ import logging import os -import yaml +import sys from pathlib import Path -from string import Template from typing import Optional, Dict, Any from .base import AuthProvider @@ -12,6 +11,12 @@ from .keycloak import KeycloakProvider from .entra import EntraIDProvider +# Add parent directory to path for utils import +parent_dir = Path(__file__).parent.parent +if str(parent_dir) not in sys.path: + sys.path.insert(0, str(parent_dir)) +from utils.config_loader import get_provider_config + logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", @@ -20,51 +25,8 @@ logger = logging.getLogger(__name__) -def _load_oauth2_config() -> Dict[str, Any]: - """Load OAuth2 providers configuration from oauth2_providers.yml. - - Returns: - Dict containing OAuth2 providers configuration - """ - try: - oauth2_file = Path(__file__).parent.parent / "oauth2_providers.yml" - with open(oauth2_file, 'r') as f: - config = yaml.safe_load(f) - # Substitute environment variables in configuration - processed_config = _substitute_env_vars(config) - logger.debug("Successfully loaded OAuth2 configuration") - return processed_config - except Exception as e: - logger.error(f"Failed to load OAuth2 configuration: {e}") - return {"providers": {}, "session": {}, "registry": {}} - - -def _substitute_env_vars(config: Any) -> Any: - """Recursively substitute environment variables in configuration. - - Args: - config: Configuration value (dict, list, or str) - - Returns: - Configuration with environment variables substituted - """ - if isinstance(config, dict): - return {k: _substitute_env_vars(v) for k, v in config.items()} - elif isinstance(config, list): - return [_substitute_env_vars(item) for item in config] - elif isinstance(config, str) and "${" in config: - try: - template = Template(config) - return template.substitute(os.environ) - except KeyError as e: - logger.warning(f"Environment variable not found for template {config}: {e}") - return config - else: - return config - - def get_auth_provider( - provider_type: Optional[str] = None + provider_type: Optional[str] = None ) -> AuthProvider: """Factory function to get the appropriate auth provider. @@ -79,9 +41,9 @@ def get_auth_provider( ValueError: If provider type is unknown or required config is missing """ provider_type = provider_type or os.environ.get('AUTH_PROVIDER', 'cognito') - + logger.info(f"Creating authentication provider: {provider_type}") - + if provider_type == 'keycloak': return _create_keycloak_provider() elif provider_type == 'cognito': @@ -120,7 +82,8 @@ def _create_keycloak_provider() -> KeycloakProvider: "Please set these environment variables." ) - logger.info(f"Initializing Keycloak provider for realm '{realm}' at {keycloak_url} (external: {keycloak_external_url})") + logger.info(f"Initializing Keycloak provider for realm" + f" '{realm}' at {keycloak_url} (external: {keycloak_external_url})") return KeycloakProvider( keycloak_url=keycloak_url, @@ -140,10 +103,10 @@ def _create_cognito_provider() -> CognitoProvider: client_id = os.environ.get('COGNITO_CLIENT_ID') client_secret = os.environ.get('COGNITO_CLIENT_SECRET') region = os.environ.get('AWS_REGION', 'us-east-1') - + # Optional configuration domain = os.environ.get('COGNITO_DOMAIN') - + # Validate required configuration missing_vars = [] if not user_pool_id: @@ -152,15 +115,15 @@ def _create_cognito_provider() -> CognitoProvider: missing_vars.append('COGNITO_CLIENT_ID') if not client_secret: missing_vars.append('COGNITO_CLIENT_SECRET') - + if missing_vars: raise ValueError( f"Missing required Cognito configuration: {', '.join(missing_vars)}. " "Please set these environment variables." ) - + logger.info(f"Initializing Cognito provider for user pool '{user_pool_id}' in region '{region}'") - + return CognitoProvider( user_pool_id=user_pool_id, client_id=client_id, @@ -172,28 +135,36 @@ def _create_cognito_provider() -> CognitoProvider: def _create_entra_id_provider() -> EntraIDProvider: """Create and configure Microsoft Entra ID provider.""" - # Load OAuth2 configuration - oauth2_config = _load_oauth2_config() - entra_config = oauth2_config.get('providers', {}).get('entra_id', {}) - + # Load OAuth2 configuration using shared loader + + entra_config = get_provider_config('entra_id') or {} + # Required configuration from environment variables tenant_id = os.environ.get('ENTRA_TENANT_ID') client_id = os.environ.get('ENTRA_CLIENT_ID') client_secret = os.environ.get('ENTRA_CLIENT_SECRET') - - # Optional configuration - authority = os.environ.get('ENTRA_AUTHORITY') - + + # Endpoint URLs from oauth2_providers.yml (already have environment variable substitution) + auth_url = entra_config.get('auth_url') + token_url = entra_config.get('token_url') + jwks_url = entra_config.get('jwks_url') + logout_url = entra_config.get('logout_url') + userinfo_url = entra_config.get('user_info_url') + + # Optional configuration from oauth2_providers.yml + graph_url = entra_config.get('graph_url') + m2m_scope = entra_config.get('m2m_scope') + # OAuth2 configuration from oauth2_providers.yml with fallbacks scopes = entra_config.get('scopes') grant_type = entra_config.get('grant_type') - + # Optional claim mappings from oauth2_providers.yml username_claim = entra_config.get('username_claim') groups_claim = entra_config.get('groups_claim') email_claim = entra_config.get('email_claim') name_claim = entra_config.get('name_claim') - + # Validate required configuration missing_vars = [] if not tenant_id: @@ -202,20 +173,37 @@ def _create_entra_id_provider() -> EntraIDProvider: missing_vars.append('ENTRA_CLIENT_ID') if not client_secret: missing_vars.append('ENTRA_CLIENT_SECRET') - + if not auth_url: + missing_vars.append('auth_url in oauth2_providers.yml') + if not token_url: + missing_vars.append('token_url in oauth2_providers.yml') + if not jwks_url: + missing_vars.append('jwks_url in oauth2_providers.yml') + if not logout_url: + missing_vars.append('logout_url in oauth2_providers.yml') + if not userinfo_url: + missing_vars.append('user_info_url in oauth2_providers.yml') + if missing_vars: raise ValueError( f"Missing required Entra ID configuration: {', '.join(missing_vars)}. " - "Please set these environment variables." + "Please set the required environment variables or check oauth2_providers.yml." ) - - logger.info(f"Initializing Entra ID provider for tenant '{tenant_id}' with scopes={scopes}, grant_type={grant_type}") - + + logger.info( + f"Initializing Entra ID provider for tenant '{tenant_id}' with scopes={scopes}, grant_type={grant_type}") + return EntraIDProvider( tenant_id=tenant_id, client_id=client_id, client_secret=client_secret, - authority=authority, + auth_url=auth_url, + token_url=token_url, + jwks_url=jwks_url, + logout_url=logout_url, + userinfo_url=userinfo_url, + graph_url=graph_url, + m2m_scope=m2m_scope, scopes=scopes, grant_type=grant_type, username_claim=username_claim, @@ -242,4 +230,4 @@ def _get_provider_health_info() -> dict: 'provider_type': os.environ.get('AUTH_PROVIDER', 'cognito'), 'status': 'error', 'error': str(e) - } \ No newline at end of file + } diff --git a/auth_server/server.py b/auth_server/server.py index 8641ef5e..055422a0 100644 --- a/auth_server/server.py +++ b/auth_server/server.py @@ -36,6 +36,9 @@ # Import provider factory from providers.factory import get_auth_provider +# Import shared configuration loader +from utils.config_loader import get_oauth2_config + # Configure logging logging.basicConfig( level=logging.INFO, # Set the log level to INFO @@ -54,6 +57,7 @@ user_token_generation_counts = {} MAX_TOKENS_PER_USER_PER_HOUR = 10 + # Load scopes configuration def load_scopes_config(): """Load the scopes configuration from scopes.yml""" @@ -67,9 +71,11 @@ def load_scopes_config(): logger.error(f"Failed to load scopes configuration: {e}") return {} + # Global scopes configuration (will be reloaded dynamically) SCOPES_CONFIG = load_scopes_config() + # Utility functions for GDPR/SOX compliance def mask_sensitive_id(value: str) -> str: """Mask sensitive IDs showing only first and last 4 characters.""" @@ -77,12 +83,14 @@ def mask_sensitive_id(value: str) -> str: return "***MASKED***" return f"{value[:4]}...{value[-4:]}" + def hash_username(username: str) -> str: """Hash username for privacy compliance.""" if not username: return "anonymous" return f"user_{hashlib.sha256(username.encode()).hexdigest()[:8]}" + def anonymize_ip(ip_address: str) -> str: """Anonymize IP address by masking last octet for IPv4.""" if not ip_address or ip_address == 'unknown': @@ -99,6 +107,7 @@ def anonymize_ip(ip_address: str) -> str: return ':'.join(parts) return ip_address + def mask_token(token: str) -> str: """Mask JWT token showing only last 4 characters.""" if not token: @@ -107,6 +116,7 @@ def mask_token(token: str) -> str: return f"...{token[-4:]}" return "***MASKED***" + def mask_headers(headers: dict) -> dict: """Mask sensitive headers for logging compliance.""" masked = {} @@ -128,6 +138,7 @@ def mask_headers(headers: dict) -> dict: masked[key] = value return masked + def map_groups_to_scopes(groups: List[str]) -> List[str]: """ Map identity provider groups to MCP scopes using the group_mappings from scopes.yml configuration. @@ -140,7 +151,7 @@ def map_groups_to_scopes(groups: List[str]) -> List[str]: """ scopes = [] group_mappings = SCOPES_CONFIG.get('group_mappings', {}) - + for group in groups: if group in group_mappings: group_scopes = group_mappings[group] @@ -148,7 +159,7 @@ def map_groups_to_scopes(groups: List[str]) -> List[str]: logger.debug(f"Mapped group '{group}' to scopes: {group_scopes}") else: logger.debug(f"No scope mapping found for group: {group}") - + # Remove duplicates while preserving order seen = set() unique_scopes = [] @@ -156,10 +167,11 @@ def map_groups_to_scopes(groups: List[str]) -> List[str]: if scope not in seen: seen.add(scope) unique_scopes.append(scope) - + logger.info(f"Final mapped scopes: {unique_scopes}") return unique_scopes + def validate_session_cookie(cookie_value: str) -> Dict[str, any]: """ Validate session cookie using itsdangerous serializer. @@ -185,20 +197,20 @@ def validate_session_cookie(cookie_value: str) -> Dict[str, any]: if not signer: logger.warning("Global signer not configured for session cookie validation") raise ValueError("Session cookie validation not configured") - + try: # Decrypt cookie (max_age=28800 for 8 hours) data = signer.loads(cookie_value, max_age=28800) - + # Extract user info username = data.get('username') groups = data.get('groups', []) - + # Map groups to scopes scopes = map_groups_to_scopes(groups) - + logger.info(f"Session cookie validated for user: {hash_username(username)}") - + return { 'valid': True, 'username': username, @@ -218,6 +230,7 @@ def validate_session_cookie(cookie_value: str) -> Dict[str, any]: logger.error(f"Session cookie validation error: {e}") raise ValueError(f"Session cookie validation failed: {e}") + def parse_server_and_tool_from_url(original_url: str) -> tuple[Optional[str], Optional[str]]: """ Parse server name and tool name from the original URL and request payload. @@ -233,15 +246,15 @@ def parse_server_and_tool_from_url(original_url: str) -> tuple[Optional[str], Op from urllib.parse import urlparse parsed_url = urlparse(original_url) path = parsed_url.path.strip('/') - + # The path should be in format: /server_name/... # Extract the first path component as server name path_parts = path.split('/') if path else [] server_name = path_parts[0] if path_parts else None - + logger.debug(f"Parsed server name '{server_name}' from URL path: {path}") return server_name, None # Tool name would need to be extracted from request payload - + except Exception as e: logger.error(f"Failed to parse server/tool from URL {original_url}: {e}") return None, None @@ -298,23 +311,23 @@ def validate_server_tool_access(server_name: str, method: str, tool_name: str, u logger.info(f"Requested tool: '{tool_name}'") logger.info(f"User scopes: {user_scopes}") logger.info(f"Available scopes config keys: {list(SCOPES_CONFIG.keys()) if SCOPES_CONFIG else 'None'}") - + if not SCOPES_CONFIG: logger.warning("No scopes configuration loaded, allowing access") logger.info(f"=== VALIDATE_SERVER_TOOL_ACCESS END: ALLOWED (no config) ===") return True - + # Check each user scope to see if it grants access for scope in user_scopes: logger.info(f"--- Checking scope: '{scope}' ---") scope_config = SCOPES_CONFIG.get(scope, []) - + if not scope_config: logger.info(f"Scope '{scope}' not found in configuration") continue - + logger.info(f"Scope '{scope}' config: {scope_config}") - + # The scope_config is directly a list of server configurations # since the permission type is already encoded in the scope name for server_config in scope_config: @@ -324,12 +337,12 @@ def validate_server_tool_access(server_name: str, method: str, tool_name: str, u if _server_names_match(server_config_name, server_name): logger.info(f" ✓ Server name matches!") - + # Check methods first allowed_methods = server_config.get('methods', []) logger.info(f" Allowed methods for server '{server_name}': {allowed_methods}") logger.info(f" Checking if method '{method}' is in allowed methods...") - + # for all methods except tools/call we are good if the method is allowed # for tools/call we need to do an extra validation to check if the tool # itself is allowed or not @@ -338,17 +351,18 @@ def validate_server_tool_access(server_name: str, method: str, tool_name: str, u logger.info(f"Access granted: scope '{scope}' allows access to {server_name}.{method}") logger.info(f"=== VALIDATE_SERVER_TOOL_ACCESS END: GRANTED ===") return True - + # Check tools if method not found in methods allowed_tools = server_config.get('tools', []) logger.info(f" Allowed tools for server '{server_name}': {allowed_tools}") - + # For tools/call, check if the specific tool is allowed if method == 'tools/call' and tool_name: logger.info(f" Checking if tool '{tool_name}' is in allowed tools for tools/call...") if tool_name in allowed_tools: logger.info(f" ✓ Tool '{tool_name}' found in allowed tools!") - logger.info(f"Access granted: scope '{scope}' allows access to {server_name}.{method} for tool {tool_name}") + logger.info( + f"Access granted: scope '{scope}' allows access to {server_name}.{method} for tool {tool_name}") logger.info(f"=== VALIDATE_SERVER_TOOL_ACCESS END: GRANTED ===") return True else: @@ -365,16 +379,17 @@ def validate_server_tool_access(server_name: str, method: str, tool_name: str, u logger.info(f" ✗ Method '{method}' NOT found in allowed tools") else: logger.info(f" ✗ Server name does not match") - + logger.warning(f"Access denied: no scope allows access to {server_name}.{method} (tool: {tool_name}) for user scopes: {user_scopes}") logger.info(f"=== VALIDATE_SERVER_TOOL_ACCESS END: DENIED ===") return False - + except Exception as e: logger.error(f"Error validating server/tool access: {e}") logger.info(f"=== VALIDATE_SERVER_TOOL_ACCESS END: ERROR ===") return False # Deny access on error + def validate_scope_subset(user_scopes: List[str], requested_scopes: List[str]) -> bool: """ Validate that requested scopes are a subset of user's current scopes. @@ -388,18 +403,19 @@ def validate_scope_subset(user_scopes: List[str], requested_scopes: List[str]) - """ if not requested_scopes: return True # Empty request is valid - + user_scope_set = set(user_scopes) requested_scope_set = set(requested_scopes) - + is_valid = requested_scope_set.issubset(user_scope_set) - + if not is_valid: invalid_scopes = requested_scope_set - user_scope_set logger.warning(f"Invalid scopes requested: {invalid_scopes}") - + return is_valid + def check_rate_limit(username: str) -> bool: """ Check if user has exceeded token generation rate limit. @@ -412,29 +428,30 @@ def check_rate_limit(username: str) -> bool: """ current_time = int(time.time()) current_hour = current_time // 3600 - + # Clean up old entries (older than 1 hour) keys_to_remove = [] for key in user_token_generation_counts.keys(): stored_hour = int(key.split(':')[1]) if current_hour - stored_hour > 1: keys_to_remove.append(key) - + for key in keys_to_remove: del user_token_generation_counts[key] - + # Check current hour count rate_key = f"{username}:{current_hour}" current_count = user_token_generation_counts.get(rate_key, 0) - + if current_count >= MAX_TOKENS_PER_USER_PER_HOUR: logger.warning(f"Rate limit exceeded for user {hash_username(username)}: {current_count} tokens this hour") return False - + # Increment counter user_token_generation_counts[rate_key] = current_count + 1 return True + # Create FastAPI app app = FastAPI( title="Simplified Auth Server", @@ -445,6 +462,7 @@ def check_rate_limit(username: str) -> bool: # Add metrics collection middleware add_auth_metrics_middleware(app) + class TokenValidationResponse(BaseModel): """Response model for token validation""" valid: bool @@ -454,6 +472,7 @@ class TokenValidationResponse(BaseModel): client_id: Optional[str] = None username: Optional[str] = None + class GenerateTokenRequest(BaseModel): """Request model for token generation""" user_context: Dict[str, Any] @@ -461,6 +480,7 @@ class GenerateTokenRequest(BaseModel): expires_in_hours: int = DEFAULT_TOKEN_LIFETIME_HOURS description: Optional[str] = None + class GenerateTokenResponse(BaseModel): """Response model for token generation""" access_token: str @@ -472,11 +492,12 @@ class GenerateTokenResponse(BaseModel): issued_at: int description: Optional[str] = None + class SimplifiedCognitoValidator: """ Simplified Cognito token validator that doesn't rely on environment variables """ - + def __init__(self, region: str = "us-east-1"): """ Initialize with minimal configuration @@ -487,42 +508,42 @@ def __init__(self, region: str = "us-east-1"): self.default_region = region self._cognito_clients = {} # Cache boto3 clients by region self._jwks_cache = {} # Cache JWKS by user pool - + def _get_cognito_client(self, region: str): """Get or create boto3 cognito client for region""" if region not in self._cognito_clients: self._cognito_clients[region] = boto3.client('cognito-idp', region_name=region) return self._cognito_clients[region] - + def _get_jwks(self, user_pool_id: str, region: str) -> Dict: """ Get JSON Web Key Set (JWKS) from Cognito with caching """ cache_key = f"{region}:{user_pool_id}" - + if cache_key not in self._jwks_cache: try: issuer = f"https://cognito-idp.{region}.amazonaws.com/{user_pool_id}" jwks_url = f"{issuer}/.well-known/jwks.json" - + response = requests.get(jwks_url, timeout=10) response.raise_for_status() jwks = response.json() - + self._jwks_cache[cache_key] = jwks logger.debug(f"Retrieved JWKS for {cache_key} with {len(jwks.get('keys', []))} keys") - + except Exception as e: logger.error(f"Failed to retrieve JWKS from {jwks_url}: {e}") raise ValueError(f"Cannot retrieve JWKS: {e}") - + return self._jwks_cache[cache_key] - def validate_jwt_token(self, - access_token: str, - user_pool_id: str, - client_id: str, - region: str = None) -> Dict: + def validate_jwt_token(self, + access_token: str, + user_pool_id: str, + client_id: str, + region: str = None) -> Dict: """ Validate JWT access token @@ -540,19 +561,19 @@ def validate_jwt_token(self, """ if not region: region = self.default_region - + try: # Decode header to get key ID unverified_header = jwt.get_unverified_header(access_token) kid = unverified_header.get('kid') - + if not kid: raise ValueError("Token missing 'kid' in header") - + # Get JWKS and find matching key jwks = self._get_jwks(user_pool_id, region) signing_key = None - + for key in jwks.get('keys', []): if key.get('kid') == kid: # Handle different versions of PyJWT @@ -570,13 +591,13 @@ def validate_jwt_token(self, # For PyJWT 2.0.0+ signing_key = PyJWK.from_jwk(json.dumps(key)).key break - + if not signing_key: raise ValueError(f"No matching key found for kid: {kid}") - + # Set up issuer for validation issuer = f"https://cognito-idp.{region}.amazonaws.com/{user_pool_id}" - + # Validate and decode token claims = jwt.decode( access_token, @@ -585,25 +606,25 @@ def validate_jwt_token(self, issuer=issuer, options={ "verify_aud": False, # M2M tokens might not have audience - "verify_exp": True, # Always check expiration - "verify_iat": True, # Check issued at time + "verify_exp": True, # Always check expiration + "verify_iat": True, # Check issued at time } ) - + # Additional validations token_use = claims.get('token_use') if token_use not in ['access', 'id']: # Allow both access and id tokens raise ValueError(f"Invalid token_use: {token_use}") - + # For M2M tokens, check client_id token_client_id = claims.get('client_id') if token_client_id and token_client_id != client_id: logger.warning(f"Token issued for different client: {token_client_id} vs expected {client_id}") # Don't fail immediately - could be user token with different structure - + logger.info(f"Successfully validated JWT token for client/user") return claims - + except jwt.ExpiredSignatureError: error_msg = "Token has expired" logger.warning(error_msg) @@ -617,9 +638,9 @@ def validate_jwt_token(self, logger.error(error_msg) raise ValueError(f"Token validation failed: {e}") - def validate_with_boto3(self, - access_token: str, - region: str = None) -> Dict: + def validate_with_boto3(self, + access_token: str, + region: str = None) -> Dict: """ Validate token using boto3 GetUser API (works for user tokens) @@ -635,16 +656,16 @@ def validate_with_boto3(self, """ if not region: region = self.default_region - + try: cognito_client = self._get_cognito_client(region) response = cognito_client.get_user(AccessToken=access_token) - + # Extract user attributes user_attributes = {} for attr in response.get('UserAttributes', []): user_attributes[attr['Name']] = attr['Value'] - + result = { 'username': response.get('Username'), 'user_attributes': user_attributes, @@ -652,14 +673,14 @@ def validate_with_boto3(self, 'token_use': 'access', # boto3 method implies access token 'auth_method': 'boto3' } - + logger.info(f"Successfully validated token via boto3 for user {hash_username(result['username'])}") return result - + except ClientError as e: error_code = e.response['Error']['Code'] error_message = e.response['Error']['Message'] - + if error_code == 'NotAuthorizedException': error_msg = "Invalid or expired access token" logger.warning(f"Cognito error {error_code}: {error_message}") @@ -671,7 +692,7 @@ def validate_with_boto3(self, else: logger.error(f"Cognito error {error_code}: {error_message}") raise ValueError(f"Token validation failed: {error_message}") - + except Exception as e: logger.error(f"Boto3 validation error: {e}") raise ValueError(f"Token validation failed: {e}") @@ -692,8 +713,8 @@ def validate_self_signed_token(self, access_token: str) -> Dict: try: # Decode and validate JWT using shared SECRET_KEY claims = jwt.decode( - access_token, - SECRET_KEY, + access_token, + SECRET_KEY, algorithms=['HS256'], issuer=JWT_ISSUER, audience=JWT_AUDIENCE, @@ -705,18 +726,18 @@ def validate_self_signed_token(self, access_token: str) -> Dict: }, leeway=30 # 30 second leeway for clock skew ) - + # Validate token_use token_use = claims.get('token_use') if token_use != 'access': raise ValueError(f"Invalid token_use: {token_use}") - + # Extract scopes from space-separated string scope_string = claims.get('scope', '') scopes = scope_string.split() if scope_string else [] - + logger.info(f"Successfully validated self-signed token for user: {claims.get('sub')}") - + return { 'valid': True, 'method': 'self_signed', @@ -728,7 +749,7 @@ def validate_self_signed_token(self, access_token: str) -> Dict: 'groups': [], # Self-signed tokens don't have groups 'token_type': 'user_generated' } - + except jwt.ExpiredSignatureError: error_msg = "Self-signed token has expired" logger.warning(error_msg) @@ -742,11 +763,11 @@ def validate_self_signed_token(self, access_token: str) -> Dict: logger.error(error_msg) raise ValueError(f"Self-signed token validation failed: {e}") - def validate_token(self, - access_token: str, - user_pool_id: str, - client_id: str, - region: str = None) -> Dict: + def validate_token(self, + access_token: str, + user_pool_id: str, + client_id: str, + region: str = None) -> Dict: """ Comprehensive token validation with fallback methods. Now supports both Cognito tokens and self-signed tokens. @@ -762,7 +783,7 @@ def validate_token(self, """ if not region: region = self.default_region - + # First try self-signed token validation (faster) try: # Quick check if it might be our token by attempting to decode without verification @@ -773,16 +794,16 @@ def validate_token(self, except Exception: # Not our token or malformed, continue to Cognito validation pass - + # Try JWT validation with Cognito try: jwt_claims = self.validate_jwt_token(access_token, user_pool_id, client_id, region) - + # Extract scopes and other info scopes = [] if 'scope' in jwt_claims: scopes = jwt_claims['scope'].split() if jwt_claims['scope'] else [] - + return { 'valid': True, 'method': 'jwt', @@ -793,14 +814,14 @@ def validate_token(self, 'scopes': scopes, 'groups': jwt_claims.get('cognito:groups', []) } - + except ValueError as jwt_error: logger.debug(f"JWT validation failed: {jwt_error}, trying boto3") - + # Try boto3 validation as fallback try: boto3_data = self.validate_with_boto3(access_token, region) - + return { 'valid': True, 'method': 'boto3', @@ -811,19 +832,22 @@ def validate_token(self, 'scopes': [], # boto3 method doesn't provide scopes 'groups': [] } - + except ValueError as boto3_error: logger.debug(f"Boto3 validation failed: {boto3_error}") raise ValueError(f"All validation methods failed. JWT: {jwt_error}, Boto3: {boto3_error}") + # Create global validator instance validator = SimplifiedCognitoValidator() + @app.get("/health") async def health_check(): """Health check endpoint""" return {"status": "healthy", "service": "simplified-auth-server"} + @app.get("/validate") async def validate_request(request: Request): """ @@ -842,8 +866,7 @@ async def validate_request(request: Request): Raises: HTTPException: If the token is missing, invalid, or configuration is incomplete """ - - + try: # Extract headers authorization = request.headers.get("X-Authorization") @@ -853,7 +876,7 @@ async def validate_request(request: Request): region = request.headers.get("X-Region", "us-east-1") original_url = request.headers.get("X-Original-URL") body = request.headers.get("X-Body") - + # Extract server_name from original_url early for logging server_name_from_url = None if original_url: @@ -866,12 +889,12 @@ async def validate_request(request: Request): logger.info(f"Extracted server_name '{server_name_from_url}' from original_url: {original_url}") except Exception as e: logger.warning(f"Failed to extract server_name from original_url {original_url}: {e}") - + # Read request body request_payload = None try: if body: - payload_text = body #.decode('utf-8') + payload_text = body # .decode('utf-8') logger.info(f"Raw Request Payload ({len(payload_text)} chars): {payload_text[:1000]}...") request_payload = json.loads(payload_text) logger.info(f"JSON RPC Request Payload: {json.dumps(request_payload, indent=2)}") @@ -883,27 +906,27 @@ async def validate_request(request: Request): logger.warning(f"Could not parse JSON RPC payload: {e}") except Exception as e: logger.error(f"Error reading request payload: {type(e).__name__}: {e}") - + # Log request for debugging with anonymized IP client_ip = request.client.host if request.client else 'unknown' logger.info(f"Validation request from {anonymize_ip(client_ip)}") logger.info(f"Request Method: {request.method}") - + # Log masked HTTP headers for GDPR/SOX compliance all_headers = dict(request.headers) masked_headers = mask_headers(all_headers) logger.debug(f"HTTP Headers (masked): {json.dumps(masked_headers, indent=2)}") - + # Log specific headers for debugging with masked sensitive data logger.info(f"Key Headers: Authorization={bool(authorization)}, Cookie={bool(cookie_header)}, " f"User-Pool-Id={mask_sensitive_id(user_pool_id) if user_pool_id else 'None'}, " f"Client-Id={mask_sensitive_id(client_id) if client_id else 'None'}, " f"Region={region}, Original-URL={original_url}") logger.info(f"Server Name from URL: {server_name_from_url}") - + # Initialize validation result validation_result = None - + # FIRST: Check for session cookie if present if "mcp_gateway_session=" in cookie_header: logger.info("Session cookie detected, attempting session validation") @@ -913,7 +936,7 @@ async def validate_request(request: Request): if cookie.strip().startswith('mcp_gateway_session='): cookie_value = cookie.strip().split('=', 1)[1] break - + if cookie_value: try: validation_result = validate_session_cookie(cookie_value) @@ -921,11 +944,12 @@ async def validate_request(request: Request): safe_result = {k: v for k, v in validation_result.items() if k != 'username'} safe_result['username'] = hash_username(validation_result.get('username', '')) logger.info(f"Session cookie validation result: {safe_result}") - logger.info(f"Session cookie validation successful for user: {hash_username(validation_result['username'])}") + logger.info( + f"Session cookie validation successful for user: {hash_username(validation_result['username'])}") except ValueError as e: logger.warning(f"Session cookie validation failed: {e}") # Fall through to JWT validation - + # SECOND: If no valid session cookie, check for JWT token if not validation_result: # Validate required headers for JWT @@ -936,15 +960,15 @@ async def validate_request(request: Request): detail="Missing or invalid Authorization header. Expected: Bearer or valid session cookie", headers={"WWW-Authenticate": "Bearer", "Connection": "close"} ) - + # Extract token access_token = authorization.split(" ")[1] - + # Get authentication provider based on AUTH_PROVIDER environment variable try: auth_provider = get_auth_provider() logger.info(f"Using authentication provider: {auth_provider.__class__.__name__}") - + # Provider-specific validation if hasattr(auth_provider, 'validate_token'): # For Keycloak, no additional headers needed @@ -959,7 +983,7 @@ async def validate_request(request: Request): detail="Missing X-User-Pool-Id header", headers={"Connection": "close"} ) - + if not client_id: logger.warning("Missing X-Client-Id header for Cognito validation") raise HTTPException( @@ -967,7 +991,7 @@ async def validate_request(request: Request): detail="Missing X-Client-Id header", headers={"Connection": "close"} ) - + # Use old validator for backward compatibility validation_result = validator.validate_token( access_token=access_token, @@ -975,7 +999,7 @@ async def validate_request(request: Request): client_id=client_id, region=region ) - + except Exception as e: logger.error(f"Authentication provider error: {e}") raise HTTPException( @@ -983,18 +1007,18 @@ async def validate_request(request: Request): detail=f"Authentication provider configuration error: {str(e)}", headers={"Connection": "close"} ) - + logger.info(f"Token validation successful using method: {validation_result['method']}") - + # Parse server and tool information from original URL if available server_name = server_name_from_url # Use the server_name we extracted earlier tool_name = None - + if original_url and request_payload: # We already extracted server_name above, now just get tool_name from URL parsing _, tool_name = parse_server_and_tool_from_url(original_url) logger.debug(f"Parsed from original URL: server='{server_name}', tool='{tool_name}'") - + # Try to extract tool name from request payload if not found in URL if server_name and not tool_name and request_payload: try: @@ -1002,23 +1026,23 @@ async def validate_request(request: Request): if isinstance(request_payload, dict): # JSON-RPC 2.0 format: method field contains the tool name tool_name = request_payload.get('method') - + # If not found in method, check other common patterns if not tool_name: tool_name = request_payload.get('tool') or request_payload.get('name') - + # Check for nested tool reference in params if not tool_name and 'params' in request_payload: params = request_payload['params'] if isinstance(params, dict): tool_name = params.get('name') or params.get('tool') or params.get('method') - + logger.info(f"Extracted tool name from JSON-RPC payload: '{tool_name}'") else: logger.warning(f"Payload is not a dictionary: {type(request_payload)}") except Exception as e: logger.error(f"Error processing request payload for tool extraction: {e}") - + # Validate scope-based access if we have server/tool information # For Keycloak, map groups to scopes; otherwise use scopes directly user_groups = validation_result.get('groups', []) @@ -1032,25 +1056,27 @@ async def validate_request(request: Request): # Extract method and actual tool name method = tool_name # The extracted tool_name is actually the method actual_tool_name = None - + # For tools/call, extract the actual tool name from params if method == 'tools/call' and isinstance(request_payload, dict): params = request_payload.get('params', {}) if isinstance(params, dict): actual_tool_name = params.get('name') logger.info(f"Extracted actual tool name for tools/call: '{actual_tool_name}'") - + # Check if user has any scopes - if not, deny access (fail closed) if not user_scopes: - logger.warning(f"Access denied for user {hash_username(validation_result.get('username', ''))} to {server_name}.{method} (tool: {actual_tool_name}) - no scopes configured") + logger.warning( + f"Access denied for user {hash_username(validation_result.get('username', ''))} to {server_name}.{method} (tool: {actual_tool_name}) - no scopes configured") raise HTTPException( status_code=403, detail=f"Access denied to {server_name}.{method} - user has no scopes configured", headers={"Connection": "close"} ) - + if not validate_server_tool_access(server_name, method, actual_tool_name, user_scopes): - logger.warning(f"Access denied for user {hash_username(validation_result.get('username', ''))} to {server_name}.{method} (tool: {actual_tool_name})") + logger.warning( + f"Access denied for user {hash_username(validation_result.get('username', ''))} to {server_name}.{method} (tool: {actual_tool_name})") raise HTTPException( status_code=403, detail=f"Access denied to {server_name}.{method}", @@ -1058,10 +1084,11 @@ async def validate_request(request: Request): ) logger.info(f"Scope validation passed for {server_name}.{method} (tool: {actual_tool_name})") elif server_name or tool_name: - logger.debug(f"Partial server/tool info available (server='{server_name}', tool='{tool_name}'), skipping scope validation") + logger.debug( + f"Partial server/tool info available (server='{server_name}', tool='{tool_name}'), skipping scope validation") else: logger.debug("No server/tool information available, skipping scope validation") - + # Prepare JSON response data response_data = { 'valid': True, @@ -1077,7 +1104,7 @@ async def validate_request(request: Request): logger.info(f"Response data being sent: {json.dumps(response_data, indent=2)}") # Create JSON response with headers that nginx can use response = JSONResponse(content=response_data, status_code=200) - + # Set headers for nginx auth_request_set directives response.headers["X-User"] = validation_result.get('username') or '' response.headers["X-Username"] = validation_result.get('username') or '' @@ -1086,9 +1113,9 @@ async def validate_request(request: Request): response.headers["X-Auth-Method"] = validation_result.get('method') or '' response.headers["X-Server-Name"] = server_name or '' response.headers["X-Tool-Name"] = tool_name or '' - + return response - + except ValueError as e: logger.warning(f"Token validation failed: {e}") raise HTTPException( @@ -1117,13 +1144,14 @@ async def validate_request(request: Request): finally: pass + @app.get("/config") async def get_auth_config(): """Return the authentication configuration info""" try: auth_provider = get_auth_provider() provider_info = auth_provider.get_provider_info() - + if provider_info.get('provider_type') == 'keycloak': return { "auth_type": "keycloak", @@ -1156,9 +1184,10 @@ async def get_auth_config(): "error": str(e) } + @app.post("/internal/tokens", response_model=GenerateTokenResponse) async def generate_user_token( - request: GenerateTokenRequest + request: GenerateTokenRequest ): """ Generate a JWT token for a user with specified scopes. @@ -1178,19 +1207,19 @@ async def generate_user_token( """ try: # Note: No internal API key validation needed since registry already validates user session - + # Extract user context user_context = request.user_context username = user_context.get('username') user_scopes = user_context.get('scopes', []) - + if not username: raise HTTPException( status_code=400, detail="Username is required in user context", headers={"Connection": "close"} ) - + # Check rate limiting if not check_rate_limit(username): raise HTTPException( @@ -1198,7 +1227,7 @@ async def generate_user_token( detail=f"Rate limit exceeded. Maximum {MAX_TOKENS_PER_USER_PER_HOUR} tokens per hour.", headers={"Connection": "close"} ) - + # Validate expiration time expires_in_hours = request.expires_in_hours if expires_in_hours <= 0 or expires_in_hours > MAX_TOKEN_LIFETIME_HOURS: @@ -1207,10 +1236,10 @@ async def generate_user_token( detail=f"Invalid expiration time. Must be between 1 and {MAX_TOKEN_LIFETIME_HOURS} hours.", headers={"Connection": "close"} ) - + # Use user's current scopes if no specific scopes requested requested_scopes = request.requested_scopes if request.requested_scopes else user_scopes - + # Validate that requested scopes are subset of user's current scopes if not validate_scope_subset(user_scopes, requested_scopes): invalid_scopes = set(requested_scopes) - set(user_scopes) @@ -1219,7 +1248,7 @@ async def generate_user_token( detail=f"Requested scopes exceed user permissions. Invalid scopes: {list(invalid_scopes)}", headers={"Connection": "close"} ) - + # Generate JWT access token current_time = int(time.time()) expires_at = current_time + (expires_in_hours * 3600) @@ -1248,7 +1277,8 @@ async def generate_user_token( refresh_token = None refresh_expires_in_seconds = 0 - logger.info(f"Generated access token for user '{hash_username(username)}' with scopes: {requested_scopes}, expires in {expires_in_hours} hours (no refresh token - configure longer token lifetime in Keycloak if needed)") + logger.info( + f"Generated access token for user '{hash_username(username)}' with scopes: {requested_scopes}, expires in {expires_in_hours} hours (no refresh token - configure longer token lifetime in Keycloak if needed)") return GenerateTokenResponse( access_token=access_token, @@ -1259,7 +1289,7 @@ async def generate_user_token( issued_at=current_time, description=request.description ) - + except HTTPException: raise except Exception as e: @@ -1270,10 +1300,11 @@ async def generate_user_token( headers={"Connection": "close"} ) + @app.post("/internal/reload-scopes") async def reload_scopes( - request: Request, - authorization: Optional[str] = Header(None) + request: Request, + authorization: Optional[str] = Header(None) ): """ Reload the scopes.yml configuration file. @@ -1343,6 +1374,7 @@ async def reload_scopes( detail=f"Failed to reload scopes: {str(e)}" ) + def parse_arguments(): """Parse command line arguments.""" parser = argparse.ArgumentParser(description="Simplified Auth Server") @@ -1370,80 +1402,26 @@ def parse_arguments(): return parser.parse_args() + def main(): """Run the server""" args = parse_arguments() - + # Update global validator with default region global validator validator = SimplifiedCognitoValidator(region=args.region) - + logger.info(f"Starting simplified auth server on {args.host}:{args.port}") logger.info(f"Default region: {args.region}") - + uvicorn.run(app, host=args.host, port=args.port) + if __name__ == "__main__": main() # Load OAuth2 providers configuration -def load_oauth2_config(): - """Load the OAuth2 providers configuration from oauth2_providers.yml""" - try: - oauth2_file = Path(__file__).parent / "oauth2_providers.yml" - with open(oauth2_file, 'r') as f: - config = yaml.safe_load(f) - - # Substitute environment variables in configuration - processed_config = substitute_env_vars(config) - return processed_config - except Exception as e: - logger.error(f"Failed to load OAuth2 configuration: {e}") - return {"providers": {}, "session": {}, "registry": {}} - -def auto_derive_cognito_domain(user_pool_id: str) -> str: - """ - Auto-derive Cognito domain from User Pool ID. - - Example: us-east-1_KmP5A3La3 → us-east-1kmp5a3la3 - """ - if not user_pool_id: - return "" - - # Remove underscore and convert to lowercase - domain = user_pool_id.replace('_', '').lower() - logger.info(f"Auto-derived Cognito domain '{domain}' from user pool ID '{user_pool_id}'") - return domain - -def substitute_env_vars(config): - """Recursively substitute environment variables in configuration""" - if isinstance(config, dict): - return {k: substitute_env_vars(v) for k, v in config.items()} - elif isinstance(config, list): - return [substitute_env_vars(item) for item in config] - elif isinstance(config, str) and "${" in config: - try: - # Handle special case for auto-derived Cognito domain - if "COGNITO_DOMAIN:-auto" in config: - # Check if COGNITO_DOMAIN is set, if not auto-derive from user pool ID - cognito_domain = os.environ.get('COGNITO_DOMAIN') - if not cognito_domain: - user_pool_id = os.environ.get('COGNITO_USER_POOL_ID', '') - cognito_domain = auto_derive_cognito_domain(user_pool_id) - - # Replace the template with the derived domain - config = config.replace('${COGNITO_DOMAIN:-auto}', cognito_domain) - - template = Template(config) - return template.substitute(os.environ) - except KeyError as e: - logger.warning(f"Environment variable not found for template {config}: {e}") - return config - else: - return config - -# Global OAuth2 configuration -OAUTH2_CONFIG = load_oauth2_config() +OAUTH2_CONFIG = get_oauth2_config() # Initialize SECRET_KEY and signer for session management SECRET_KEY = os.environ.get('SECRET_KEY') @@ -1456,6 +1434,7 @@ def substitute_env_vars(config): signer = URLSafeTimedSerializer(SECRET_KEY) + def get_enabled_providers(): """Get list of enabled OAuth2 providers, filtered by AUTH_PROVIDER env var if set""" enabled = [] @@ -1508,6 +1487,7 @@ def get_enabled_providers(): logger.info(f"Returning {len(enabled)} enabled providers: {[p['name'] for p in enabled]}") return enabled + @app.get("/oauth2/providers") async def get_oauth2_providers(): """Get list of enabled OAuth2 providers for the login page""" @@ -1522,30 +1502,31 @@ async def get_oauth2_providers(): logger.error(f"Error getting OAuth2 providers: {e}") return {"providers": [], "error": str(e)} + @app.get("/oauth2/login/{provider}") async def oauth2_login(provider: str, request: Request, redirect_uri: str = None): """Initiate OAuth2 login flow""" try: if provider not in OAUTH2_CONFIG.get("providers", {}): raise HTTPException(status_code=404, detail=f"Provider {provider} not found") - + provider_config = OAUTH2_CONFIG["providers"][provider] if not provider_config.get("enabled", False): raise HTTPException(status_code=400, detail=f"Provider {provider} is disabled") - + # Generate state parameter for security state = secrets.token_urlsafe(32) - + # Store state and redirect URI in session for callback validation session_data = { "state": state, "provider": provider, "redirect_uri": redirect_uri or OAUTH2_CONFIG.get("registry", {}).get("success_redirect", "/") } - + # Create temporary session for OAuth2 flow temp_session = signer.dumps(session_data) - + # Use configured external URL or build dynamically auth_server_external_url = os.environ.get('AUTH_SERVER_EXTERNAL_URL') if auth_server_external_url: @@ -1555,19 +1536,20 @@ async def oauth2_login(provider: str, request: Request, redirect_uri: str = None else: # Fall back to dynamic construction (for development) host = request.headers.get("host", "localhost:8888") - scheme = "https" if request.headers.get("x-forwarded-proto") == "https" or request.url.scheme == "https" else "http" - + scheme = "https" if request.headers.get( + "x-forwarded-proto") == "https" or request.url.scheme == "https" else "http" + # Special case for localhost to include port if "localhost" in host and ":" not in host: auth_server_url = f"{scheme}://localhost:8888" else: auth_server_url = f"{scheme}://{host}" - + logger.warning(f"AUTH_SERVER_EXTERNAL_URL not set, using dynamic URL: {auth_server_url}") - + callback_uri = f"{auth_server_url}/oauth2/callback/{provider}" logger.info(f"OAuth2 callback URI: {callback_uri}") - + auth_params = { "client_id": provider_config["client_id"], "response_type": provider_config["response_type"], @@ -1575,9 +1557,9 @@ async def oauth2_login(provider: str, request: Request, redirect_uri: str = None "state": state, "redirect_uri": callback_uri } - + auth_url = f"{provider_config['auth_url']}?{urllib.parse.urlencode(auth_params)}" - + # Create response with temporary session cookie response = RedirectResponse(url=auth_url, status_code=302) response.set_cookie( @@ -1587,10 +1569,10 @@ async def oauth2_login(provider: str, request: Request, redirect_uri: str = None httponly=True, samesite="lax" ) - + logger.info(f"Initiated OAuth2 login for provider {provider}") return response - + except HTTPException: raise except Exception as e: @@ -1598,14 +1580,15 @@ async def oauth2_login(provider: str, request: Request, redirect_uri: str = None error_url = OAUTH2_CONFIG.get("registry", {}).get("error_redirect", "/login") return RedirectResponse(url=f"{error_url}?error=oauth2_init_failed", status_code=302) + @app.get("/oauth2/callback/{provider}") async def oauth2_callback( - provider: str, - request: Request, - code: str = None, - state: str = None, - error: str = None, - oauth2_temp_session: str = Cookie(None) + provider: str, + request: Request, + code: str = None, + state: str = None, + error: str = None, + oauth2_temp_session: str = Cookie(None) ): """Handle OAuth2 callback and create user session""" try: @@ -1613,26 +1596,26 @@ async def oauth2_callback( logger.warning(f"OAuth2 error from {provider}: {error}") error_url = OAUTH2_CONFIG.get("registry", {}).get("error_redirect", "/login") return RedirectResponse(url=f"{error_url}?error=oauth2_error&details={error}", status_code=302) - + if not code or not state or not oauth2_temp_session: raise HTTPException(status_code=400, detail="Missing required OAuth2 parameters") - + # Validate temporary session try: temp_session_data = signer.loads(oauth2_temp_session, max_age=600) except (SignatureExpired, BadSignature): raise HTTPException(status_code=400, detail="Invalid or expired OAuth2 session") - + # Validate state parameter if state != temp_session_data.get("state"): raise HTTPException(status_code=400, detail="Invalid state parameter") - + # Validate provider if provider != temp_session_data.get("provider"): raise HTTPException(status_code=400, detail="Provider mismatch") - + provider_config = OAUTH2_CONFIG["providers"][provider] - + # Exchange authorization code for access token # Use configured external URL or build dynamically auth_server_external_url = os.environ.get('AUTH_SERVER_EXTERNAL_URL') @@ -1655,7 +1638,6 @@ async def oauth2_callback( token_data = await exchange_code_for_token(provider, code, provider_config, auth_server_url) logger.info(f"Token data keys: {list(token_data.keys())}") - # For Cognito and Keycloak, try to extract user info from JWT tokens if provider in ["cognito", "keycloak", "entra_id"]: try: @@ -1708,38 +1690,21 @@ async def oauth2_callback( logger.warning("No ID token found in Keycloak response, falling back to userInfo") raise ValueError("Missing ID token") elif provider == "entra_id": - # For Entra ID, decode the ID token to get user information - if "id_token" in token_data: - import jwt - # Decode without verification (we trust the token since we just got it from token exchange) - id_token_claims = jwt.decode(token_data["id_token"], options={"verify_signature": False}) - logger.info(f"Entra ID token claims: {id_token_claims}") - - # Extract user info from ID token claims - # Entra ID uses different claim names than Keycloak - username = ( - id_token_claims.get("preferred_username") or - id_token_claims.get("upn") or - id_token_claims.get("unique_name") or - id_token_claims.get("email") or - id_token_claims.get("sub") - ) - # Retrieve the user's group separately, without using the ID group, - # to avoid manually reconfiguring in scopes.yml - user_member_groups = await get_user_member_of(token_data["access_token"]) - groups_name = [groups.get("displayName") for groups in user_member_groups.get("value")] - logger.info(f" groups_name: {groups_name}") - mapped_user = { - "username": username, - "email": id_token_claims.get("email") or id_token_claims.get("upn") or username, - "name": id_token_claims.get("name"), - "groups": groups_name - } - logger.info(f"User extracted from Entra ID token: {mapped_user}") - else: - logger.warning("No ID token found in Entra ID response, falling back to userInfo") - raise ValueError("Missing ID token") + # For Entra ID, use provider's get_user_info method + auth_provider = get_auth_provider('entra_id') + user_info = auth_provider.get_user_info( + access_token=token_data.get("access_token"), # Required for Graph API calls + id_token=token_data.get("id_token") # Preferred for user identity extraction + ) + mapped_user = { + "username": user_info["username"], + "email": user_info.get("email"), + "name": user_info.get("name"), + "groups": user_info.get("groups", []) + } + logger.info(f"User info retrieved for Entra ID: {mapped_user['username']}" + f" with {len(mapped_user['groups'])} groups") except Exception as e: logger.warning(f"JWT token validation failed: {e}, falling back to userInfo endpoint") # Fallback to userInfo endpoint @@ -1753,7 +1718,7 @@ async def oauth2_callback( logger.info(f"Raw user info from {provider}: {user_info}") mapped_user = map_user_info(user_info, provider_config) logger.info(f"Mapped user info: {mapped_user}") - + # Create session cookie compatible with registry session_data = { "username": mapped_user["username"], @@ -1763,9 +1728,9 @@ async def oauth2_callback( "provider": provider, "auth_method": "oauth2" } - + registry_session = signer.dumps(session_data) - + # Redirect to registry with session cookie redirect_url = temp_session_data.get("redirect_uri", OAUTH2_CONFIG.get("registry", {}).get("success_redirect", "/")) response = RedirectResponse(url=redirect_url, status_code=302) @@ -1779,25 +1744,26 @@ async def oauth2_callback( samesite=OAUTH2_CONFIG.get("session", {}).get("samesite", "lax"), secure=OAUTH2_CONFIG.get("session", {}).get("secure", False) ) - + # Clear temporary OAuth2 session response.delete_cookie("oauth2_temp_session") - + logger.info(f"Successfully authenticated user {hash_username(mapped_user['username'])} via {provider}") return response - + except HTTPException: raise except Exception as e: logger.error(f"Error in OAuth2 callback for {provider}: {e}") error_url = OAUTH2_CONFIG.get("registry", {}).get("error_redirect", "/login") return RedirectResponse(url=f"{error_url}?error=oauth2_callback_failed", status_code=302) - + + async def exchange_code_for_token(provider: str, code: str, provider_config: dict, auth_server_url: str = None) -> dict: """Exchange authorization code for access token""" if auth_server_url is None: auth_server_url = os.environ.get('AUTH_SERVER_URL', 'http://localhost:8888') - + async with httpx.AsyncClient() as client: token_data = { "grant_type": provider_config["grant_type"], @@ -1806,11 +1772,11 @@ async def exchange_code_for_token(provider: str, code: str, provider_config: dic "code": code, "redirect_uri": f"{auth_server_url}/oauth2/callback/{provider}" } - + headers = {"Accept": "application/json"} if provider == "github": headers["Accept"] = "application/json" - + response = await client.post( provider_config["token_url"], data=token_data, @@ -1819,27 +1785,14 @@ async def exchange_code_for_token(provider: str, code: str, provider_config: dic response.raise_for_status() return response.json() + async def get_user_info(access_token: str, provider_config: dict) -> dict: """Get user information from OAuth2 provider""" async with httpx.AsyncClient() as client: headers = {"Authorization": f"Bearer {access_token}"} - - response = await client.get( - provider_config["user_info_url"], - headers=headers - ) - response.raise_for_status() - return response.json() - -async def get_user_member_of(access_token: str): - """Get user group information from OAuth2 provider""" - async with httpx.AsyncClient() as client: - headers = {"Authorization": f"Bearer {access_token}"} - url = ("https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.group?" - "$count=true&$select=id,displayName") response = await client.get( - url, + provider_config["user_info_url"], headers=headers ) response.raise_for_status() @@ -1854,12 +1807,12 @@ def map_user_info(user_info: dict, provider_config: dict) -> dict: "name": user_info.get(provider_config["name_claim"]), "groups": [] } - + # Handle groups if provider supports them groups_claim = provider_config.get("groups_claim") logger.info(f"Looking for groups using claim: {groups_claim}") logger.info(f"Available claims in user_info: {list(user_info.keys())}") - + if groups_claim and groups_claim in user_info: groups = user_info[groups_claim] if isinstance(groups, list): @@ -1878,27 +1831,28 @@ def map_user_info(user_info: dict, provider_config: dict) -> dict: mapped["groups"] = [groups] logger.info(f"Found groups via alternative claim {possible_group_claim}: {mapped['groups']}") break - + if not mapped["groups"]: logger.warning(f"No groups found in user_info. Available fields: {list(user_info.keys())}") - + return mapped + @app.get("/oauth2/logout/{provider}") async def oauth2_logout(provider: str, request: Request, redirect_uri: str = None): """Initiate OAuth2 logout flow to clear provider session""" try: if provider not in OAUTH2_CONFIG.get("providers", {}): raise HTTPException(status_code=404, detail=f"Provider {provider} not found") - + provider_config = OAUTH2_CONFIG["providers"][provider] logout_url = provider_config.get("logout_url") - + if not logout_url: # If provider doesn't support logout URL, just redirect redirect_url = redirect_uri or OAUTH2_CONFIG.get("registry", {}).get("success_redirect", "/login") return RedirectResponse(url=redirect_url, status_code=302) - + # For Cognito, we need to construct the full redirect URI full_redirect_uri = redirect_uri or "/logout" if not full_redirect_uri.startswith("http"): @@ -1913,20 +1867,20 @@ async def oauth2_logout(provider: str, request: Request, redirect_uri: str = Non registry_base = f"{parsed.scheme}://{parsed.netloc}" else: registry_base = "http://localhost" - + full_redirect_uri = f"{registry_base.rstrip('/')}{full_redirect_uri}" - + # Build logout URL with correct parameters for Cognito logout_params = { "client_id": provider_config["client_id"], "logout_uri": full_redirect_uri } - + logout_redirect_url = f"{logout_url}?{urllib.parse.urlencode(logout_params)}" - + logger.info(f"Redirecting to {provider} logout: {logout_redirect_url}") return RedirectResponse(url=logout_redirect_url, status_code=302) - + except HTTPException: raise except Exception as e: diff --git a/auth_server/utils/__init__.py b/auth_server/utils/__init__.py new file mode 100644 index 00000000..06973e37 --- /dev/null +++ b/auth_server/utils/__init__.py @@ -0,0 +1,4 @@ +from .config_loader import OAuth2ConfigLoader, get_oauth2_config + +__all__ = ['OAuth2ConfigLoader', 'get_oauth2_config'] + diff --git a/auth_server/utils/config_loader.py b/auth_server/utils/config_loader.py new file mode 100644 index 00000000..0966071e --- /dev/null +++ b/auth_server/utils/config_loader.py @@ -0,0 +1,261 @@ +import logging +import os +import re +import yaml +from pathlib import Path +from typing import Any, Dict, Optional +from threading import Lock + +logger = logging.getLogger(__name__) + + +class OAuth2ConfigLoader: + """Singleton OAuth2 configuration loader with environment variable substitution. + + This class ensures that the OAuth2 configuration is loaded only once and + cached for subsequent access. It supports bash-style default values in + environment variables (e.g., ${VAR_NAME:-default_value}). + """ + + _instance: Optional['OAuth2ConfigLoader'] = None + _lock: Lock = Lock() + _config: Optional[Dict[str, Any]] = None + + def __new__(cls) -> 'OAuth2ConfigLoader': + """Create or return the singleton instance.""" + if cls._instance is None: + with cls._lock: + # Double-checked locking pattern + if cls._instance is None: + cls._instance = super(OAuth2ConfigLoader, cls).__new__(cls) + return cls._instance + + def __init__(self): + """Initialize the configuration loader. + + Note: This will only execute once due to singleton pattern. + """ + # Prevent re-initialization + if self._config is not None: + return + + with self._lock: + if self._config is None: + self._config = self._load_config() + + def _load_config(self) -> Dict[str, Any]: + """Load OAuth2 providers configuration from oauth2_providers.yml. + + Returns: + Dict containing OAuth2 providers configuration with environment + variables substituted. + """ + try: + oauth2_file = Path(__file__).parent.parent / "oauth2_providers.yml" + logger.info(f"Loading OAuth2 configuration from: {oauth2_file}") + + with open(oauth2_file, 'r') as f: + config = yaml.safe_load(f) + + # Substitute environment variables in configuration + processed_config = self._substitute_env_vars(config) + + # Log loaded providers + providers = list(processed_config.get('providers', {}).keys()) + logger.info(f"Successfully loaded OAuth2 configuration with providers: {providers}") + + return processed_config + except FileNotFoundError: + logger.error(f"OAuth2 configuration file not found") + return {"providers": {}, "session": {}, "registry": {}} + except yaml.YAMLError as e: + logger.error(f"Failed to parse OAuth2 configuration YAML: {e}") + return {"providers": {}, "session": {}, "registry": {}} + except Exception as e: + logger.error(f"Failed to load OAuth2 configuration: {e}") + return {"providers": {}, "session": {}, "registry": {}} + + def _substitute_env_vars(self, config: Any) -> Any: + """Recursively substitute environment variables in configuration. + + Supports bash-style default values: ${VAR_NAME:-default_value} + + Args: + config: Configuration value (dict, list, or str) + + Returns: + Configuration with environment variables substituted + """ + if isinstance(config, dict): + return {k: self._substitute_env_vars(v) for k, v in config.items()} + elif isinstance(config, list): + return [self._substitute_env_vars(item) for item in config] + elif isinstance(config, str) and "${" in config: + # Handle special case for auto-derived Cognito domain + if "COGNITO_DOMAIN:-auto" in config: + cognito_domain = os.environ.get('COGNITO_DOMAIN') + if not cognito_domain: + user_pool_id = os.environ.get('COGNITO_USER_POOL_ID', '') + cognito_domain = self._auto_derive_cognito_domain(user_pool_id) + config = config.replace('${COGNITO_DOMAIN:-auto}', cognito_domain) + + # Support bash-style default values: ${VAR_NAME:-default_value} + def replace_var(match): + var_expr = match.group(1) + # Check if it has a default value + if ":-" in var_expr: + var_name, default_value = var_expr.split(":-", 1) + return os.environ.get(var_name.strip(), default_value.strip()) + else: + var_name = var_expr.strip() + if var_name in os.environ: + return os.environ[var_name] + else: + logger.warning(f"Environment variable not found: {var_name}") + return match.group(0) # Return original if not found + + return re.sub(r'\$\{([^}]+)\}', replace_var, config) + else: + return config + + def _auto_derive_cognito_domain(self, user_pool_id: str) -> str: + """Auto-derive Cognito domain from User Pool ID. + + Example: us-east-1_KmP5A3La3 → us-east-1kmp5a3la3 + + Args: + user_pool_id: AWS Cognito User Pool ID + + Returns: + Derived domain string + """ + if not user_pool_id: + return "" + + # Remove underscore and convert to lowercase + domain = user_pool_id.replace('_', '').lower() + logger.info(f"Auto-derived Cognito domain '{domain}' from user pool ID '{user_pool_id}'") + return domain + + @property + def config(self) -> Dict[str, Any]: + """Get the loaded OAuth2 configuration. + + Returns: + Dictionary containing the OAuth2 configuration + """ + if self._config is None: + # This should never happen due to __init__, but just in case + with self._lock: + if self._config is None: + self._config = self._load_config() + return self._config + + def reload(self) -> Dict[str, Any]: + """Force reload the configuration from file. + + This method can be used to refresh the configuration without + restarting the application. + + Returns: + Dictionary containing the reloaded OAuth2 configuration + """ + with self._lock: + logger.info("Reloading OAuth2 configuration...") + self._config = self._load_config() + return self._config + + def get_provider_config(self, provider_name: str) -> Optional[Dict[str, Any]]: + """Get configuration for a specific provider. + + Args: + provider_name: Name of the provider (e.g., 'keycloak', 'cognito', 'entra_id') + + Returns: + Provider configuration dictionary or None if not found + """ + return self.config.get('providers', {}).get(provider_name) + + def get_enabled_providers(self) -> list: + """Get list of all enabled provider names. + + Returns: + List of enabled provider names + """ + enabled = [] + for provider_name, config in self.config.get('providers', {}).items(): + if config.get('enabled', False): + enabled.append(provider_name) + return enabled + + +# Global singleton instance accessor +_config_loader: Optional[OAuth2ConfigLoader] = None + + +def get_oauth2_config(reload: bool = False) -> Dict[str, Any]: + """Get the OAuth2 configuration (singleton access). + + This is a convenience function that provides access to the singleton + OAuth2ConfigLoader instance. + + Args: + reload: If True, force reload the configuration from file + + Returns: + Dictionary containing the OAuth2 configuration + + Example: + >>> config = get_oauth2_config() + >>> keycloak_config = config.get('providers', {}).get('keycloak') + """ + global _config_loader + + if _config_loader is None: + _config_loader = OAuth2ConfigLoader() + + if reload: + return _config_loader.reload() + + return _config_loader.config + + +def get_provider_config(provider_name: str) -> Optional[Dict[str, Any]]: + """Get configuration for a specific provider. + + Args: + provider_name: Name of the provider (e.g., 'keycloak', 'cognito', 'entra_id') + + Returns: + Provider configuration dictionary or None if not found + + Example: + >>> entra_config = get_provider_config('entra_id') + >>> if entra_config: + ... tenant_id = entra_config.get('tenant_id') + """ + global _config_loader + + if _config_loader is None: + _config_loader = OAuth2ConfigLoader() + + return _config_loader.get_provider_config(provider_name) + + +def get_enabled_providers() -> list: + """Get list of all enabled provider names. + + Returns: + List of enabled provider names + + Example: + >>> enabled = get_enabled_providers() + >>> print(f"Enabled providers: {enabled}") + """ + global _config_loader + + if _config_loader is None: + _config_loader = OAuth2ConfigLoader() + + return _config_loader.get_enabled_providers() + diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example index a509bed0..ebaabe50 100644 --- a/docker-compose.override.yml.example +++ b/docker-compose.override.yml.example @@ -23,16 +23,21 @@ services: - ENTRA_CLIENT_ID=your-client-id-here - ENTRA_CLIENT_SECRET=your-client-secret-here - # Optional: Custom authority for sovereign clouds - # - ENTRA_AUTHORITY=https://login.microsoftonline.us/your-tenant-id # US Government - # - ENTRA_AUTHORITY=https://login.chinacloudapi.cn/your-tenant-id # China - # Optional: Custom claim mappings # These determine which claims from the ID token are used for user information - # - ENTRA_USERNAME_CLAIM=upn # Default: preferred_username + # - ENTRA_USERNAME_CLAIM=preferred_username # Default: preferred_username # - ENTRA_GROUPS_CLAIM=groups # Default: groups - # - ENTRA_EMAIL_CLAIM=upn # Default: email + # - ENTRA_EMAIL_CLAIM=email # Default: email # - ENTRA_NAME_CLAIM=name # Default: name + + # Optional: Advanced configuration for sovereign clouds + # Microsoft Graph API base URL (default: https://graph.microsoft.com) + # - ENTRA_GRAPH_URL=https://graph.microsoft.us # US Government + # - ENTRA_GRAPH_URL=https://microsoftgraph.chinacloudapi.cn # China + + # M2M scope for client credentials flow (default: https://graph.microsoft.com/.default) + # - ENTRA_M2M_SCOPE=https://graph.microsoft.us/.default # US Government + # - ENTRA_M2M_SCOPE=https://microsoftgraph.chinacloudapi.cn/.default # China # Example: Disable Keycloak if using Entra ID # keycloak: diff --git a/docker-compose.yml b/docker-compose.yml index 3cc2f6bd..fe204dd2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -119,12 +119,12 @@ services: - ENTRA_TENANT_ID=${ENTRA_TENANT_ID} - ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID} - ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET} - - ENTRA_AUTHORITY=${ENTRA_AUTHORITY} # Optional: Claim mappings - customize based on your Entra ID setup - ENTRA_USERNAME_CLAIM=${ENTRA_USERNAME_CLAIM:-preferred_username} - ENTRA_GROUPS_CLAIM=${ENTRA_GROUPS_CLAIM:-groups} - ENTRA_EMAIL_CLAIM=${ENTRA_EMAIL_CLAIM:-upn} - ENTRA_NAME_CLAIM=${ENTRA_NAME_CLAIM:-name} + - ENTRA_ROLE_TOKEN_KIND=${ENTRA_ROLE_TOKEN_KIND:-id} ports: - "8888:8888" volumes: diff --git a/docs/auth.md b/docs/auth.md index 3901224a..1e12174c 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -45,6 +45,9 @@ KEYCLOAK_M2M_CLIENT_SECRET=your_keycloak_m2m_client_secret # ENTRA_TENANT_ID=your-tenant-id-or-common # ENTRA_CLIENT_ID=your-application-client-id # ENTRA_CLIENT_SECRET=your-client-secret-value +# ENTRA_TOKEN_KIND=id # 'id' or 'access' - which token to use for user info +# ENTRA_GRAPH_URL=https://graph.microsoft.com # For sovereign clouds +# ENTRA_M2M_SCOPE=https://graph.microsoft.com/.default # M2M scope # Egress Authentication (Optional - for external services) EGRESS_OAUTH_CLIENT_ID_1=your_external_provider_client_id diff --git a/docs/configuration.md b/docs/configuration.md index 056b62ec..0bc3afd4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -108,12 +108,25 @@ cat keycloak/setup/keycloak-client-secrets.txt ### Microsoft Entra ID Configuration (if AUTH_PROVIDER=entra_id) +#### Required Variables + | Variable | Description | Example | Required | |----------|-------------|---------|----------| | `ENTRA_TENANT_ID` | Azure AD tenant ID (or 'common' for multi-tenant) | `12345678-1234-1234-1234-123456789012` | ✅ | | `ENTRA_CLIENT_ID` | Azure AD application (client) ID | `87654321-4321-4321-4321-210987654321` | ✅ | | `ENTRA_CLIENT_SECRET` | Azure AD client secret value | `abc123~XYZ...` | ✅ | -| `ENTRA_AUTHORITY` | Custom authority URL (for sovereign clouds) | `https://login.microsoftonline.com/{tenant_id}` | Optional | + +#### Optional Configuration Variables + +| Variable | Description | Example | Default | +|----------|-------------|---------|---------| +| `ENTRA_TOKEN_KIND` | Which token to use for user info extraction ('id' or 'access') | `id` | `id` | +| `ENTRA_GRAPH_URL` | Microsoft Graph API base URL (for sovereign clouds) | `https://graph.microsoft.com` | `https://graph.microsoft.com` | +| `ENTRA_M2M_SCOPE` | Default scope for M2M authentication | `https://graph.microsoft.com/.default` | `https://graph.microsoft.com/.default` | +| `ENTRA_USERNAME_CLAIM` | JWT claim to use for username | `preferred_username` | `preferred_username` | +| `ENTRA_GROUPS_CLAIM` | JWT claim to use for groups | `groups` | `groups` | +| `ENTRA_EMAIL_CLAIM` | JWT claim to use for email | `email` | `email` | +| `ENTRA_NAME_CLAIM` | JWT claim to use for display name | `name` | `name` | **Note: Getting Entra ID Credentials** @@ -149,19 +162,41 @@ To obtain these credentials from Azure Portal: **Sovereign Cloud Support** -For non-global Azure clouds, set the `ENTRA_AUTHORITY` variable: +For non-global Azure clouds, configure `ENTRA_GRAPH_URL` and `ENTRA_M2M_SCOPE`: ```bash # US Government Cloud -ENTRA_AUTHORITY=https://login.microsoftonline.us/{tenant_id} +ENTRA_TENANT_ID=your-tenant-id +ENTRA_GRAPH_URL=https://graph.microsoft.us +ENTRA_M2M_SCOPE=https://graph.microsoft.us/.default # China Cloud (operated by 21Vianet) -ENTRA_AUTHORITY=https://login.chinacloudapi.cn/{tenant_id} +ENTRA_TENANT_ID=your-tenant-id +ENTRA_GRAPH_URL=https://microsoftgraph.chinacloudapi.cn +ENTRA_M2M_SCOPE=https://microsoftgraph.chinacloudapi.cn/.default # Germany Cloud -ENTRA_AUTHORITY=https://login.microsoftonline.de/{tenant_id} +ENTRA_TENANT_ID=your-tenant-id +ENTRA_GRAPH_URL=https://graph.microsoft.de +ENTRA_M2M_SCOPE=https://graph.microsoft.de/.default +``` + +**Token Kind Configuration** + +The `ENTRA_TOKEN_KIND` variable determines how user information is extracted: + +```bash +# Use ID token for user info (recommended - fast, standard OIDC) +ENTRA_TOKEN_KIND=id + +# Use access token for user info (alternative) +ENTRA_TOKEN_KIND=access ``` +- `id` (default): Extracts user info from ID token (OpenID Connect standard, fast) +- `access`: Extracts user info from access token +- Automatic fallback to Microsoft Graph API if token extraction fails + **Multi-Tenant Configuration** To support any Microsoft organizational account: diff --git a/docs/entra-id-implementation.md b/docs/entra-id-implementation.md index 75cefdb0..de868f7c 100644 --- a/docs/entra-id-implementation.md +++ b/docs/entra-id-implementation.md @@ -33,7 +33,13 @@ def __init__( tenant_id: str, client_id: str, client_secret: str, - authority: Optional[str] = None, + auth_url: str, + token_url: str, + jwks_url: str, + logout_url: str, + userinfo_url: str, + graph_url: str, + m2m_scope: str, scopes: Optional[list] = None, grant_type: str = "authorization_code", username_claim: str = "preferred_username", @@ -47,7 +53,13 @@ def __init__( - `tenant_id`: Azure AD tenant ID (use 'common' for multi-tenant) - `client_id`: Azure AD application (client) ID - `client_secret`: Azure AD client secret -- `authority`: Optional custom authority URL (defaults to global Azure AD) +- `auth_url`: OAuth2 authorization endpoint URL +- `token_url`: OAuth2 token endpoint URL +- `jwks_url`: JSON Web Key Set endpoint URL +- `logout_url`: Logout endpoint URL +- `userinfo_url`: User info endpoint URL (typically Graph API /me endpoint) +- `graph_url`: Microsoft Graph API base URL (for sovereign clouds) +- `m2m_scope`: Default scope for machine-to-machine authentication - `scopes`: List of OAuth2 scopes (default: `['openid', 'profile', 'email', 'User.Read']`) - `grant_type`: OAuth2 grant type (default: `'authorization_code'`) - `username_claim`: Claim to use for username (default: `'preferred_username'`) @@ -56,12 +68,9 @@ def __init__( - `name_claim`: Claim to use for display name (default: `'name'`) **Endpoints Configured:** -- `token_url`: `{authority}/oauth2/v2.0/token` -- `auth_url`: `{authority}/oauth2/v2.0/authorize` -- `jwks_url`: `{authority}/discovery/v2.0/keys` -- `logout_url`: `{authority}/oauth2/v2.0/logout` -- `userinfo_url`: `https://graph.microsoft.com/v1.0/me` -- `issuer`: `https://login.microsoftonline.com/{tenant_id}/v2.0` +- All endpoint URLs are explicitly provided via constructor parameters +- This design supports sovereign clouds and custom deployments +- `issuer`: Automatically derived as `https://login.microsoftonline.com/{tenant_id}/v2.0` ### Token Validation @@ -141,23 +150,61 @@ def refresh_token(self, refresh_token: str) -> Dict[str, Any]: ### User Information Retrieval -The implementation integrates with Microsoft Graph API to fetch user profile information: +The implementation provides flexible user information extraction with automatic fallback mechanisms: ```python -def get_user_info(self, access_token: str) -> Dict[str, Any]: +def get_user_info( + self, + access_token: str, + id_token: Optional[str] = None +) -> Dict[str, Any]: ``` -**Graph API Endpoint:** `https://graph.microsoft.com/v1.0/me` +**Token Strategy:** + +The method supports three extraction strategies controlled by the `ENTRA_TOKEN_KIND` environment variable: + +1. **ID Token Extraction (Recommended)**: `ENTRA_TOKEN_KIND=id` + - Extracts user information from the ID token (OpenID Connect standard) + - Fast: Local JWT decoding, no network calls + - Contains standard user claims: username, email, name, groups + +2. **Access Token Extraction**: `ENTRA_TOKEN_KIND=access` + - Extracts user information from the access token + - Used when ID token is not available + - May not contain all user claims + +3. **Graph API Fallback** (Automatic): + - Falls back to Microsoft Graph API if token extraction fails + - Makes HTTP request to `{graph_url}/v1.0/me` + - Provides complete user profile information **Returned Fields:** -- `username`: User Principal Name (UPN) +- `username`: User Principal Name (UPN) or preferred_username - `email`: Mail address or UPN - `name`: Display name -- `given_name`: First name -- `family_name`: Last name +- `given_name`: First name (Graph API only) +- `family_name`: Last name (Graph API only) - `id`: Object ID -- `job_title`: Job title -- `office_location`: Office location +- `job_title`: Job title (Graph API only) +- `office_location`: Office location (Graph API only) +- `groups`: List of group display names (from separate Graph API call) + +### Group Membership Retrieval + +User groups are fetched separately using the Microsoft Graph API: + +```python +def get_user_groups(self, access_token: str) -> list: +``` + +**Graph API Endpoint:** `{graph_url}/v1.0/me/transitiveMemberOf/microsoft.graph.group` + +**Features:** +- Fetches transitive group memberships (includes nested groups) +- Returns group display names as a list +- Automatically called by `get_user_info()` method +- Handles errors gracefully (returns empty list on failure) ### Machine-to-Machine (M2M) Support @@ -174,8 +221,9 @@ def get_m2m_token( **Client Credentials Flow:** - `grant_type`: `client_credentials` -- Default scope: `https://graph.microsoft.com/.default` +- Default scope: Configured via `m2m_scope` parameter (typically `https://graph.microsoft.com/.default`) - Supports custom client credentials for service accounts +- Sovereign clouds: Scope is automatically adjusted based on `graph_url` configuration #### M2M Token Validation @@ -202,40 +250,87 @@ entra_id: display_name: "Microsoft Entra ID" client_id: "${ENTRA_CLIENT_ID}" client_secret: "${ENTRA_CLIENT_SECRET}" + # Tenant ID can be specific tenant or 'common' for multi-tenant tenant_id: "${ENTRA_TENANT_ID}" auth_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/authorize" token_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token" + jwks_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/discovery/v2.0/keys" user_info_url: "https://graph.microsoft.com/v1.0/me" logout_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/logout" scopes: ["openid", "profile", "email", "User.Read"] response_type: "code" grant_type: "authorization_code" - username_claim: "preferred_username" - groups_claim: "groups" - email_claim: "email" - name_claim: "name" + # Entra ID specific claim mapping + username_claim: "${ENTRA_USERNAME_CLAIM}" + groups_claim: "${ENTRA_GROUPS_CLAIM}" + email_claim: "${ENTRA_EMAIL_CLAIM}" + name_claim: "${ENTRA_NAME_CLAIM}" + # Microsoft Graph API base URL (for sovereign clouds) + graph_url: "${ENTRA_GRAPH_URL:-https://graph.microsoft.com}" + # M2M (Machine-to-Machine) default scope + m2m_scope: "${ENTRA_M2M_SCOPE:-https://graph.microsoft.com/.default}" enabled: true ``` ### Environment Variables +#### Required Variables + ```bash -# Required +# Microsoft Entra ID Configuration ENTRA_CLIENT_ID=your-application-client-id ENTRA_CLIENT_SECRET=your-client-secret-value ENTRA_TENANT_ID=your-tenant-id-or-common +``` -# Optional - For sovereign clouds -# ENTRA_AUTHORITY=https://login.microsoftonline.us # US Government -# ENTRA_AUTHORITY=https://login.chinacloudapi.cn # China +#### Optional Configuration Variables -# Optional - Custom claim mappings (defaults are shown) +```bash +# Token Configuration +# Determines which token to use for extracting user information +# - 'id': Extract user info from ID token (default, recommended) +# - 'access': Extract user info from access token +# If token extraction fails, the system will automatically fallback to Graph API +ENTRA_TOKEN_KIND=id + +# Microsoft Graph API Configuration +# For sovereign clouds or custom Graph API endpoints +# Default: https://graph.microsoft.com +ENTRA_GRAPH_URL=https://graph.microsoft.com + +# M2M (Machine-to-Machine) Scope Configuration +# Default scope for client credentials flow +# Default: https://graph.microsoft.com/.default +ENTRA_M2M_SCOPE=https://graph.microsoft.com/.default + +# Custom Claim Mappings (defaults are shown) ENTRA_USERNAME_CLAIM=preferred_username ENTRA_GROUPS_CLAIM=groups ENTRA_EMAIL_CLAIM=email ENTRA_NAME_CLAIM=name ``` +#### Sovereign Cloud Configuration + +For non-global Azure clouds, update `ENTRA_GRAPH_URL` and `ENTRA_M2M_SCOPE`: + +```bash +# US Government Cloud +ENTRA_TENANT_ID=your-tenant-id +ENTRA_GRAPH_URL=https://graph.microsoft.us +ENTRA_M2M_SCOPE=https://graph.microsoft.us/.default + +# China Cloud (operated by 21Vianet) +ENTRA_TENANT_ID=your-tenant-id +ENTRA_GRAPH_URL=https://microsoftgraph.chinacloudapi.cn +ENTRA_M2M_SCOPE=https://microsoftgraph.chinacloudapi.cn/.default + +# Germany Cloud +ENTRA_TENANT_ID=your-tenant-id +ENTRA_GRAPH_URL=https://graph.microsoft.de +ENTRA_M2M_SCOPE=https://graph.microsoft.de/.default +``` + ## Error Handling The implementation includes comprehensive error handling: @@ -301,22 +396,50 @@ logging.basicConfig( ## Extensibility -### Custom Authority URLs -Support for sovereign clouds: -- Azure US Government: `https://login.microsoftonline.us` -- Azure China: `https://login.chinacloudapi.cn` +### Sovereign Cloud Support + +The implementation is fully compatible with sovereign clouds through configuration: + +**Azure US Government:** +```bash +ENTRA_TENANT_ID=your-tenant-id +ENTRA_GRAPH_URL=https://graph.microsoft.us +ENTRA_M2M_SCOPE=https://graph.microsoft.us/.default +``` + +**Azure China (21Vianet):** +```bash +ENTRA_TENANT_ID=your-tenant-id +ENTRA_GRAPH_URL=https://microsoftgraph.chinacloudapi.cn +ENTRA_M2M_SCOPE=https://microsoftgraph.chinacloudapi.cn/.default +``` + +**Azure Germany:** +```bash +ENTRA_TENANT_ID=your-tenant-id +ENTRA_GRAPH_URL=https://graph.microsoft.de +ENTRA_M2M_SCOPE=https://graph.microsoft.de/.default +``` ### Custom Scopes Easily extendable to support additional Microsoft Graph permissions: ```yaml -scopes: ["openid", "profile", "email", "User.Read", "Mail.Read", "Calendars.Read"] +scopes: ["openid", "profile", "email", "User.Read", "Mail.Read", "Calendars.Read", "Group.Read.All"] ``` ### Multi-Tenant Support - Use `tenant_id: "common"` for multi-tenant applications +- Use `tenant_id: "organizations"` for organizational accounts only +- Use `tenant_id: "consumers"` for personal Microsoft accounts only - Automatic tenant discovery and validation +### Token Kind Flexibility +Configure token extraction strategy based on your needs: +- `ENTRA_TOKEN_KIND=id` for standard OpenID Connect flow (recommended) +- `ENTRA_TOKEN_KIND=access` for access token-based extraction +- Automatic fallback to Graph API ensures reliability + ## Testing ### Unit Testing @@ -336,12 +459,23 @@ The implementation can be tested with: ```python from auth_server.providers.entra import EntraIDProvider +import os + +# Set environment variables +os.environ['ENTRA_TOKEN_KIND'] = 'id' # Use ID token for user info -# Initialize provider +# Initialize provider with all required parameters provider = EntraIDProvider( tenant_id="your-tenant-id", client_id="your-client-id", - client_secret="your-client-secret" + client_secret="your-client-secret", + auth_url="https://login.microsoftonline.com/your-tenant-id/oauth2/v2.0/authorize", + token_url="https://login.microsoftonline.com/your-tenant-id/oauth2/v2.0/token", + jwks_url="https://login.microsoftonline.com/your-tenant-id/discovery/v2.0/keys", + logout_url="https://login.microsoftonline.com/your-tenant-id/oauth2/v2.0/logout", + userinfo_url="https://graph.microsoft.com/v1.0/me", + graph_url="https://graph.microsoft.com", + m2m_scope="https://graph.microsoft.com/.default" ) # Generate authorization URL @@ -356,17 +490,28 @@ token_data = provider.exchange_code_for_token( redirect_uri="https://your-app/callback" ) -# Validate token -user_info = provider.validate_token(token_data["access_token"]) +# Get user information (includes groups) +# Pass both access_token and id_token for best results +user_info = provider.get_user_info( + access_token=token_data["access_token"], + id_token=token_data.get("id_token") # Optional but recommended +) + +print(f"User: {user_info['username']}") +print(f"Email: {user_info['email']}") +print(f"Groups: {user_info['groups']}") -# Get user profile -profile = provider.get_user_info(token_data["access_token"]) +# Get user groups separately (if needed) +groups = provider.get_user_groups(token_data["access_token"]) ``` ### Machine-to-Machine Authentication ```python -# Get M2M token +# Get M2M token using client credentials flow +m2m_token = provider.get_m2m_token() + +# Or specify custom scope m2m_token = provider.get_m2m_token( scope="https://graph.microsoft.com/.default" ) @@ -375,6 +520,35 @@ m2m_token = provider.get_m2m_token( validation_result = provider.validate_m2m_token(m2m_token["access_token"]) ``` +### Sovereign Cloud Example + +```python +import os + +# Configure for Azure US Government +os.environ['ENTRA_GRAPH_URL'] = 'https://graph.microsoft.us' +os.environ['ENTRA_M2M_SCOPE'] = 'https://graph.microsoft.us/.default' + +provider = EntraIDProvider( + tenant_id="your-tenant-id", + client_id="your-client-id", + client_secret="your-client-secret", + auth_url="https://login.microsoftonline.us/your-tenant-id/oauth2/v2.0/authorize", + token_url="https://login.microsoftonline.us/your-tenant-id/oauth2/v2.0/token", + jwks_url="https://login.microsoftonline.us/your-tenant-id/discovery/v2.0/keys", + logout_url="https://login.microsoftonline.us/your-tenant-id/oauth2/v2.0/logout", + userinfo_url="https://graph.microsoft.us/v1.0/me", + graph_url="https://graph.microsoft.us", + m2m_scope="https://graph.microsoft.us/.default" +) + +# Use provider normally - all Graph API calls will use the sovereign cloud endpoint +user_info = provider.get_user_info( + access_token=token_data["access_token"], + id_token=token_data.get("id_token") +) +``` + ## Troubleshooting ### Common Issues @@ -383,15 +557,35 @@ validation_result = provider.validate_m2m_token(m2m_token["access_token"]) - Check audience and issuer configuration - Verify JWKS endpoint accessibility - Ensure token hasn't expired + - Check that `jwks_url` is correctly configured for your tenant 2. **API Permission Errors** - Verify delegated permissions are granted - Check admin consent for application permissions - Validate scope configuration + - Ensure `Group.Read.All` permission if fetching groups 3. **Multi-Tenant Issues** - Ensure app registration allows multi-tenant access - Verify tenant ID is set to "common" for multi-tenant apps + - Check that users are from supported tenant types + +4. **Token Kind Configuration Issues** + - If `ENTRA_TOKEN_KIND=id` but no ID token in response, check OAuth scopes include `openid` + - System will automatically fallback to access token or Graph API + - Check logs to see which extraction method was used + +5. **Sovereign Cloud Issues** + - Verify `ENTRA_GRAPH_URL` matches your cloud environment + - Ensure `ENTRA_M2M_SCOPE` uses the correct Graph API URL + - Check that all OAuth endpoints (auth_url, token_url, jwks_url) match your cloud + - Confirm app registration is in the correct cloud tenant + +6. **Group Retrieval Failures** + - Ensure access token has `Group.Read.All` or `Directory.Read.All` permissions + - Check that user is member of groups in Azure AD + - Verify Graph API endpoint is accessible + - Check logs for specific Graph API error messages ### Debug Mode diff --git a/docs/entra-id-setup.md b/docs/entra-id-setup.md index 644a41b7..419921af 100644 --- a/docs/entra-id-setup.md +++ b/docs/entra-id-setup.md @@ -64,26 +64,60 @@ This guide provides step-by-step instructions for setting up Microsoft Entra ID Add the following environment variables to your MCP Gateway Registry deployment: +### Required Variables + ```bash # Microsoft Entra ID Configuration ENTRA_CLIENT_ID=your-application-client-id ENTRA_CLIENT_SECRET=your-client-secret-value ENTRA_TENANT_ID=your-tenant-id-or-common - -# Optional: For sovereign clouds -# ENTRA_AUTHORITY=https://login.microsoftonline.us # US Government -# ENTRA_AUTHORITY=https://login.chinacloudapi.cn # China ``` -**Optional Claim Mapping Environment Variables:** +### Optional Configuration Variables + ```bash -# Optional: Custom claim mappings (defaults are shown) +# Token Configuration +# Determines which token to use for extracting user information +# - 'id': Extract user info from ID token (default, recommended) +# - 'access': Extract user info from access token +# If token extraction fails, the system will automatically fallback to Graph API +ENTRA_TOKEN_KIND=id + +# Microsoft Graph API Configuration +# For sovereign clouds or custom Graph API endpoints +# Default: https://graph.microsoft.com +ENTRA_GRAPH_URL=https://graph.microsoft.com + +# M2M (Machine-to-Machine) Scope Configuration +# Default scope for client credentials flow +# Default: https://graph.microsoft.com/.default +ENTRA_M2M_SCOPE=https://graph.microsoft.com/.default + +# Custom Claim Mappings (defaults are shown) ENTRA_USERNAME_CLAIM=preferred_username ENTRA_GROUPS_CLAIM=groups -ENTRA_EMAIL_CLAIM=upn # upn or email +ENTRA_EMAIL_CLAIM=email ENTRA_NAME_CLAIM=name ``` +### Sovereign Cloud Configuration + +For non-global Azure clouds, update **both** `ENTRA_TENANT_ID` and `ENTRA_GRAPH_URL`: + +```bash +# US Government Cloud +ENTRA_TENANT_ID=your-tenant-id +ENTRA_GRAPH_URL=https://graph.microsoft.us + +# China Cloud (operated by 21Vianet) +ENTRA_TENANT_ID=your-tenant-id +ENTRA_GRAPH_URL=https://microsoftgraph.chinacloudapi.cn + +# Germany Cloud +ENTRA_TENANT_ID=your-tenant-id +ENTRA_GRAPH_URL=https://graph.microsoft.de +``` + **Note**: URLs, scopes, and default claim mappings are configured in `auth_server/oauth2_providers.yml`. Environment variables for claim mappings are only needed if you want to override the defaults. ## Step 5: Enable Entra ID Provider @@ -95,18 +129,25 @@ entra_id: display_name: "Microsoft Entra ID" client_id: "${ENTRA_CLIENT_ID}" client_secret: "${ENTRA_CLIENT_SECRET}" + # Tenant ID can be specific tenant or 'common' for multi-tenant tenant_id: "${ENTRA_TENANT_ID}" auth_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/authorize" token_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token" + jwks_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/discovery/v2.0/keys" user_info_url: "https://graph.microsoft.com/v1.0/me" logout_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/logout" scopes: ["openid", "profile", "email", "User.Read"] response_type: "code" grant_type: "authorization_code" - username_claim: "preferred_username" - groups_claim: "groups" - email_claim: "email" - name_claim: "name" + # Entra ID specific claim mapping + username_claim: "${ENTRA_USERNAME_CLAIM}" + groups_claim: "${ENTRA_GROUPS_CLAIM}" + email_claim: "${ENTRA_EMAIL_CLAIM}" + name_claim: "${ENTRA_NAME_CLAIM}" + # Microsoft Graph API base URL (for sovereign clouds) + graph_url: "${ENTRA_GRAPH_URL:-https://graph.microsoft.com}" + # M2M (Machine-to-Machine) default scope + m2m_scope: "${ENTRA_M2M_SCOPE:-https://graph.microsoft.com/.default}" enabled: true ``` @@ -126,6 +167,17 @@ entra_id: ### Multi-Tenant Setup For multi-tenant applications, set `ENTRA_TENANT_ID=common` and ensure the app registration is configured for multi-tenant access. +```bash +# Support any Microsoft organizational account +ENTRA_TENANT_ID=common + +# Support only organizational accounts (exclude personal accounts) +ENTRA_TENANT_ID=organizations + +# Support only personal Microsoft accounts +ENTRA_TENANT_ID=consumers +``` + ### Machine-to-Machine (M2M) Authentication For service accounts and automated processes: @@ -158,8 +210,14 @@ Modify the `scopes` configuration in `oauth2_providers.yml` to include additiona - Check token audience and issuer configuration 4. **Sovereign Cloud Issues** - - For Azure Government or China clouds, set the appropriate authority URL + - For Azure Government or China clouds, set the appropriate `ENTRA_GRAPH_URL` - Ensure app registration is in the correct cloud environment + - Verify OAuth endpoints match your cloud environment + +5. **Token Kind Configuration** + - If using `ENTRA_TOKEN_KIND=id` but ID token is not available, system will fallback to access token + - If using `ENTRA_TOKEN_KIND=access`, ensure access token contains user claims + - Check logs to see which token extraction method was used ### Logs and Debugging diff --git a/uv.lock b/uv.lock index 337621c9..506626f9 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = "==3.12.*" resolution-markers = [ "python_full_version >= '3.12.4'", @@ -397,14 +397,14 @@ wheels = [ [[package]] name = "faker" -version = "37.3.0" +version = "37.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/4b/5354912eaff922876323f2d07e21408b10867f3295d5f917748341cb6f53/faker-37.3.0.tar.gz", hash = "sha256:77b79e7a2228d57175133af0bbcdd26dc623df81db390ee52f5104d46c010f2f", size = 1901376, upload-time = "2025-05-14T15:24:18.039Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/84/e95acaa848b855e15c83331d0401ee5f84b2f60889255c2e055cb4fb6bdf/faker-37.12.0.tar.gz", hash = "sha256:7505e59a7e02fa9010f06c3e1e92f8250d4cfbb30632296140c2d6dbef09b0fa", size = 1935741, upload-time = "2025-10-24T15:19:58.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/99/045b2dae19a01b9fbb23b9971bc04f4ef808e7f3a213d08c81067304a210/faker-37.3.0-py3-none-any.whl", hash = "sha256:48c94daa16a432f2d2bc803c7ff602509699fca228d13e97e379cd860a7e216e", size = 1942203, upload-time = "2025-05-14T15:24:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl", hash = "sha256:afe7ccc038da92f2fbae30d8e16d19d91e92e242f8401ce9caf44de892bab4c4", size = 1975461, upload-time = "2025-10-24T15:19:55.739Z" }, ] [[package]] @@ -1031,7 +1031,9 @@ source = { virtual = "." } dependencies = [ { name = "aiohttp" }, { name = "bandit" }, + { name = "factory-boy" }, { name = "faiss-cpu" }, + { name = "faker" }, { name = "fastapi" }, { name = "httpcore", extra = ["asyncio"] }, { name = "httpx" }, @@ -1048,6 +1050,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, + { name = "pytest" }, { name = "python-dotenv" }, { name = "python-multipart" }, { name = "pytz" }, @@ -1092,8 +1095,10 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.8.0" }, { name = "bandit", specifier = ">=1.8.3" }, { name = "coverage", extras = ["toml"], marker = "extra == 'dev'", specifier = ">=7.4.0" }, + { name = "factory-boy", specifier = ">=3.3.3" }, { name = "factory-boy", marker = "extra == 'dev'", specifier = ">=3.3.0" }, { name = "faiss-cpu", specifier = ">=1.7.4" }, + { name = "faker", specifier = ">=37.11.0" }, { name = "faker", marker = "extra == 'dev'", specifier = ">=24.0.0" }, { name = "fastapi", specifier = ">=0.115.12" }, { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.4.0" }, @@ -1118,6 +1123,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "pyjwt", specifier = ">=2.10.1" }, { name = "pymdown-extensions", marker = "extra == 'docs'", specifier = ">=10.0.0" }, + { name = "pytest", specifier = ">=8.4.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, @@ -1833,7 +1839,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.0" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1842,9 +1848,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232, upload-time = "2025-06-02T17:36:30.03Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797, upload-time = "2025-06-02T17:36:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] From 2a3b133299bf15ca2d8afa0eab85584e895e0ec4 Mon Sep 17 00:00:00 2001 From: ryo Date: Tue, 4 Nov 2025 10:25:53 -0500 Subject: [PATCH 09/17] update the comment in token kind --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index 2bbee80b..9088fb48 100644 --- a/.env.example +++ b/.env.example @@ -149,6 +149,7 @@ ENTRA_NAME_CLAIM=name # M2M (Machine-to-Machine) Default Scope #ENTRA_M2M_SCOPE=https://graph.microsoft.com/.default +# Entra offers id,access token kinds # id token: OIDC get user info, JWT # access token: OAuth 2.0, Graph API ENTRA_ROLE_TOKEN_KIND=id From 8ce578ab16dc19fcfcb41e8a6a97a7b350acdcf6 Mon Sep 17 00:00:00 2001 From: ryo Date: Tue, 4 Nov 2025 10:57:04 -0500 Subject: [PATCH 10/17] update config to attach screenshot --- docs/configuration.md | 7 ++++--- docs/img/entra-token-kind.png | Bin 0 -> 89564 bytes 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 docs/img/entra-token-kind.png diff --git a/docs/configuration.md b/docs/configuration.md index 0bc3afd4..dd19f295 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -124,10 +124,9 @@ cat keycloak/setup/keycloak-client-secrets.txt | `ENTRA_GRAPH_URL` | Microsoft Graph API base URL (for sovereign clouds) | `https://graph.microsoft.com` | `https://graph.microsoft.com` | | `ENTRA_M2M_SCOPE` | Default scope for M2M authentication | `https://graph.microsoft.com/.default` | `https://graph.microsoft.com/.default` | | `ENTRA_USERNAME_CLAIM` | JWT claim to use for username | `preferred_username` | `preferred_username` | -| `ENTRA_GROUPS_CLAIM` | JWT claim to use for groups | `groups` | `groups` | -| `ENTRA_EMAIL_CLAIM` | JWT claim to use for email | `email` | `email` | +| `ENTRA_EMAIL_CLAIM` | JWT claim to use for email | `email,upn,preferred_username` | `email` | | `ENTRA_NAME_CLAIM` | JWT claim to use for display name | `name` | `name` | - +| `ENTRA_GROUPS_CLAIM` | JWT claim to use for groups | `groups` | `groups` | **Note: Getting Entra ID Credentials** To obtain these credentials from Azure Portal: @@ -185,6 +184,8 @@ ENTRA_M2M_SCOPE=https://graph.microsoft.de/.default The `ENTRA_TOKEN_KIND` variable determines how user information is extracted: +**Screenshot:** +![Azure Token Kind](img/entra-token-kind.png) ```bash # Use ID token for user info (recommended - fast, standard OIDC) ENTRA_TOKEN_KIND=id diff --git a/docs/img/entra-token-kind.png b/docs/img/entra-token-kind.png new file mode 100644 index 0000000000000000000000000000000000000000..779f0d2f7aa762ac86e6dbd711155a5cbb733df9 GIT binary patch literal 89564 zcmdRWgDMdmNX+gj7j_0}cy`Mke z<9C34_L)6z<1=W;{a%Tn)-cwk}sw+W3`O!f^g+xF>-GWmg2T)L6>`+jLrchA)Poba)T=Tot z1i=T6R=RT5N=i_S;1~i02mJu*XARKcR~VZ3KgZJ0^iZ&WoCmj!wu6HEtBf-E|MN!{ z{QkV=&;Kw*Fn^aoFe`%n`xxfo&!XqAR%yXMBv)BI4=5-UoS(nYP`UYppjRk%n!294 zN{ajzE>5gwmM-R2tiDdJKd*uk^yLSKPF9{~6uwT5&K~@}LR5d0;0MP)PqR@`{87Zy zL5NCMNtHsv#oda6hn1a`ok|#)f`UTO-O`$0T~hk5%fUM#DqBxaSAI4&A0Ho9pGT}N z?lx>3e0+Rt?3`?zoGhROi^mgZPcvT@XAkN>EBU(~Nh=QvcRN>4I~Qk)pY@uVyLfpD zQBnQ8(SLsaY^SH4^?&c=?D1E(KnK}=e!|AV%Fgz0ms$DR{g2Cje)8vKfAs6m+X?=x zj9=Z#!^P3-XREZF?L37!1^>9m@2CIUNWq_V@vGYTS~==U+BsP{dw@%YId~pD5@h?= zkN)*iz5iay&CT)8EC2DyUsnpU{T!};4Bwy6=8v=B;RquOvi;{l3nP<9hu}j&i9*Rq zifQ^n9~ZzIYUvRSzrq)Im88~Wpr&~2+|=XY_^Q6iK++Lj6Lkpv*!y?{-vW*puBQ*) zM(}OHw=fwI)6Fke)^9zos(!q{@I5+dN&eVi@>0<^1q$hxm#8hIQdlNN+A3L z{>_%J#ph`P3 z5_oo0G~hR+{@PMWBIv3Z_;?GkLJmv0jKomVzdjfBg|H_v3!dv{|Iy+`_rPR4cn7Uc*4j;ZyeWg?-w;inH^;EQ}?bB)JOyv)?qB#1PpNW*)^TkH7j03s;#s z9GW-DkoM2MiNaw))Nay>EwL6T^CtFj>IQ>1|zA*e{^)a90 zSbn)b6w(kYOs@V@=2Yq9Tn-zFKb{<>1ClCrd|O8r4)vcc`m0@I)J0k_>5+3Wz0i$T zf7h!EgGANN*Dn3vXZ~{>Fdrky8YvQ*y@CxkMd(whkOz8`-6kX~zf4_1 zQs^-=b*yyhp`^3XWb%un<=`LRU)Xx@jwI45WN|gd%OsO|9W5#GzU}c*QDHfsD^noY zZgf~?RZvh6npShQ8_!_SX)ucwP-SGX+v`ZYeSRO`{K?aG z4l-QI=~Q$u3>?`|%lac5{-des|9t2nU$A3zn~ryeAw!Ph5DCoo!<-hGzsCNrnKcDRrA>-cdU?G1 zT=M<#v0%XUDUDnjJ&HyB{g+*tVyN41-9@!-SI(ey)BItwU5bY$#1VsXGTb(!gwKQzrJk!dgSCQa`N)1ue+jJ$Kc!cJI z?j(bckFRUK+WW-A$lkQl@A1$oCb`6Wl<&m_wYJ*JP~wuIhx$k6gvEY@s5zv8z_65L z_eXr$P0dqZCM5IsSIK7=Th_YYPoSQE6Rq=?>G^vhm*n(UX;chBFf|t|)u~qa zL=*znyZfuRgex~+KY(L!fz_T&jb@d4ukR0O%Fdpic=+PQYAuJ2&M)ApV}x;@>v;`-QQju;XG6hnGA(RRApVWTWkIK%5=`XY_b@2rA`Sr0X<%Awi-Nv znnXZ*Agh8Ghe0lkO2{s12rpX7x8C9McJ{f9T8CDRw&_!LGjUaSPtPG=cIu5t9;Y?J zXM@U@3gK|F{Sc({kMpC`&O@cIgSS~49*_l1DnDg+zCF*Wc868L*^|#x&31UVY|_n| zL(947#o&uWctRi>A5Kk&ma&s3qkWdn!vYfMk1bAmsSsc%?}~ zb0}?)QL7r_|LyVwEVJ;tVn>hRIJ_2{H(9hPMy}_d-Xt4GLbsNk@KD`)+{%g4NBgCbyhUpFHOY)_ev}RdtY_mwa9Y5T*=znTGx)83&n;LP`e+FCol-dkhNB)3t!{)JvChw z)(%8=p6Nd~S!O@k}NwO8x#& zvu6T6&gTcSu{aM$Xz}7)-uB5kcYd76V!>Hd!@__n*K2pwV<<6`UJt`43ZG;YC=mm; zPy*?xfvzY#F~8@*q+ScB%`~&km%AGee*uAm_pT|C#he2)N|Duw{J$6pU@$~=k*;rU zPBwdV7NSJ6fQ>RAj*HcRBWExeu2QE+{{oMWjxPUr<;UGum6t*HmXr}ZwsO4OmZQ@2 z^75gm#Wv~{5{)ZqvRs#*P)yE2lD)VJ6+|aE~z5M<(PaT7vByQ6gid4 zjZJinS%1*~mzdgw}1 z%rR+H^6vx7I*QOE^gXHcs4b#V-oQ7`d*=W~ z6=n^J6q5Lx#_#A%MBoWkon!HsckZ#~#PEvtV((Nh)f?4(tYQ?KN!QG1w8et9#2i+A z0j&A}jsuO!3%{~z_?!2WIgeCtS&h0iE0jsy@Z4NyrjhX&TLW&+z_}Q4dS~8Y=o7_7 zi1Of)06vR38nxf3xH6FxZB?99s;wa^HWjs?mEh+0`)_E9Oi>?0q6oj{ayz&>v^%cC zNU3Cm@MsGo8(c8DP;oXCJD@@dHZ7@lZueE*ajS5+he-K6%~)7|hY*T^CLS&kP*@4S zh_Oy>+uo1;({IT_VSy>FGFqZgX>pBcad=KN-q_5p823Pru&4?77iX3dk}C?wTCd5I z$jj`u)@UYSMlmT*sO$9Mn?M+pnY{Y*P+jdJQRr(L`3y)^RSLA?^@I-d1`dz9GvzD2 zyX!BhGLvg-Yg=2!x3=89FJ4&jg{4Wh5>zKY;Fhm`1guDw@PMdq?wD{El~f!sQnGJ= zbF31)D2%G}*`ppTQEp9GOp^V<)-4*a`r%EXnM4Z?6BX7Zyx~r@CgpY%udrI>4?Loo z2#F5o%_1`7^|`sZ>1lN6KwcUTEdfl9VW<>ZB}14uSu6&~#D~wuO}^sdLF4c1wOBjN zmBzY;j7O1J4rC$IDeuElvwq10U{o5X|4kMW4&AC*XxU~A63)YD4GLJ~_s7YzZ(tEs zegPj%IZ*ggvZG zs7Nv06IUn;fr|=zBZC@z)%H}V@G~i%l6Kha=X1}H`Irz!0y2c%=&>mLCDkQIRXLpwxHB-F8 zUiB*%>I(;+5=Jq8o>B~)$e3w~Mq}VEp;hTFbZ#7kw5ZThi>o6qPu3MqFx&!Y@S<{dF?AF(g8Fg1J3ViqaNq#8f?M3s%n6qn-@_7{gkwkW6WW(iJCBW{P1Wje1k5xxgmW`SEf2N7$G|OM5KR zXF;_Jc(Afh*(td>_X|ptK-@wVLUX^epxzZv+b4y|NmTlS-Nos3{ypQgAniY`Ei^Ve zG7QxVf~c^zNB)<`L_gk2l>3^z3=(Q3D=mvoq8mv>y@iH`9+1whLnG#?e6P`JtER^3 zvL%>fPQGmfoV&)8YGtfOI)z5q=dF}0uYH^DZoW>qo0-1jL|ZD6icf))C2voj#vFeQM4}`1x2WAu&eGP0i}k%gBiN;{~i*TBw5i`U zvHMks9c`+(F9?xGRi1WwuFkpbjwl2VqS;$UwUj*kZTjM3h2+!h)=?1JUB)G%OifKi zVo*&NrBb#mwmB>pP4pAZB^XN4XG4!m$Kf#{FxXE%hOcUmeeU*u0`@A2}D-KT;l6J zwUrYGb7d&lM!SsCF_`SzbRB4mS_&;B#MBhJs8j^3p~)E(kq?yt#H-gsNz1RRW0B-A z96%^{$CzhF6>V|L0#AHM8)o;R;>@EqVALa(C?xU>9jXwS*?y-AFhV7w(PNV+BgD|> zN#IH=-%sVmyVs&gO0#Du=5kem8SRcnT`!JuG+&WyeW6{aPkEr2(_U-P;S9iJ`m@|r zhaK#J=a}x1`}yPj^fbJKYbv$R7dCjYQD}Tl*5DCWIR|P}=uB8yB>9#Q>x_DqMn zAreVqht3F>=#OW^GPX6*b~L%rwNUH}(sTg?C15jVzWc)1oduf`<(Y!ujN=|BRyD+u z9*IfMaSwoHJhq7~bw-~VMq@|+U$wQU{s>LZ4os4=Y=1|KP!M_%0f<50K!`l8u~`6^kVnf6@^v(E(B|24mZNVn7&KyXV+sok zU%nKHs%CJBnTN~)Ns^)+&n8}rp5R!N>V;+Xxo;H`e3Wicgb8HTVW-o13C-|}1B z`f_ApanDCH(1%Y$pUtsIM4`@=|54Yhc!&ebb9e!P-wfEF@i6$yPW6+MIk0x6|LxBI zc?+tz87uj{iqNMrqL$=1yIDD7uE@mEYWvjj-koD7k+6g8j~>l;mszwXr@;Ps7ga1% zVNN{EpI+n_XDsTAL=peen(D_X&-m@1w@Z}+&81FfUH=8${=@dhApPWqB6@~6ey{EC z$|dQb?JZN~vYr0VL;vgULvS!ZUdu$4rp4p4$fwgOH5k7raX&2-3FhJER?6pf)AY92 z3=F(If4#o`4Vb9lyDu_vxC{yQjwGnRw*T)t4*9|SSY19_tkGFoS^@#5K)`k5QjSGO zZb%4JUACp!Km-!F4Wo(#R}tD`tsagupfMuV@k@e_Jyrl2GL}j`+$)_-w%KYoZ#Q2~ z`Ksc@i@-3rA6OiT!JmMUxcdB-oWooaK=H)jRuam8v_Z5I@@_ZeoS;1*@>L)UEy+(1 z1YR7eq6aij0o2r~)7SbT$fQ->YQM;MFFal|Zqhn_#%m57t3Jdsg$@OmJ_UrFAjItM z>9M#)d%#7nRw9YI6EbS?Dh!^`HI+&Vv=2XAdb;>uM|;Q(rg(mAXBdSqSg$f{Y=<{c z@$K+1d;^Gf^7y^%0dzKd8F16An8O)EJy9s~a`p_cRP=n>YbHv*_zJ>*FbECE_~8-!z)e?@D-K=8L_hibOz63EavTAhZ1di~-V&1l z5q*4EwU5YNRe5ik8@>BjO;_ok+Ax{r|+P(zrpgYlu5G1{+xw&T7?Sa?n4@XJ1W3!|Pa}OTC zj`U}Ez3mG`!Cm3NnUU;D%S=wjm@6^E*Q(aU()n@ofjj~FW|)28c(nz#Xv692YKzUv zIzMr< zdQCfe`NIBwQRbZd3hkv%x6()CXDV={U`s$)p1KXG>t- zls;SoK^0mAjb6!O2&BzzS6YkUwD-wZ>k`RW(}3G^BqLsa{_1L#9L_SSczkt5KDQk> z9`9PU=h79*1%R(8@*Js5@k6COsIi*JI>Ww;L~BBg%LM*eIg>b0oQnj|Tz!GxEb#~x zvX}u?hLHdQ9VE0P(7zSvBInI+K(=Z6nQI*viG?$2Rzjhd7&eE!gC~x}+$>5JTYwea zd!V5mtsdJ4Cu(E!0T`S38p32k>~cQm4PIRH@svS9LBW~aQ;t)Plony{V_o3XC^^TW zTOF3sCDMGYoL)!o%K z1TD7C@^u=-Etk)Oj*HjUri5S>w_Q6z5f;80l#8JYXO7f}QSI?QnPc-#0PN$@Qk^a0 z1Drh?=FUNOLn9-p3DvfujRKKivWk=lKs&(6?E?-O1lN&%@MtxFV|7Pm2W#Bt^qp8j zv0OSGBtgH&n*}C=!cJlZKATT65q-ggq&DFg`%2<;?8{lpf`zxFU$sk@Mxjxxg*bjlP7<`%8Y*75tsdf zF4tC@!7}{U4}ioCn%iT*+9Lh;0cU50nGx87mO`qIPE1T>o^>$|CA%xuwd)p}c|}Vy z6GdSmhe*#^a@QXUTY`CnFNL(x7yKg$^N|~}m-JFtIURDb$7}IDZJj6Lq1d!}JWgrO zs%?^#sCt!l`GQZ>I~jUHh@ZxMkr2i~hpeS7Qs#?w;q2@B(vO!t90@0i6&Dc^2?_fE zh+Qi$!jexX>m63b*fI1OXjCOi-DakkXOdnJgAx)mPkJJD(-2P_1iDCfxF@gOUn3(w zKSQ9bMRlhUzbUPNi)l+~R-N<%0cNu#p?MOf#IPM98LX|D?kC3TaO0~_n;h;Co9ZO0 z!-Xj)iErLcOuE@FYo)QHOLfhw+%-Dc%xb+lZ^*J)jWZ(ufbV~0&XREY-zJ{*n+))t zA`+tqRK&ZGqzb{Jun7lThp?HX9>Frm@9?rJ7yc2}P*`Kd)aKut+epLFi*2fo#OoOt z0Ct*$lhTqoQ#Zz>tpg|X*S~bKb zsZ+%-MS+cvKvnpHb^Lk<#mu3T5tm5maR4P9r==ND`5~jbj+x#L7||45lP5?6`Ha~j zT*AB&+I9oWZY2(7!u0%UhndZ9RaY_LQVC32kS$4v!YE77(!nUEy7udYhd!{-)s#c4< zP3<#ojvHEqq9O$YFS)Gw|G0{O9>n;;q?V<|_8NhtOs)pDnvSd+{Dlm|OiZh}Zl4a| zrma}UkW08NzYi4m(m2-gYvNP&%RfODP|D@<)f&fPL}X6Kr3DT{%Luz-PV6XQXgW__ z0b47As@ze@8Lf&LbryksJG_8HYufKpNcG7fKOS<%8RJ3|pT~R0l}0f=LTdrk|c|)p#jP`o?i)ovN_`GlN;}UiTPA7#LqIKXK`d9vkx)j64RStifYpvaV56{F%=X z*35iSY3yes4;T7_B=#?lbX5C#w_f=3@v%fIB1mzI!!@fWhED$rjuM5LRS;$AkzBA% z;X4AbLboI zOb!#3XM$?)#nKbwWjXTASD%-|+HYfX34bIHje_(@gbND`1H`1=Ufbn}g7WGWd zW(xa>oE?RsGr%eIugY^U$M(wD_f&Db22Tmcl%J2Bo^^8vtekdzeJ>Jpyji33=Ht9g zga|*Iz4Wy4p9BBfU#b+SG+L=~N|?UJXli^-Mgh@&^mDchip;)d5}vepN^&BqlYO5-^gg+8o50cd;>k2qY%V1aPw(@e1iuICi%ZyosaZ9?47=~Te)H6V!92izHb zmA6^(4#2BJJQ)BB%)^E9Rsm+Dgk%T)(jY?|gJ)Edva}Ga4hE?XfJ3RbyR8Mx=!y6} z>Fu4?+tF69%CMuol2$}TX*h{F%p+WNo6I9UeXl-|UYbch0r?h3N5_E~ml&ZsjKW1mrL;4QDgKTX1aj$`!Ts2u8O zu_vi71R^Q`#1NNb(5WZMf(9uqW#(H6zaXm-4qDFRMaV^k#b{E>-0kH_yet>o6Em5K zp!-`E<6goe4lE_@Ds|eu%Wn65jMeTh;A%VSixWkN#9O3^V&7s?o`WyKM#}JNySo3bM;I!b2Ml z`>8D2D`8O}@PbfvB^H}AeFoV|V*v8iqR9Rsr}A>G-V4+1k~?^0dgA0#_?B_j|4W_x zbRvY5p{~QJu>ptGy`SCF5+87fg~2Kz%EwhyJRIUYV7401SbgE|17eV}JZtsKv;7GH zUfzCaqN(p7{k9$J+XPHbEXk=Ybe-5E@#Yo)h9}A;)dQvwyfP=^2KuvdXD(*oM`AAl zPKZzKa9CD;zgEa)hdn|8N3Ey?u~c0$jmdmkLdpT9r*K)KN5#f4TH6{a0emQcz(q0J zxO3YezQHnk?7k;^5H%o{Bkpke?iH0ZhB^U_h$Y8hBM1hORQ91F>JCxXVJP&`t{~Hx z%c2zEG$w7TwxSj(Eg=+gD1<+^nsndpDLE`xk^~Peni|=6onziA?6HBjbpK=86vHVF zi;HvIg2hP544IX1uQH05L|$j3A`;|Es?}?WD?Qt`y9D&DY+j@i4c7D0g{Xn~^6L6y z826{z@R&6+#X__y&q(+dsMrD&xf%1rXqv;#KAAJAFmn%^OGgNcO{((^;s_vx@Pti^ z*RXD<6lFXGW)i5!t>M@yv zqp><;z9#2$hilMZ9nc_JvzFbRL(F~fJ(pt- z18qy3nnJUDlW+lnf!sbRL*klEudPAUFtEYN^sh_@BRROuPN)x^JgG8#-D$eoSJMCT)?>5Sh<_g1(e)~u4Ojalr9MmJZJiJ7xfkw01# zqK0KU9rV1L^nVdgDWY7MKuU%-vr79vx&W#ulD5|D2oL{DF!g_8ych()|CK=V_xx-4 z2gZ(_@h3re=?>z%h)Kz{{2ybydEr|{br#GjQ-dBA;bd(q`d@q3IA<&fN=@6r2w+G2SqIk z|Dyo(b1Koq$x3HL#6PuP|3dws0d`Uf*3(<=--sbl-c%D5AX+?mXZo*h7Mv^zh2n0u z4Vh;B=hFRW;eBWVR$9t|M!1WXA(lTz633iH@51m#Xb7%&FU_a>U zlTmSX$;6ZGu$xY-?)s>fyX|nq!Duuz8)|8Fp>8rH6_q#MPV1e#0x}%YK)%#q)PhhT z{ciB`aIpsPr%UyE)IjxmH-Gc}zGru5*k!9129W7n zUt|OaL!+B14$}FfsnjwQb06*4*iQ#ewhJR^q`=i@E;_+wx=n%ZT3XQOyYup0nhyX zMZ`7y$ao+dzDz`0n^9;-uv;%pT32QPm3FG%EeJF%e)l66fbrtZ=MB1>$bO?QJh!dQQ6^M-12YGh=jf16Y|z;>k(3mDHw zGbS&IBid;d^52DDGu%H@d!qGvh%@+mWIRbpvyV_9(W2>j!lN`+HR^bpM#rq*9?db_ zcR)bu*nCLL<3R~UMcbdsTb`Z{8ha;JXV4*L84Y-aOh%p6UOS6iE<1y@HNYWjczPeN z%)%m~Q{a}U?~k(>tWj|RD0dQLCI@6v>(yRgc7`)8&aSRRywB&>1=-FziT z(Rms8jmKjoA>;}gFq;$Ek4;{FzZVJqe*C_D8$g{{Y`WGe?KFMj;hd0bDK;${oYNs*F z%lcCW*Tn8({8HII%k%Bl3%N8JyZLsb?SaVcqSb77S-?Ar*GVT5TR;&%?k*7a;s^A4 z-FmB<`%G`?OQkv;thXQftR}iVf=5+Sik`H*H|}_`=Xr}k_>=UHCt&N}9vn^Pu3omC zX{4Mfe(OX=HmX9Rl1HaBYyP8)ZJYsU_fW3HrC`sU4PW*xS{!{1{thT%m2c>zkx8+U z)=IfNQ_qAD^?5X*L+&SM%@y zVQVl*tN?vpqhlk${I2YeADeW#ih;ORA%i~D+m*ojP3~%&flrxZ9AUjzeSJ(`xW@<9 z0x2KNx>MJ3*_`GlU-5lC)vk}0+1}iL`w~D^R3-U34KOmpfs%YXvFwZ+eqR%a^0-}$ zTWvXTOO!seIbh;s&?+oFd*N4X6`{p1(C`9)S#<%&&Ci^x8d zAdLqoCy*I1F6;|zYfnbpy*SbVR)g=!j%1Vhuz=U6<4>Q0ZqFGUj*vmJL^g{#=V_$x zqGd@hqmvOv-piM7-?QWu3ZXq9+k)BY!fR{g%(-38@b~%D?X=>0^{JrVsOR-1_uINo zZXoT&!tfC7{}zdYzY~Zo;e|NdgF$~vGQQ-gggoiCyVd8f0~A+_gW=t|$xV)!QA^}l zF!6^4@uQ-zX|$>{u0wfF--y{tF5eFj6MfDNk4E27a|8<%#5NoaAYo7_F(~lySsr^@!F=6ni&p9 zyiyC>&$+JRwv=_$Wu0ov8h;r58%PRK!}L8rP>ai87#d~gqxdq54zs%6@i~j1kB^tR zTKmOFkB^K0#Q|#oPj^=$35TG=Qms-R_vo1rZoMHG%Cr6J&b)^%Ti#Ck@#3o#a1G&z z7@7@gLLk%$^t+0>rDhW|p?A>2uaH6}2sI2-0HMTYn}^L*ZW}T_BbVJy1W+W)*$}4a z&**IWiNz%>DK9s8v=Q?S%y9+_PXrVQc=tavv$}wRN49Qlyr%YX_RM89 z1R@mcsi&!ci1EMr1gLwf4@sn$&e(K{E7ex%Olbr~qwRrv~rVsRwg4_EqxI<*?K?;BpynRI(% z)v*|KCKgB z68=H=?p|*AC!)zAmARA>2&>H4*4$2WgmHwLf{-?H5roZ*Y&K(4qei8+LrgZzJNo=r zPIuQ$GiEi*l}&-)+OYQpE96{I$y0`OeB7+D*R+llKJdpwMA#i zarKnu-4|l^B9U?nhjrvP>;uG7c{__#8s&FcHyUM%k&t=m^ql0oI!*aFB+|;^Whmisu#ot!wKtZt21LW zwfK6kP~gbrZKiyfO380{1ijB!+K!gl1M8j-_k13V7PWHF-*nymp3B*#`H|Ci8H1P; zu0u;t>d1nb)bl=O=5|_(L0>aKF^~P(S7}>o)G0zma=;5!XB9KNSw^MJ#PibeGS|Wux8Sg_*ra&Sa zn*GRz)wcG!xYUscVI9-?$?u3%GzRXJQk4wC(I5JzlJ`{ZBhVJa6o=S{W1Om@oyuVp zo=P~XCJ%xT)yc`Anq)@d)m;{#poeU92!XgMVleMGS;r}v-eEf?l-g&GAK+GOZEexd zt}!5l9Fmd!NUC$&jgl>6%S~%|E|V-?gsIGd)f!bqwgNH)$IBgTsJ5=Q&?#oStVJ3_ z6}BfR=x3sJNV}ukp5J?)Z1#ACzwD(!TckB|x;$Kk6?4^Z*hw;V9P43QkD#o8B{rho zli7fWsCays#5$qaQPB+Tqw{4nz--rkKscH4Iz19Lta#?zPx(1AbEifL2|vtPABsU= z$xdl3&amBAYbCG@X^)}rzfpea_4NO-MgACN%6t;z)7$7!3OYkl9?zJsYeoJv7++}m zm9k=@aoSYF9>W|82OTF8BU%8WZa@h$yt2uDv09(IdN(T09o6Elv#F7Tr!;5OKrthJ z^fMTwg9D&Qw?!s87^eN=ciJBc1`&zaL$b51(W@;P5w=$HCCd9Z65%_m{hl;9R^J*k z6*IE;9?_vK;=q46S#OUW)zwoYz`0&%(7D>^@{qT&v&cW&WK00L`CTAxG}`-yQZYUY z^MDSrHkVSzusM`CScj$aW#p9viovwPSB|Wc{+SDEn8n$i!vd6GTAVKyaFKqW2(~D8 zI3zS{dwnXy#3eU(LSMZ>&X2odKz(A{CnT{Ka;aw2N$HB$?t8dYYs@myVDy3!`Aaw? zvgtk8iGaHFn9uW@zcb2+VfHd2yP^r2Sh z3O%gp4Isj;S#0s|D2YVbCZ`HN)zdVN425?#;`d?JD!I;JNO!A_yIB>C1cY{{AQ_!jeC6=9H(@cTaP!oj<$sS z-H^VRu;{nr3ZoHoODp0#GIueW^xaJnKR!P%vv1crS^{{wQojtl3E@)zC?e~>xDS{8U9UE=lI9Fo$-l^(#nZvW!s`imM zGM^09)IWFBV{((z`PLZ+Us__qEZkPFs>XabnMz&Z3l!>006k7W%8 zHjkYO@@4Ne4rc81Ghw6W(n<^5uC2h}wP5zqA%tAK1TaMvUHU7Bw(;UuYVofHHMNzk z7aI)^xE*3gQC+z2CSRq;BD!y^H2KnC!pCN|Q)Q^H<~Clu6l!x<8?aE*g+ z&>CeAxj*UYpeNzBR!nv_YBD%zb2xmeWJS#}W-Z)I=-I-n`Rg2Gf{Tc8-8(g7R!sMh zmXt7_2mmpc)Pn9@sdcmAinXHEI>;C)wUsG5?S{ru&zcYC-cd@p$`NqY2~mB`#!hlv-MjJ}&u&mHcO;@oOV7r< zgC~~H(G5T>lQW*<;<7XKGCMVn;~BWQtTWJJ)Co<54^&l<+D;!$PDNL2Y4b$}$@}{n zr<{@a=!8#@(BroqZ|1Bfm^5o+A`^A#K)YooL`m^(_T6x%PyL#$i6pTa$ExR<(+WFY6Uf;Upk5SBMM z>cR8d_+-@6=OGmfz5Ugb56gr3mI>g|>ehchS1pSueG!>oxOQ$R78t;AqGmsuwl_Jp{p z=qg7x=JK`JB$5efFT)iVP;MbI<$-j16h=3MNFt7;lznuEmswFFVFe_PU|?Z07ENw% z<1oCOot?+s?Eor7%b9K5LwhANGtjsIA$kSuO2V56QhqP&IAXZe;=w397X4QLtNN5e z@ncP%dQQ&LHzBe_=kQ`^0(cayh)m!3y$-2x?kUs}s>(%SiQ2Ga!z#H3zYU19Gvl%t zmw;rNo_+(B39-Jx3wu0g92&++n2Pa_6s#y67u#eHnQRy;dDV-TpH>t7 z0SF`Dfm7u%k70p9O1(#0gXZ*j&B(*Es9ebZv2_TG@4~8>y&oqVolFn{^bbmr*JWp~ z7)nS}6Hzc8Qaxjr5lmP02MQpgHhV2i5zkYKB`lLZPq5qSq|JUYN(3K%S9-Zp4`<*g zufyeRzh%m`p~di- z5=)AJMUv#Bshnb3+vKmB5OwYAr)LPIPg}Y@bB*xK=gQ ze8B6SVIbt~3D{?Z7`QsDG!_@W3|^fp`%c1M6f_w11-DR%^YsA`vItKt^BCH@ao^xTVuLA6l%5^MGlXksiQ_0DC+sR^&%f;d1Y;8+Rix(f0AnHncoA(JnKQu2!Y=k=HS;aHkR9z)Hu@5-k1b7TotGq|`4>5q4jxDjI- zn^m+UiZf&9#OxdbJH+h<5~X+HQSc*L>IGsH&NdMxYKYn2MME`{?65=`aqF+O4+J%f z;RU~pZ51AIkLV7P>=!!{(0MJn1s(or`7^6QYpYZ%HCY;vr)kyeYvD3|?xUZ*y&R$y zB9R;pCSLHxd0m5vBa_@b>fb0|7FW>YIUKeRcChSJq8H7L2E6J%~{G2;YX`@O&+|Lr400AM82Y6M~*`!;c?!GkvyKzsl}WI0d&yuiBQmh zh{c}m4|(FadjnOn$fIUlV|{?TKxfSarkLw2c9|8b*X%&d^@#YsC{{EgXfHut&>T1 z%aVJYVyj=Ac%2IF)>8&`TfKV_%Poy?VBLS#R-5?gd**+@$^h7chUbE|pA5PGY*?o# z?)4HiV2%6jGxwElURlI@gdi%)EPRb9V(BFE?@@QY3vaEtjPHH0?6{IbjD8pOhLFLe zbetP5a(t2a^!IlG^p@W6k#NS9=h25U7GpDOKC6D?@0Y8t#kHZKJ!DEjDP}yBCIp8z zWzwi>oED``Ig-*xaa-w0zFNMdaJ#6`^YZfYxlrF`P#m@O~JBMUu8T)f>sfk3gpPO!!GyS?YL7z3fS9;wT-A+g<^&gEJA>e4T6X zI`)@fR&)>S;vroP1v$fnF#`t%fkbMAB_^|S4j~fi5@+q!`F<$O@aJ8k+$D5l-oRWZ zM2o@=rRJ=?MRgN?S&oN18~t-<_ir!F(Tq2ut@AOL(DZgQK;|8z34>LXY8G%y%Pt|= zDqBGF?<&tfvUoypRHkD7aitsrw0UIwy^_Dz{nxF4fLAg^`ZCDyd#7le$Nx%ffpBS{ zCiSH2qVH}8QEudk9-rV!umHv6Q-ClUOnSWUR+>h)wx}2^2y?qn_PNckPl0SSs+hG} z(C>U~YYTm3Fd9Q9`2k+LH2XCOUd?K(wJv}t5$yYfYp8brKWg{C@sFfHsOvtpfS*X? z6?!3_(j$WpTmrlISnUdF*Nqa~H9&{%==GN0X>CE|Y)>wWd9|%Z2k4JK_AN^2Py;S< zwZp|_cSMPc^!u%YJV+!kYSvahd7Y95sDpgIzz5IBVPM&e1zlwE>V$!)_&ad)XLF@; zIE8{ij?T^;o?|I|K=M*&|GZZ|?^hE2zsWd1qzDCSVvl!a1~s)yU1?_M+$ zTLjU5?~xZXc$_v`t*00Xmc4)_mK+WV2?2Z44(whnFZ;|5#R56N$oC%k4zM$FtJ`bB ztjVHUvnpeM#I(a{ofRl@2<7?$ZZ1E+RY{yAbVeTiKNIB7hnObX$<;isB9b|ltJ$tK zL-jiLVWBG{PxCpcm7Yi0)dhh`X?Qub88C}ENeWqzP*ijV^-_A{-p*6NWPSw>5Nu1% zWj7nB)~a5rHX@$YRaPi)^}0Q`qgBYLHL6HD1?eBX<{_+05CQ|hZfc=%a#p55wk{|f zbO-4%e&L6?0KST$3!p}j70AiS0aRNFV60DNaMv$S1ORK8L+6!CB^4jDG{mIs4D|Ca zzxwOcShIL0iU$Y(NPnoDFXT@LqV9{s<&M@?$!)+CbUg6|xoc2A9MF(4=v8Mp!a-Zv zyvhH#T9b`ArkbRc-{l^qERj(+`&Y{P|0Zi`V7A9)v^S`lvOoVIz}q^%F`T2~K)7|# zr|i`i5IR<6rge0y_JaAzozPobX(@kYq^Wxq{(YmzMg_QJls&eP;in*1K&z5kVw+mx z%BoTB2sEpfEMvyvdQ5uNmP@sIY{tDSFZ?^g;E~rf+j6))MlvU|x4XQ7roTo<;`EmK zirlEj^ZWG|SY(}A-KK@tP5D|~ckpL#3LeSuTpunT5k0agY7>t{rj#S{%p$)p{f{XUo z*SD6_`Fe(aU*9YBwAV$xShV z*U>KNXxAI=v!ye{JWv1$BS0~AUmeWOE#cz3FJ;Nn*V^PlJ6qsZS)1JN7Pztv@$Ha}L!#5fb9edh~Ogq6D9OS0oDZ33Awm~39{tko?tj3+HC6Y+_2tn8H zj*h~`zjLwD^gJbfXEZsv*N7pgK~eFU zU}Q(f6)=IA1PJ7^*1lk8k1fkXbjlhegRgCldNt~03T=)=xH5F;SX)4TbsO+XZb%iP zrA+S(cA_eS-3s!bm@bh+$gV!l%RFi+I)OCeiGocSW{QP8ZMB(;JHts3@)})AdeKSd zsvP$edMuf@efK1!1QyXsIR`zlDaYm9wT>BrfUv~l~x6%OYD24N& zj3(Ht2J~>J%W8t6F=qQvduGaH#Bo{MbNj^_%1vSHy+K429&*9X1wWTT5v}IqOG^zV7+7dkQV{9>4@>RT(1v*y!lSc)&&Y z#a!2qFZg>{-6w-;?W&DdIg1z#pjVU6{3Ed3k2?>fR2J*;TA$FO)4Qd7AQKAE zeAWH`7(45zs@Apbs~{nbfTWZt-5}kigdpAB-CfcR(p}Qs-5}lF-5}ldJ)V8eKJVV| zyT|y(@Q)>94Hk2*Ip_1-_jO&r+hzFeVj{D#T$%=N6cQ$_#baZg!5I)OEj)0hB#ME- zcz=qDS+;=Ddp7kZ?5Kb`)ovqUw0@LRq-hZ;s57||MCL3SmLxs zwJ@P%be5VR7OfP2Oq9!<;kTIU#&X_t~*rGL%SsT)I{?TjKRWm5KH zjA1raicVw^2USi!E_s~ZGz{tZba$e;oH;&cWVq z-x1r?LBKz}p_K*TofwAh+NJ*KxhsGNY(-Mt37r4*2FwrO4L=>#S;+tIgD3FJB?i8u zJ%x%7|L}(A>+gW?h~)@QSnwYX716|D2pb@yNFT^E)81~mQ1=iyKL}?k{{~4tP zhOyOROZY!_a!{x%|5w=j&+ma?3Kf{xjLoS&{0)rt=gv@r0GvXoZy7lM;Bw4ypTG2PFQCAg(jy?P|EFuM zQwbcV)?v|R=RbW0_)bCwcqDE9LM!_p`%M2TaF|_l%KZWV^ck2U1QXb^V3CA3VH2nU zv1m}SJOW9b(8;%(ke1fsVo!FuL={e!__sTSia#INhmLw9yL}zT{Ql|7VS&ZWkGQ$J zqp`Et8EtZ2{r0j?F&Ya<-5?|Cet(UUqG&o-TV~yjcz1P#^o5vk%14OVd24_|zQlVc z&xXbB9LC6p6b4+xRi83_tErSp_s9M*KdcCeib=m9`i2G(k8NW!=nW8qeofT%0|N`n zlkLt*8$=4U-at|Wvnd-tw&(C1lQGB^jnt@fuc`yvf;*cXC2(JOPKJz7f#-CGP_Rfm zQ5H&Q2tU#PIEMtDm|s>dO(Pi%#Rdj91&%qJz z$KeD%1ZiXr5$=yivlgdkI1@cLcT2OYGjt=8g@uZiwl=Z<%E$3j-F^%z6gE)+|xs=d?0YVw!Mk5=tP&xw|ik(R#xSbf!}0y$p0 zV-#fMk4JEA zuxHD&LBMP*c(3!-?z-)~@pkB8kx~t{R0gCBgZYs#*d~36CZ7cR&Fv#~z~`|c!~+$XZawHR22aak`|GFF< zr2!&e?qVboMQVxW(Z22J=IXQ_iNaJ)n)XM| z0$}RA3nKs+jIBhsS*gm%7_nG5ECrgA^NGtD1A7)USh1N5Xn_?FxV2V-uvb-T4Msu9 z&DYB}R`%)j&&=cVyhsZ#%kh2YW^b(f?beXQ8Z>GZOcLbupCNeGzmBXksMY4H%(-=T zXw%bRw?9SZ3?wi?BqSdzmiD9zg(#}9lk(05MjQ%x`9LP-yr+zKES^L(rA+Y=5g_d zv3%roi8u#`nA=lw@KFU2RGw2}jX}G%1)JAa==M{S-rU}r*;qHYZrjZ<8XAGZ9%vm> z)M_WO-1L-B*C$P2FLv$k8AheNxOgJoSY_)IQ(bK^)ob_(@CXMP5{`hQEmaaE=MtzF zpDOx(H*yREh!f^ON-bpnBgm$E(R4v0VQJ~<`SQ7B(=stt6>A(ecQqMS=!uAWy-WvO zw4;fq|f4l30J$Yb^PeG9wBo#xhsAOyq4HLkp5|`9_?`SQZ{dv>zcqETO z|4=xr+468Mcz4{g3Xn9EYfQgH(7((`({wOdCMVykUt@QIN)npPQm3=~_5M{Vn{FR?D`b<4r%NM}p1+plKK{}kuCCG3?7jER5N!!kT{a~OEiZP+qY2dtwk_9%u zfRajJ7DO7|+X#4#H(zsg<&NZopl*J&fOUSNv)tU|E}p_+zO(>rRiL$i<6Bt`_pwPoYqhzU@h`(F>G|;;%X*6n9CXmUb0dV-PQY>VrpuOk-0^8 z?Cn4BL>?%}FucSa@^qLwQznX4h)jU8NeYtub*FovszNzT0y-dQ?f|zc@~XUoXWV;n z>@qGC_1)H1Y%yK*nQI?!5#TV^pD31wE?`1&VjdfMn}7{TnKdDs11iW1oFC$e)t2k; zLlPNzdEzk%r?`t1E2?Gk#&)Iwc1R#x(=;>x%_P|#5eLp^jBo8}YU$E0+tS%$SkX8V zAI;`efGHHj(1{A`UH#HXOYe~08q^1TcYC;#2i`OH#}|vv=RB}btPaQFAaelh4_VH` zDWtsN%xYyKuS-DW{N+#3j>(l_nvC14-W~vT|JK7yg6YCdyTB>8#$JQf9(+2DR~0X67l^6kC{0H$z~I7b>?r z@3%argZ6Q%k39VMj?&-m1z}+oDAGko*^+W#0uM*U5p%?qDJ_@80*siHRb4bvEEKz-ZY6vT!W1FrvvE)<(AD+KE@qy!oW9D|bCrz%x21o^7N8@+TnO0h9h7xPcoVcBA)^BZVp`jidmZU3; zX3OO=kg>)!2y7&v4E2X_qcjJXm+?zB`;{Bm*zFG&^30ze0oxFjcP@kI@8zvnVUZUB z8krVM;N8`)mv>OC+OANHg`5rcq7lXiaih9LeMFJ;#UQP%su4vlgxo=pB4Le$$+#0nuTYz*!^=(u@ zBf*A;6vO0cB|0Djxy4~)a$*K@4w7(~_xZ5G(sC*916}4piCRMppvAswai{vEU*tP% zjUS~C^Qncu77H~HVQ1O=so6E}YfiCjPUzH>4q^L(H`Q8ux$c8ITFOitPcYUE3I?^6 zNF3=XDSLwvv#d<|N;%WNktBbvorI{!?%u?r<;RWoPjA;6e;`iQiOO%-o}8a|%A&2I zHv4irT>IaD;CTJ+{-ZAe?~CL%NUI>RPUtiy`ztB~#rV-{af@bbmEV~5(yg_%z@F`PU)O2xi!cL7^k{LzIgk!(wz{D& z4>+7_f~NRXAX`wp`??y{WwFw=TjHQAlFJo}RM*KW$*0J5>aV*&7QQs4i?o)poJS@Q z5*io9#XDN8n@{Kez-nKZC%}ewfC;tu$_J3bgtB7+oR!cR(<*Rr3gtCH0UU&~Q9JNg z=NdrH#-At}&Z)cezTjVT-#u6*`Boqxeoq87)Eno%fI-oi(UMS>?dHgCAGA(i=mq# zhqiRuT_eG?dbzp8*-%vv5~?wQxIMQhq-n=5pK+Vc#VSt~OP+ z-;{mht(9pgX~_R z_26$!Df~69vWDh4WjeQ#YSqVLYG@o(MK%K|<^~1wx0sJ@2o27fG|szWmABVl@t2i@ zc4(u_=P@c9vRF#*VrD${?b}+OQrS#z{Uhw?1%uTsGFF`~o>CdH+y<2^aXIx4!2bCM zPW{w@#3cQ{J4zYJpg6$tok`d3&gcpRp#V5mvMzrVyr~L#a}m8B*}NqFr{@q&jbFyQ00L^};i1%v5HS}DFPl3SpP|i4o81Lg>okQ`{LjZE!}*iO zg?-VlC!>d2M87ppqAVY-uoH2&*!x=x!M^BwFP=hgW+JH*!y!O(Due9>g&?%`ZI(g* zEw>Yz&&Y1M4q~8F$I#$dj?iENh_nd!<34JFAp)~G@-&RJCg{R{Kd5b;Z^^uGOZcA!EY-*F=d|~ij+To5XW4u z1{SD#nTwY`5`i|SUuWtgCk_ATXA3YtmyWj@3r{&_f~=-xl|9-%q885rYEUKnA73e z_Y^lR#m`b0%9|vF_wfi5=jMgo|3r66TiHtf=fn2*3uIl$s8l>}Ds@eQ?EnX#lS3Q~ zQI{^FFj5?4H2aOUH4fK38SmiPFzjeX!>^<4U!yPf$DP-=sC?kwgsz>Gvj3}^^gMnI zlPr{-!<{9R-5nD9x8?#s)u#Igw29ing8*YESqOY&S2`9Y2LBPI-yeXaXD^_Ns@{?P zr7-3=U0WggGm3%kE1_K^Ko2l&s!;*Xzr2l03pRSA490Z;cVvH0(K zq#d^W3h{sGD+EVB!;tAqhgEL=SydJD9paB#!&8BupnXb-Xw#*0t0IiapY=a*{Xd(K z|9L_Dp|vVT$jys|YJ3f4pcHTLF<95N(CCz922GwOIYepf6Ft z1}rRQJgv@Xw>LM7FkHVAXj_9g(aO0JplU6dmC9A3wbBQ=@Y-z^i;7e0Qp+`>++hC7s4yte~dJ_T|`CXy{no1(EYu|&I!S~upl_AQnV zPBXsPDfRVEE?CzOrxjuK#XrBDMngYr9XL=tb$!DU{D}q*9!j?QZy`x!I>_wD3;9SP zgym+0*bFo}e!4Vt8k&-iC6oMk^bR8z`yVCm)_mZKl&Ucq^khJfgj~K*vOzBdz}PsT`Q=-ma^;A`aeYYI!P3{D?5!!vz9c ztE+_B$G{xE|FIYv3MyX@EoB_|=MHDeT!Dz^4(zuncIVTaRvZ2$B_+qO-0r%@_P>@K zZ%(rwm!jpPkAg;~WUDj#m)zOYJo}|p<+XVo^SHmu^rUvq(f3XTd1}{mZ>7FntyBC5 zvg@;NJsej@{n6|-+it@m2k&1cQma(%RGYGg;&R$Ou4Ppp&Ry+Jf((yz{^N9!b(7Ua zV19Vcs6^`@xj0lX3}8hBn6Bycq8>mEv|_HOSFIBu4Fp~ILSz4Vpz7t%%1}tA@qpqN z;RGo$tZC670NDMCiuI^;jV~`V^JPnlcQXqFZkl0CRaH%Y=AEw9eOK6IQno&D4a+AB z(*J~(vt@RzgW&3nvqZ%C7x@5mED88v4whSnvz0F9s*FoisxpA38)V)x6bqXd8V%RS z^@mTl2IpIzJOlzzAHtd{Jc8&Q*I6}@apKnFfjnu<&?io?v_b3jgg-wqn6EZ>K0nq3`8;;Jy(CbO7zTT{JT-ys zMKwsL1NT4z|J2;}Fix!oz4cUKPfriH-9Y?}&^(0$6n8TFNbM&HOy*{~S*Tu!_+0mU zvH}mc2#m_!A2D3gR%D~578-36t6y*}6oNPaf_uSQSK$8ljEsz>XAxIeM;wl4=LfTJ zdLYMeG+&|~L#a5j2K3%)lO+z9PMA8GN(qvQ7=X+Jv7}E^k>HaRBof)se}g(3U*|AWoSnW%}wB22XVi_IIaiB z>oZUoV^DoP-+m4u$#H7C0Xmp8>UF{M`F6={1~21%DH{QGF@Mrm5zxJC0J~8N!U;&& zZVr30z`rt?&*bE`(GvmMhAT!Lz9E!URFl7glG!jnx_jj^Rkm{6V~^Lbw__fQe#FzvTk=sAX3=?FH{{;%PIfSfCUUo7=jw3k?yBg! zZd<^L9?5tP1Ctx(Bb2%9KD9S1h8TOPlALJJ7w_1^Hyv-in*tnOHy!SsVc!fs6{fBq zb$qeA<}z1W$S|Lz#2Wu0fE2dm#JYVwjx)R@(-Yyuhu7~CFS~EC_Lx@AI>=Gd0$S8v z3tM$&909luG&@2fQT)x7k$cM7Ob5fTuWyt$Mfx{BifU^u*ej-)qEr+!F;H{< z2z=b)k8Bn8PneGTUwL=#z-KyWF4cb2E0W`@zRSNezWyl$D{!N+Gr@VT4VQUev%TZ> z5V=(Cf6bJEgl1?JF3_oME(?(k*elIrx!s?WO22>qKGz701DKi2RlYJqW+B)l^$Q5; z0D3O8c>ZVf`A63~KEL)!oR7s}Z<$UfcBRF!Kh7TQ1{CFm2jFF6PZZ!_y!9PkU0Tu) z#pBE`{4i9>KbWvRoZKPoWOy_Fg`|ADM=Do?H?fZynTs}@xkx!a=M%*0%CAM|!31Wx zpZmO8`(t(103=BeZit9wYjk*~5AV_!#f|iqLjgb@CRi<2o{>_505N#tlPrAG85Vpo z>O)94EHF1b{3b@kxVrW?g{nV>SCm}khlrn@sTBHFnR-jJaOhjZrN)<0G=#KyvZ1l# zIpU7E=e3}l?$$gl6o&mW{&b>t3e|WxX1LD+nC+xba>hwA}xq7(+n5J-M#LC9$j4_rY|3&Ba z=Js-Tv4FQjNIa3*i~K`9VG2mJCx9=j(Xpsh!UK-u=~`RmWR07Zjg?pW&bHG+aeKK! zZ&s!l9$idPrKqOX=5j&NB9U@QNpN3;G@D#6pVQA^!_lKmWA@3PrnVq4 zvVmE9%xK&}$>ER|2n=MF>pkki8Z3j#EZQUl0L*webjS{P(Y0VPB@#Yt4%f9^BuL{l z+9&~xN*bj?gXy8qp=~#f)GjZDy`}sv` zPkbMo*xx-Ntw`@Iz$JEF!4vD*kFLVU!LcA3P44qZsaAYJf0k}e0oHh*7`FvJUq<}hZXJ_Twstt}q_@K`@CX_lI^q~Vw4oa&mfyjy3l~V1`1@BFW@9_$}xQRY-jRoYUzAOP+TJRGLmz}!`zu= z6_xNW=Y@QMdRf!6_Ilk}_^lT8c?T{uYCQ) zli$Fe1Rs-PaX7%7(|h-XV6jWga+|WkxUe@ntnz94sELDv8yH0$egUIc&Bs-nv?d#xvUn8mY9v zqnk;`Qs*HlMvWbSb)!*Y(Mf9s=~n@+yTyvf0lD2*`JaJ(F1mb0UEN`_Tr^G2m*LFe zORq2}A$wt8#P<|RmSt?EJk-im=uv4tGgEkL;cAtC$Y?iUetgm3{=^%a#47p|Ca+vi zRXR`c3mDub*;gG{L>lBU-2aBA+3Di59LZ}yD-e>8eu4Flwlj4oi4LNPo{niSo*|Y| zPV^bUD*%Iv1sGYF2b2uLG0adHzkwuHxUt^`M3&8+oehQYblXH`*W1f+nLdF{Ufz7p zi=rZKktRMAICywe&kPGcG`-W=BWZgqV?jPn^+&Af`)A z=0QEjqfTo+mn1Whu+5&p@7(nAQywp z({+)jk(zAnl0X+Qm+OYwSV=ZkOSSm<*I!mGQ(W_n4_RbRvd6cD3?WpsDyxpvhx4}L>g3DY@Byf>44|5?Q zvZ;b6J+a(RU2ty%vER~NcP`~hX`j#!>DN9{s<5;e1{Va?pFl685&Lt|`H@edK{d;l zOqG@pjx9Q0b}q^KLmy1!F<4b1V%;RTJ_dX059Hpegb1$^k+*eiFF4joz0umyDt2-B zrvJJV@1*@{i8CnfaAQ)*TMgE0P`O4^npP7w9*G`n0jdo-!mFpz>c%R8aceFA6pMjG zjmzmAX74Kip-@te@$>PK=BAdY=Y%HvXpsi(Bzzup5{S@G$A2NX`a!nX#jy%mP*dA1 z1w+-x<$Z~hvonoiW6xP@+d`dniAo(RPGm2l#;}`i?3khgY5!8#C@zW&ob#rGAU1DX zWRBS3Vh)X+RgN-`f0azdC^P?J_UHXF`z6z?|Yi^|e zPh55z{3$6o0++P9eP3dBy64tD%MO{!m59vIN#NTk$8iTaOuL)|WYh4>KP5ey0t$rfrsL5wThh!sb|8-j6qlD*k$mM5QN`oH6c>h_pCt|oUg zm&}g)*QhnYW@61l?^P=QfTg;9e_7Tc9>zpowBLTiQ)u(NPhDts41&&}!Ns`ByLe`!F; zry<~|q|fa@zrnh}R*q|BVaxFxGfIk_cUt%g$;L0o}GIvYMIM_>@fY_Z8WR%vi;v)A2>H!`uK{z2k8?n)u#qz35L$wkacq~@EjZ$qgk`?hc`ZiUhE}s)hZXzCxRq zP^}lS6i5hw3?<-E(FZf?aG1#;KVwh5K!1*5i*Qx%)%>I;CzP&F;JT|5?EORPFo)#X6EpjWe@WaaJb+ThT@im zh+|Db<*f`vp zFYFYajBjnup-;08szn&h7938adYPxtd8;(nfBqzsF~drrvimi55vvZxJ|hqP>JA(6 z^71lnzij;xlU3__qBoMRC=DC^g0NYP*;7isybJ^as47J!xa}WbSi;_75BYa0hOgpNFR5&-z)Ru|U|Yj} zN#&qzIWFe2DeXyqoEFNI zIrguWfCr59Y~(0GGK2A%dpBSpJJxz#AW_8J@#gI-qbXla(CX11FwZP)z=bcpTZ${w zUL~HbjWD{SUC4L9z|J^r4(A+o@`4dWs+5xBfDCkhd~8rC%h0lKnW0F%$3Hza{be{U zIF%Zz=w6&)J9twX2zNw~qhmL|NoDnBKx?i@#rO2goH$2%_PqOEJ)k)+uyZZ+K|T7z zTO)Ja&k&m=@!Vy_S?&gC)rZ2C2P)M?swwS?vMIiK!Q4y3U6g}RvO^O#O?~S~+jx5b)Z~qHvy%uI%$RVuHzyEvQ(8hopsiIY}bgms{$SLM1`HZ|oQ{D9ByYBJW7@JzvBY|5rfPsNzj-POJA@n34`r`h-m}`_8 z-m^wUpY^nUT0YYSql9#yq;QBZHbpgI^+^XZmEKcp9w_+rsUGVL_CY$m6n!>2@ikhH z2*GAYHi`4iPv0vHdkiyVEY*TpYAbjZhtdgI9Nc{|F)8Irm|X`Xd4S3U#6 z*@Pcq20Wy!sdYG&UZ;@9 z1qbhvjm^3KOHyk7<1cruwGYmhLErqVOt@(@wtF}dE@*{OuOYJrbie&YmH~S!RQl&G zF){c#g4|5jjDi#bS!B>YUsjCp+oR<#D40Hr4k8`)6{JS|>~_#izP-yURJZQwE1)lG zX=i~B{goFH#T4YNhoGeFy_X}0jOweW$dnj2#n3P2PXj(zP?XG%I6(9*Yawj?)q$f(ATGnojj0m(Q$c5FD=h!B9^_MuhhKSB-=1Lv4=u~ zpyEI~C7TG77#&S9-7)})P;{dNNc1GHX(J6gUSpx}#gS%B^uw2YXpR|KS&LfznnOR7 zBB7cuYOZ&Glm%lQQg+K3Or9)H{IPlQ^WNn|&-Yt+YnQ)>ap4v)w1Fh613r%_J1456 z>c@9#KH4ZmxF~CEhGVJqNppKj|pF8(SFT&df z3`6`FuSuyt+wGxJG+%AjRdRVsr!PTK;ud6+q>wbwi6@=w1LI{?^&R>L z5#tAS&hhc_v(ZdHKs1hI0!^r%l&5fkB+<2g=ZovEdB1e2%`+GHrkhi?*D?qch~BWep&tVB`Zabc#SBw4Tg?ezk*i&;%s6JLnX0#-#{*}lLrr5Gp7_&~q zeKe=$Ix<`#M?|n9HBi=VDf6sXC}q7sFVKEiG?s=? zM45{Fq#2BEQ92&isCr!Y$2CYWieLV<=?%M1A(KmMvqMW7XMeQtV26P9l_6sUOd!cO zB;)OsAeY~jSHT3$2oCGpN+oEWOe4@>1a#!ZMSGq(3Ypr{f%`pJp4I@7j5(HubCM$C zCfSP?^p#rDq>~>7qa{qdeV?W$r{sH~m*#8CC<<Gw-9qQQ3$G%X^h^MzX zpA^k(N(^C}pmjozXl)Spr1boP+#viSz@KNKEM}z3Wync;yB#zPX=#&;=Nsg|bV_s> zicGUu9{0^G3w<=6f*&gG3Vf5wZr)7Yf0Pt9vIzjt?%P5w?$(*Y5A`lxeSqoh;+I{v zy9?|$z{l)sa0zr6S3iuFQrLq*G9$_2G4m!`oy>YR_tOpcDx;~}-JDb#ESKnepx&WV z$Q9be)8|qRQM*QCL`S>yDz{B15N!USxVyv~Q#XcLPA^zahu!>ok2pv&{JsyJ)~i5x zS=X}(cZ`M;Ero;7VglTxu>P&z0$_6)4n8-+EVSrulr81jmN=ki(EZK>y!-ls42;1p z$=L_q4qQ4DOq<``o>vS4WLpCf)(dfTppbUGK5pBSrJx$iQLoi-3tpdbPI7VC9>PoV zfFq-Nw=oM?k$}KtLN1^8ZOvj$p~mbGaQL4%9iF8a0Il8Ae`|Bo_7Q-Srb}p4YM%+S zY!9pR9IV;{F$wUe+TLcJZV#JlX<30_Q|X8#v|oY|9+%YyZK$c;eDgzBKk%`~EYyud z@bQdh2wF<`0pY{|JQu8i!*M8q31`hdTO$5zz6=>I!8asB3)1;!*TjMX3cE-CXJnxj z56jshpkx1S%Bx2b*R|ZHcP*MiRkG3HWUcQI$#ycBK{19}_4IOZ9<)quuN~GI)$Nh4 zYRs3?82V-$l0;9h3KhX5ZlXsUK;F;J&I3+eyZxy_N9%2qh0@jAkbNQ-)O)FbeNb%( z)pSX{2ja9ct^4B>vuQgl8H@*DvnY&ebwR%NJ`hi2o9v!vXQV`v@?&OZws3-mj79H9 zIl;z6gFzQM<3JOe`rZ{Z(s!}%hEAd6^%E%17@*E!n;mmFs1OLz9I%|-6+ZjD@e>^$6KtMwBd;V>gHaeVdBpw75h7#HB&z>Hy?z@6O z;kX)1Y+wiDH5gCvLBh?m4J24gR2u?cdW|=f@qLZ|al3t_sGv78H!=w>V?1`JccN2; zMxRsPJIeTEm&wq&=6p$Jq!T~1y;m4kYluRX~+iqahu zbEW2h+wpO-V3rO@+|HNRV0_5pFj7PO?e%p}?^u%AIo8LX0)Kz-C?$eHi+BwlA>Hjf z%?=*bd3ga@S)cuYW1AJe6KkVFZ?N;w`tFiyA)h9tMWNJ!S3|=iIvO2~hz{~Z&Vx?N zw9rP;5|Go+F?Ovthnfsl+m)(t=`8H+uYhI-5UU@n$tIt)ZUWe^uFJG7bQgD5JtwI# z5=+sT`5<7bX{do{9OZnp7dY#*?*R_fCy4kA^lW66?4u(T4!9%gZ25=;9U@m;J6% zRnL!ag{MfPewFZl5juc%xxF{dOVZP$>n6l&y0FR>Zr$kk@@3IgkxL%CjOL*8ftN+V zaOq^lwV7t`Nyl>ilg`s&SoT}DwKtfGMH))uQTRx&T2PRIT6fBOjB^t?OQcJ_j)vT0 z5)@}!6mQv;Uiy}ap{I=DU*pV{L!T|RO0lM#w3?IN|CD-&5qQ=&q28>TS$LE9x<|b; zS836F=@rgzzl$d%&q^fsCes~6TR%~gVBT0(bZjAoY`duc3(2+^N;Qs~;ZNvA6w%bO zPS+#p;gQE!==gdX9!MR?jT*#j)F;NLKxJBwG(DWN1;b}pE!!RBr!Qz5EZ1*$q=>&H zb4~uRjC@E*0qF%)Q%wl6ozK5hs30)V1_3RQy>*ryf+PIMMg)T2Km}Uo&l`J|%xa<0 zk{PMJH3M?*j6(YWh0{$|o8~=K zW4QPVTC2ur^7aqy5o0uO=PWm(1%pBkH$w&JS_Dw52dHI^F;%uClI=2J#sr2f-;{|$ zi<*PjhjeA}WR9CQS6Q*waxbR5c05v3X>=riM0m+Uj*eto=^DiD(0cmQ?rO60`9S!gbDcwzYN3|Qh%_od6a4qnr)a{;rv3S_tJ4KqQEgq_Vx4RE z>@1ekYj4qryc^111J%QD$uf`CoPfFxc%vrq3DT}QCnomG|7xn0FJ$@bK26(X2DEuW zW9QJ?f#0|2bUq$^$-&_QR7*^PxLy+1@b66K*Q+fqzBS%;Svb-Yq++X zzRn5L zqqWLajp4UfL$rY7LF9Tc{ZoG2?$_BnJDzM+Xbn+ww9=bVV8hKD*tJatB$ z%;fvYn!{g|t$x*bQEs0lCf&%lh36jlYSb)b)1K-pPFQZt=4oUuUxk^kP$U0i48?Hr zM-CS(bXAg6M~&V3K8{Y%d6n%q*3$LyZj5B{(JY1x%2ahGl`Y!Xh1*Rc)pM!Pp&s zk!hv!Tok=QM9)a!(;EG4v*4l*epp2Zj%sS|5WQz}yt_25M8&;FOZJxjetn?$GWrd**{Rr{{thi$#!>z0tW zB*FSa_o9bW>^IBqYjRze_X>w=75lXRUb6#c18L~X;9alxp|L%qV4Lu^KI zTj1MYixtpk-;P8i1*S!++Jny)anOrdUw=0a+%pyC`uynHv^)iEz9h7+-&*a*$?V$L zo!acczPJl}gs~X#W0A&|ZRSUj*GQeDKX8rPv_~vu}jD&Q0&SMg}6e zE*E~;Pnt7sLf~CHI<^Kv4C`!lHI(Qi!2atl;d^n-ugZ07Pyg0o{3nEa%sRu1rJ0k^ zkfU(qUmiyVT=#mx!7l0({@ej^t*Kru7PF-+|0)$QURWLo`@#quF#D)`6Ol{8_<{9} z_XV#-IP8k<`;SuO($z_Kp@;RIf)1$Yqzb>Yj_qlDK7qp>sq0syY5+<=++GWW!ebUv zm_cxVZRroiJ%7S}EjZ61V*+QNfBs?1No;onMAcPZ>BXhDR78*mz?g9?K`x#DgYsMWPiOlh78VwNT*#~# zpq8pliV@hM$4}992)`iAbwC8#p>jX0bFQKG&(PO~C&42<%%<}Jj4u00dlRt!wtHmi z&EI-xehY&m+PY-AbNknp19?3G6_M@mGZC!;kq+Qm@$2bi5P0Q$hkcs${^r9MnMG3d zM+ksml}$8VMWK<*fJw1Mfq=vsiq7#KcXE>&_2UR*yD;MLM$pU6ihu`Dj zQs&(T_mj6ac9e==0+WwtW@e-T%E*Q|6*b^?;+Vg@mXh{4l|1sY!FhTT~OK=c8n8VnW{UGU)h5BI0uL;0J$fms{Bi4`bbWhuu|V&%C13t^Kk+e3SH#D+<@p{qB~k1sg@1I@-FeaGj(=s!M`-al zr=_Mzr~;SW|H`QYKu&GJ|Coeuxce6QW~)iJKFZ;S`PTKbfCe_!BxRCB+-jW};aSwr z`~hGG%UBZV_$hz_6L8P=Qbse;A*xqXj{c6qT3Y*usO}fe?Wp{sf-f0Dv-+~C%>Q4K zY6f=-O-?=OCBbXL7~?Q++x+xbWt7RoVzwbje{)ASL9UJTc;{Q~EW4xReb{fazY`2Z z@0yYs=}EJ0dkH~jUmK`uZeqgalKWG%$NRG?S+Y-8@_fz7AnMw#1-kA@a2yRs;*Z-A z1a0j}^?L^gXJ>6c%3%oTvXgO8NPlr~3~;H^x3-pTupp1X7mtSeErs1Lj_FbyPsZ53 zq*D5B`FRo={X_(7-sY9I_Nqj1a^2;xGY*#>WUibnX7QqTX$5*`m0KCi%E}F&nWN`^ zl8cvxxLm6vd-QxWTko&InU+mYcCgtQDbwr_0&9xNA386XTYw^$GsOKgfFXYZpGB|! zc>jjRg(U%by?uKUL~KR9!UEz32|AfS@ldrdd)JaNkA07cWB2aqjTPDGiD%flA3@Uc zJDMzD(vl@#j~n(Z{glU(LrF0PDki|mDi@((k0;cI3Rc>4j_{6@wN=4mKU-DWf5m7&W{Awt6@s z2yyj}=>iWW;VEK7dWPY{a6*BqVaYagM3i?^%49)_cB)&FkgQWSyKX+xRc1NWDBF)= z-w+&pPN$7!ccjP1ZU_IKr-!R$YNRkQ?gSPhH;^cTsH?j{Y-6Qbwi(@DjH6#Li+OkK z8LT(qmlS&Rdf3ZqAnV=%7f!T%M9fv%)I9WO43o|u+Chvq{Ak@Z3w=&wiY`&yjN45X z5$&{>toCZ5C?DV7_ekQ%HOw3x0laB_w6lnxZK|%x$VneK!({s$XO|%4Z>? z;jb=Qp1T=P>yhnY7nWi%>hvtkRva2nc(~>3|45*{Xml<>kh>|jGOKVB91FE9kBT2A zTB=&Ux3ErJ7}8~UTYu9&x|z}#HF~h#&K%;Z9d#5wCid=Y7jPhS@cbfvKkewtNJjIr zF-DM@E5Jzcw8L3j?+ocG%~Y)-2gSVU_RV*_GSj|bq=4m*oMmLWbAQ!%e|LYwkzwNE zuvJ6FVDL2Yxd=<0LPw(rQ#X!Z{%g7T@B;s-Z<@`B^ks+pgY7`v=o7%ZrqW!~Jsi0w z>J8o-55$F~2>Y9LkeKjP7{~gl2AjXUeBd<=*CTk=YqXm9EG!rRPPyFQh7L}oL)z|* z3A%}d>k)Yt&7Q!%&e7*?JI_GjnsAuXTxelDBf~9!y36w z$nP7B%Nl6Su>{ZJpgA~!UpFA^90sjRc0<0%XuHmw#vs!ihq*Seyst0f=aSrv2%>n$ zI&9(>Pb6lV?q(@txf0*nfHTOb%kUZI`g&kwe}1X&>T4HPY1!fK;EdX8X z)-%N<5D2W=pMqIu;Wbpv@f6N|3nhtu=u_ndu+1IM6p7XOe-+@ z(JYm75u`kmm~{ia_^EbuWlaiN_bXIxPWhB4Cg~z*?D~{<*S=l7Hpn)z_j@c?n3>^4 zV!!=pv;a9CQnph*{jWV#ae~4;7uS-*zJ+SZ9L{K~@MuO?n!MRU?ei-FyUqD3>kqGD z$fTi;%!$r`wZB1|*_fe}kEz0es-49=emF(FO0756S0?uf0a>;;D~?Y4J?D`Wm^Ko3 zzP$mU!MxUC)<4SqL~@&)wX-cm{HB3=sNP8+wDn?2Y6(GV@peKo*fGLL`q-s$lq2PI-uT-0@>N5}L} z0hzW_Dy9C-s-4uPNA?9>S|Q^t)Rk&2 z=%O+K?6@|nW6Y}6!?Wj$r#p5SnCP_5(gjq~N~2v9`KJ2XVnf&f)7x{bRn2r;WA-)$ z7X=^UxbP)kcsw@O+X*Y(&%_6a4^d7@7A6sTxhglC+{|Qe28$FcvcXIQ7+Y_784!Gl za-C3TpfsMaAPsLJxNH~RA9-O>8dlCS#*Ktot9!NuP#Zih-fB>g*JvNNYDUrfqKl-O zl%~xVQDpu6dpy0Mt`3&)Troe|4fMwq4O$}W_=V!};tT*fztT&~Y*b4V96UC=_N`m- z#N7M-Y>7lqysNVR$KG2;WwnKC!_pwq-5nwglF|(VA`Q|hAl)GFC@E6XQqtYh9RkuI z0@5AQAsz2TcCq(4zrKIpIAb^%fV!R)bIm#L>%M|NF6mV?GG6~cJclCFN)c$PkE0PK zTbhHU#INIW&}LtMjMV|?Rm;jM)6m&DdZuhLJBlosn_FQE!EkC=hudS zPJOV1m1yJLTLe^Jybr>KYKp7OI$f`o<3q80dNoEcWjBktd<4~w2KD$Ejv zZ*g=)(X@H^(MY)JzaZ@AMs#Qmv5`(yjW8ZLbQgr30sel}V=Zi|`t1QjR%=0pNe$sm?-L0{=& z^C#W`Mj1xi$7d3s0v>bn0Ob2n3zI@A=!!j$dR%k_Kv^QO>r7{q~Dn(g7l51@(= z2-tIu9F#oppL0yP?LzLOk95C_%^~<14c;1f3W^rnPcUThFhlo$<-kcBK8xeQSYTj^ zMxwVqp=L2^8ARXwW$Po5I1!Zu!6KOO3*Fu0NG*ec8BU8_6B@~P+R*ue6i1H`z)~@9 zuI4ZCrr$ity>JPNgY%HU^Es=9$t$}z!xgfN)U?_nSZmpVgfa1Y$*dP#L*HXYLm{N5 zY7X*Vr;Zyx{fj&qr4!LXl8t0xc|kt*J{xRv3nr#gh&!X5%*IxFqT>VNU@5oG_2Or` z65OEgPVwoH4LazxFSpm!o6wwL`Ll?Qq_4)bd@mMk`aPRId-ae|P~(y+TKfA;gB`y) zv&YMVr@oEu2sFrhVeA~6-}xg|9`?!$y4ggiH>p7@7|$1{F)=-t*?L;O(p5ENP@G<# zeQq4-o7Z%n5@(+I^1VjS(E+q`Xm7e(5bw=8ze{tYv=rA8q=L-m0lL8jZbwRN9*I&~ z+I!jjk!wZL6dcQ-KE-2??Y1JuacrClO-)UM#pwyBpA$b`PthGls+~|a!hY`1awfIn zGl?pnJ^TC)X>}4_i|At;WHuu<(%xs+chSQTb%KOA1AvxxIf! z=?$~I*$h30d%2}Jgd@Sy^XSk6f0=04)WYL?Xo%;{tlg|mO^(f`l&aFXi}~_NW0ItQ zh}2c`W2{>3$SnE7M_Q}BnS4XYtFmtAu0}TKzDsR88k-pxI0y;E`Dv0d#AZoGz*^}< zdM>g!UC5LuX86FV$^d7O;U%U|Kww?n`EIHh+Y{n%HHLo3aAg_1--vO*M~2bvDrQ?< z>jt@!WNu?7^A_%f!^e9&?u-lGT%acjv@O}jSk*wgOe9G4EGl4AoT)z%$x4`X-W)75 z-@^nZ52viRN>X$^XuXUGgKw6wS{+p~6LfCSJRy!?eSOx=I(#BIOykBwO#b_r63{}+ z@*ye38WaFD1VFd1}$}E>vJ-bJesy zP5;Wva9<>0l62u1-_=;Lzs<5BJHq!UVs^>DQH2KyHs3R49i7bt9XlgBlW~1H^u&bs z2%){BPsSv?Y3gsq=HV*9^h+*}qvxix`uD0Syg=(iFSjX@g=?5=Db4zp?cEx?@YSH6 z|BG5j0wgjUo5X}C&YLzoOB0PBb*3j2q{7$*CDC|we_xBS2=FQ^i!(*NOL)N~fl4$C zl0QS);d-ysE`0vnq#J;{MjLS)f@YI0YvaX=WS3_jA>W5@P14BsfSFi~>uRXL9U2%U z<7xBcBvG~op`a*!df@y~>+sZ6y&M2_>zr;Ho1Q(x$-jvmu&qaCX4CcM6SsX}!=e`d zmGk%Q7A^ysos63D+z4`ayz_$meJUy{suZ6b;eng)*|AGWMTN(lIQv=iV1T}d^7gD` z{DH=nnj@MR@gI*4bb}v4gJdVfBd!;|5>MV3y_Rf^`gr9RZmQz zTCt`j$RyO){CGQtsAZsYKP=_&(BG}FA=JY9YbEoAT70YB(UTn=k^{DY(sg-WZ3hPV zccZs*z{dm^K2$+Lz3B8O_loN6&0Ui8@iPAUs)JYe&e1@aH3&DWG~fG>EKs|#N83h& z-~6B>$W(um{`S7MQV9!{`Et{3V%)i;aA8I$U&U6D%^iT0Piy=+r+~eW-8&ktbtzg= zMgw68BQE|QwAYiTU?2!$5eMISJ;09wg;uW(FvtUnlad|mZ1lp4drJFs&| z!+DTA`y80UX5F9=D^L5d=-wGmTlVXeC3~UXzK3@`1V0Esir1xLHVrWUIo0d!$ z&c_P1^YdW@1qD5hw}+gfPyc!n;2S-K=7E7?#U0&8F)WYNfsgwax&EXkA2RA)%=l{9 zw=6b(k8d9>wF5Mg1XTjtYxjiFLQzps$KK9!P``>xO6KXH+`nJ%uo|jmTCbbdPr^SX z9+8ui^GKb!$u)ICbqkq{MX!E$j~B~~2F!uL1SX!j9K@nLaZ6)H{>gp?IXwx^5DPfk z82zadapC#8;$hgt>LCKExcWCJD5&G@nQu;ySy`VAsOn_KDgNX60!z#4unLRy7fm8C zvC;Wp-AksknTcXUyFGQ7I06ldzCZK`!jB-ZsSzzfo4}nAh^mBK=X^$Wz3QGK5@XRl zb-lh4_y}S&Q#f370bbOo=6Uq>ptr-H0 z<)ih~WRV%G(fMZEqe7u0G?dR%=RR*I^aKDR%L;7_;VJ-3%9jx=BZi^ zwQA~0d|KEu>QqR+U;)@N3LzJxS^+-utEuW#U_%Wz>9?KV#%8hAX4bBn`tqKUg%JW7gmC5;Q>L3_O!Ir|T3Q5BYmvBCq0tp;_Z}V0wBVXQFCH-@<|r7x$eitWe{N z8V6ur&H?_qTabLyWpcC-aG2;-s%?Q^^-?A{jvI*(uu06_T^(Q>0)*jCnxQKH5JCzJ zOk!dJoFIi52AwSBYz2_D6!(om)ryES$HqEYZL|&cAM-viM+JLGhlLCoz}h+6GPuWq z&!SoTw27uj&~2ZZlk>B51f`wU@aBF z3|_9Q)b*!HAWP7`M?fQV1rfa+@*hRXwQC)NR;&uOD(v@DyhTm(G|NXpfNAFi7a`Yx zXmTNt)}Os!$6-*T*DPCF8%pm7uB82~X;0VLtF>}Jpo}o{MBcz@;01xGc@ePVZ!3j{ zh6>?agS^y-0LqPuX4I@l+}1&#fWMg1`y6^%`RUW+ms7^S@>By*FCVc#PN@Padh5-& zDK~rIT>d~dW%!=qRMR!7ctx&q0xBt|aG-3s5via%A_p~4_{Iy=(fHO!G=;8xFAvBK zdWkcc2f8nH+b%kq1ILPO+0(79X^!jc{Y=`gW*c=J??puSB|HXmSb8*S| zl8Ab_91G0X#eAo$TbvgCQM!PEpRE2f^pVmhH3#)>2a-{k1LMG)ys68#_9KId?7R^@ z^Sn(ia5cnXRLLomU#57AFx})$cDhD1`a$|)uXAUO^>9p{NQd9$r|b5nruj#r7h@i- zu7QML+5UST*5`q`$PH@hK6DL#nE!_0JxWk@BDI>MV|XhoiHdM^i$Abl+jAS4#9f!P`FwU~Die8Xir8Y55k{zO ze>pQ=no+wFd&9ODgaW=aQduZi_;G^1Kq8VWidH2#J0!x!Ere`Ie zRs{ztw*)Wz$jtgUZy^n7R9Hb}s`Gc|=<-NX8NB7!B1yKrHLml}j%;ftfbIknQv`&qbb_9M_Wq}KpKXX@~m7KU%sfFPWCUoM*2&sjq2fS^d& zTmsR!TC3=V8zN_5qc~x-Fu54Cmy9$5f{-6U+7Sr*MzKQ>@q89&t9pQekMHx;clxES zDC(PY)84r4#Vs8{$uhUS+*pDK=r;8CMKO9(Sem5q7*wPubx#%+j$J!Uj{x^mO76Lz zxUW%me-Z|ZWKGPG0KKsY_=o^F6NAc&1oToG8$2+=9zws&6litzCgIg;RH2=d;sP{m5^HOX@Gy`e zv-jfC6`_4zlw2&r^AuT_xP3gLvaB0KsBoDG+)|haZV8pIzL43_do6Z^^j%LnUbn!s>!aecqI-{9uAH2>+Dw-%n~3{H zrYom+gnZ#nKOw~T##!9yO%)56?@@vMl(@?m!K}lFXnUeHz1#oow(6VoIM$p()}huBRTK=>(9B$gDH{O3IhpEbCJlUEYGtpc$6P1=Vnko zd5-Vhi_JTG>eiG^H$o{zN5}ShTn@E7h^e`tpn#krs9(Q~JS6Dm5gzdZiYR788M#$E zHD#)$^Kh0N-r-5x8#ipMbOuY*5P>JyQhu#24~RMpjM!bml3t(xI^U`q0&~NM7->*& zZi_rc&tzB5sIvNT0ep-B*{CJqY3{5{PgJQ#n+QEe&@`js)jUn#9e9t`_nzmq!%DYF zU}gyfKmesuy9mxRK6UY8@&QC%V?Z}a`F?Nx-cT&!cdOYV0q04dcvGP5G2?UD3=}da zvE)@qezLx)NjB~RYEIw~s#ELyg}LL_RAaOfm};EVs(2j^ek1#h8$e-7KZEru>9XHk zWAn{++L?jD2wXtWTLFi*uNAqV!W@q$WqUuKru*zID-;F_kp~4MMpGtuot4v(j*H%n zj#`ctK_p>{Qvm1MX!0go8<9g@UA_A4>B&hmBqy)X!oelAUKlPC64S*91btc}G~wTO zwVgL&`U1~JG9C5nUI&^GaG3hC0E%hFRafj9zGwS0x)<}5l$7YK^ehxmkI^SXC?Kg1 z53uYB+h6F6Ijs#U_3JcwT|zEfPmwECU)eo-$Yb=8>w3q#nI1mPNg>+E*QlQHq)9%( zSR^x~=tr(b=>p_38SzM*oVuGK7?|^MQB%Xtyh`A*7ALbsq9`raZSWr;}r=lk@YN8VJ;>pEc4jElPtG9p&y zQzu$*!@dOLE-aHR(y3Hck#V{rLKAX2#4u|PS9xDMZ%r8Kh4v@akqX#jYd;V|^A7F8 z0Zt9TpHx%;0tE%Ot<0Lwe@zRq8~t3tx-F{HD0bgIW+x6BudgW&OvUOg>Ev}zcr)WW z&bOI6n!Y`>JFG~U>d_bS@^D%npU`Mq)un!!T549`#*2r>+~D5}%W#v{fu~k{u3I ziLF^=VU_t1w6{71S)`v(p*V({Ds6c2BPMNwf2xzn6`%tZ5CcOL0>9zIvr1wJ#E@>2 z;;u89_Fq^ov?HCgc}#zNd~!s16zGw&yq3Y3!?fB8jL3ST7=lvZ4px(%IAMCnUU4~F z6_ZY%AFVSrLU$ftB1YNeD3MU480O=eB>f3{*+M@YX?@U?QRhwadq63gz&bA=2qE_^6Eu&+A~lX~bQ+QS)^!M_HP_Ontd%*_)Yq zfpeMXX`ZB<386=v{iOxKR$I+nOEQGQ^0&F|Q8M3gR486<*}4qGF!3{}EcnqBb)zeo zAcXrP5R(EKih56{+~<7nt(Uk4sDE$%sZpT22T+06MTBr*nyVzVx4VRdgr=4jp@alM z^i{RaR|HhrEXi1^MPv4>u-Q_m(&WphBNokgyMZwHLI!DCjjlmxh(L~sfJ(^df}Sjh zVrC(qqt_&o?vIkFUAYNXFvgttgPw2-AhiGbwq8HTrfl!M5NavH576jGBjtNv_)g^B z>o`_BPgU(Y;1&{vLmS%ulUGvNKgXrwKtG+0>;h?oW$*FL8B;Ss|5VV>W3RP<8=ha4 zvlwJVW)qbse=84cNEWakekynfhoQ+)|C#eKi-tvX)!@6V(gN9dpOLhR+gtk)nt)kk zbZEk$kFZgqzbcNOxc<|_@YDmXRh0(}tgPsJ<%3a)sx+b8A1tjSO6sxG9w6a&MXY zXto*ebANvx%-UYzDG)xC93wgk5bNV$W9Q`4{Q-4L$H6wP)WlSts!k5m^#-sXF&P}~ zb{;W6dPy4Xrz$L0K^-Lw1IZ7@WqJH8eMAv$ulx+f^`Fr;U|ZK*L<`#ZZ(QOiii}y? zVmIj1EXlP2YWbrqdjEXHABB+8bN}k~b->rJqI z$a|^X`p|vEuvQC`F1~VId7`3k?0g04zlx(CZ8Rs7f+-p^B5s(`jvC)Go@XVf0YnL& zsKKu39Tawz-Nj4EAuQDQ`Yb__FxWIlo0ZS-;tf=9qd2+P@;HVBuoCHO31yarJmBIX zmA!B58pImNs98o0e$lY{+o}5lH>8-rGQM8M{_yX*>i4M7OS#udH&1?aTo6BQT_E!< zYZRvtY#IDaZQo^9g>kcE7@qo(8tXNEE4W3+$uVwVeMGttojVa2kR|bZsNm)^AN)r6 zY);EeuCA`|8=|A5D=M7Ak{b>;MPd@k?Fw!`UJn4OKVmVB-TtxNziFd@_J-6} zy!;KYCR}zCyz)aq072m&^`J1cP+)TcefrND?`M-%YTS@m7Dv6s-%zde8Su5Fx#V3z zykjOv-h$zJLEqvOL~`q#EIkA3T4b`H>>c_)VVpOZRdZJ$A%Qy7-_y{|=`?dwg;Rdv z&i{zxjR)Ov(R-r9Qx1GSfy%*W#|23ra~$r@tsL%Rw>GQhgUGkP z(<09z>`-oj!@s_IYC}&4yq-MMJg=e(e;lB+h&o&3KKi>1MtXM-;He{yB^-C2UHB5B zBY_pCp}*4EQyFW0nb*QNo9{<2ETbS;u_JUJ7eQAkkR}{h&>ok*y!t0L`1`r@{i$81 zY$8oEkjL4mWc#l7)`#Ky8yQg$6tX=YdEmhYFO&!0)c2$|VQ5 z&abL{wXa3WkT;8BZrJXr|BWuogx@qGcyfs`@7y_0WN1+gtjEB?l_jEs3N>UCnGd`? z$6(A2PJ^@r3!Nc8(9p?YoExIDA+tFq;Rj-3e^eq}5CF(x4w3ldSvG@5g7>dS;v)h} z?kmF&-pN@7Dlqmy8sZ;)K4xuo+Nr6^YeDO&v@dd-o11?%hr)&HZ(^D7m|Q=*cMS)yX}zaka7ai9={5kNw*kFug-Lr|J}Ur|cO(hPXlB|h(GjZdoAtn}#{g5)anVpfb z0GJH0OHpsnG!R{G>j1IY?mn<&;&oUlDVb~3u?WAFpJUQMC0oYRXNM%+ef_8v?LCyJNc>_6RhZX)pc5&^zeU zdPuztfODnGY>flxmR^(c)!SS{y+op8A9(G7jLo=KGoWnx&NqL<-K4)rhd-Jn{`Typ zWp7^B%pXe{f8)eA#-HoxyGKVI4`Y6}_|kf=0D@F)Y#IYU05UkcBI&|DPl1%uKCMs? z(>7iG@-2JvXON)5KQ#rCRsEL1_76najF;FMX@>u%T9ZRE#6kb&`A7LszdId?52#Rp zmkUxc2?R+&FOvDZ)el*{s%mFEwILf6bj&YtNk|&(+Tc$<@s2oF&%0a#BIy2eptAcE z_-eXLVu@JLkzTCh!DEY&aQLUUZ!$H_P2+Lc%AWDgyL!`j%y=4aKXU;GbaUq>yPtqx z;B=#t+_+Ao=X(FvBrx7<8$GurKG?NYkJ%Y1#iHb-qNEI8UnomcFP`b>xOk9B-Tu4f zNKFFm>8;4;@HOJEx#Q*=;pde4CMGz$YZ3EQZ*FQmZZ@Ew8ty(7BXJ;CKcV?5*Mi$^DC!e|9rk0T_VSyPx zCp#?ZZX=gku6bf&Vp3}L%J5^GKifO=mHcJn-nhN6J*?yaq2mT;lvS15BE7K+%VRL0 z=CV!lM+bX=d29jWaDzt}&Z8KkgDb(;=OFv^Aq(5>J|#Q~yqI#bz|Q`f+fUrc15BzuZvvC`gK?>V(4@+CYhl{FvZ|_p zsLGzU^q1dEKSl@m3nGWQ`TD|I>YCi{FNMJ!X2AFrt@E$MGHF3rFkb)@)a8}R2F>yn zyHG+?#7uDvotSZ{&0{;GIYD?Bo?w8R!$@gg0TdH}aII=4Wh%ha1dru~>vf}$z3l}} zvR#GV^%b|xR2PtXvvMe$?iq~)8nhYzcr@l|aw`HLhlop&`|VpTijR3X$K4s5(sOkf zH~D!lPv66$24W9L1ALX1fuVbNcpv#3ezjPQDJk}rY@qxd?43Q1)*9V<)T}tOIv)V% zCPr9z0(N7SpN8)5c5kO*;eCo`jh-crG2(}Jre-VF}x1A7thGQ!A1V=iG?R34TdrD4hVMMU5od~$?H4Bp?>d*kcHTXaO=O`@I4w| zf<(`XBJ(5;b>^i-Y&ompFo7;~Lte-6T^_hH*Y}ZGw=c-oo7S~4?-ucGKOKzZ-+$Uu zn!|>>#1$Lr>%ly801Q)()nDP7nwa=0GnUE#qrI*LA@P_$fWO9@S!A|mwd?MpuM6u{ zAcz#q>l4 zN0wsb<*CV9$qJJeF~9p~3swI3+!&BOBwuNzN%YtvBZjOv)@HU5gvb^T3Z|u}10!~< z;ss6xL0FJ@d-3F=hWKWSLnRf`n=mH3;%U$d;?KX!;sM#HiCxDU|C|Nzh-oK-xLqS2IjHjJ{JVPN#YlSr;5lKHN^yJqVa2EjP-_9%V z2?DMJTo0?&fggC|eJsX$KnCmU>-#mr1w5r}Y-~~^2n>s}8U6r>?@UurnBlOPzLL1; z==UH6Zz_Hz);RdBL<5IZM>ZZF4QK3i5R9HI=+r$j^4aV1rd8EK15}p;!)E4VjSa;+ z6@}WBAW(SLp=mX;GfcbELN!DBroRja2L~wj^`P5RMq1GxP9etUyb0Vebkb@dhanAmM2&G$pcPoKuZ2R$)BN4(vKjJr4zjl0 zxY#vMMVkfky0~-I8u&VvS2utN9iGvqAO-NXwKX(+eSLu!1ypJ~K+mmbQ(q@Paa-+8 z)+uy2O6_6`07s?4At* zby$IVlP)l2$P~c+2`nr?JCYc9Th){Sg*aexEi{^sRPO=W3JeVN$12;(iVFD%N{I!- z{+Sz}nScFhb|DWZKcado?33!eF(DtOJPsm+Q)z)L=wPOyZo1CNZ`+3w45PjMeNjqs z@1Htp2)b-4 z)qWWV|FN9td{`u1 zG`-qq;O}Q!eBogNJZDn8Q!!8{Jj!GFeP~Uy5u;l+}(xaMl4~F3L zGROc(0U?NAvKK2{c1Tau>GgE|yT@J-^V5!MExc*KPvNy+YF-V(qTBr%e0Sr0JG(cF zl7puT(k;l#9vil^CPIS=j$IDd4*6U(JCr0Cn~CpoH3i^^8s+YRixILxk<<`d=-G!? zGLeDGN2M;qMInOJw#Tp~4R&kcErZGpl1?>*7#m$Po*PWL1X2_8%V-*3196PUrG8w0 zQ?xsb!+slmzcXZ=Ae_r^TB;VL7x*uS@CaHBkQzWgdM+O4LsAS(QChsOAuy+&qn}@& zAJ22?)H(uq0zzc~ZE#=8>wmkB2;l-&iWcMD{<@tIe8J5xctlOZ7pmiUYDHR_)z9XC zKDOw3YIHpTW=`wb+Sj{t6vW!w8XwkvJ#qSp{hnO&^HvIv;{DZ+AX$vV9V@L8A-H+w zwTIcjM<)q^JoDNR4Z68tNjF}`(8{Ch?oNy z2rk0ARHy)7m6W}2iU1|CzLGs~FdH!OpwE5|jedtw|S+{c}U&oQ%mr$_!e)c{-s6Kj1}s zxcua%m8kTf_)Ao&?F&8y1JJZcsKWH`aDy%hcG{R&UQO|Kr`E1S!lJ8qHHj+G(P9jP z5QHb|_;V0S{&1)1S|N&OD1?aH_K9~xt&eh#69f)GGU*t`OuBClmo<9x{H1vc>w5RXCx`joG9b^}2*K?`G6Y5DF_+F` zD~;<2t1wg|!3zMEJXFq7foC!Ihj%p;i62qA&FE&h|jT>ZjIH2U=NQwx)A0U zGCsz;^?10Cq;LYVzHT^uL`1%iQf>?ecE;DiKckG$s3FZ(#&wkhHX~f2WbWj_U3a(| z;3~uspr$dirc%Fj&yossN)r&1JbZ@m=bh$;hHO=Ajz__lovTSa{j)5-Jv~D<$fpo* zI{Xn1T(fY^KO_kdA4FJvD*o!u6BlLSZu)=4zoTCx!ZLltxW3{-^2p_q{gszxT?Rtt~$HUr*<+ zAKP`IAq`{0$=44w->>SHdwly>!Ao9*iHL&o*n0LpAvbFgZus&o{2w>~OIZ?;#Op*k zU&Haz%GcVY%X7X0mCMHcuOr`n)2<2KD(QTw1y4ux1CW$L_+7|t&$)n$nj2ug5OJ3o zf5kM@EYZJx{&u0luv}#~F~^<0ZD(w7u@78wz5U$(eg!(6SO0g2Ki>Yjb z*m3}m8lx>BMey~RH71|izVY?drN#FgFJN00a*t z-HK1qKuvUQW0I$LQb7aO-)XhYYTCP8L5dO-H}Y!0>HrzN8O>nI zt^%ovOk8E)EWu1Y_F)_JS!ZmQ z_|R4So^z?w##ldS{48XA1rfrRCZn4v-e>EbWON?nJ`(G1NvFlc#3&+Jyss$40%&BT zy2P%m-*8z^X?)Ue0kk!t!$C2Z^iKZmyy!>X9|d*3I_``v12uBBpQ`0jmlB8$Qx{RP zy|_AGF#9zDVil0$frxV-;ixJ^l6n7Em<9h>#nAik+%!?Q42RRB<7Dd>J82 z2^fqYO|}BVMan&JCSIdW-r(7Opq1ydTkr+HV0=+MH)+#2 zU2o!Wbpgj3&}mAq!3nnEmz`wZ#=TMN$saMdq2bdzB%uLYYkR7cs#n4A1va$)yqgy_4yyTrnbIwee%s)l=yZmqbO8wfV54p&!EH5pH+g|&^XhD4sLFb# zb-M|LXcSCCFAsB+z!7d*EMTkIc)GFy2=7Z^+b8pZI$W>PilDZ)UT-yxh;kkD02Sg` z4UWC9Ndz77-8($bw`waa2a<%U%#OE#09(`zM8|xfQw*JP`PCf_&i7)s?PPF}|3~?1 zI9D+cYM_v?8&8bng2c>;YP+^1{#19e&Es`CU>aNFdCKnYvbR9<1RaoH%P>>gK7mfRdu|St=d)3`nBSML|4AgrWwTL7Dguz@%()?)!NNV1lod z?}Hpn2L#=Iju?drz#q>Dcz7i~A^2dwTdI&{<8N~5Y4+T{0>h1 zF~;@%KNqoW4#;HH8_CxeePXTRE?P(7O~?yac%%J`xv>nY4EfYMe?ZUVm6*E&tUETS z0Jy*EO!4NlI-4bU%m10y5%~;^) z=CHO)(MxBt!e#>eWLtrWmR+#C;!fkYv-Ul#0m1XlCjP7SnL?r`pgaim6DqE9CE)=3 z`{FK*3u+K1-#mwt$OpFmt+lqmk+EYMFhys6kz<>UO2TC|e(%xqh_qJs z;3N3Ac*aYOa-Jrh$I~wQP`OI!rY&^IYm?c>88TQDK>ppoJzZO*)sW}aaMr~1BCxH; zSWC?Rfj=BdgHdnQEC?BrcGnUl>;CxIyYb@X^X!Mpl@AKOh9wtI8Q%~VfF~?>M|)?h=EEQy30>G$ z*1PRz$vk@g6{P72Quy!V{=&iPHVA)%=B`)g{0bA6CWjOVsNc#ECYQKhEunkPgX5G`_taKk+jJDbf zEs1ZRi7U+5%%Uayl3`66L~z6?QJH;z>xdH>w=R6HEr>EUVZe9SR>md>*(9 zkk|!iAu49=2ky? zrWwzCnMhh&5c7_j9|O{C1F5ejE0B-YK;dPuJn=P>?&{)TAXd-%ouBm%u`D5U=5hZ%@uB=3VZVALF^+v(w$viO;2lNo}jepT~BPJPPmSjmIO%e(tLI@R^?xN2}h|j^=HJ6=;&D za2$_;p0kW%lCZDpXzh1?{O;};rr>Dv8=rP4>>(vM{;N}jESZ;&M(}>*IMx@lo-qt26P5ap+qO!LbXjmcf96^|4@>-Nlq(&H zr&n*kZ2z6^k&zyqT1D40uMfiHuwV;Y22?%cd5#A7{kD%1iauL*4N^)37wT21#g0sC z2>HRYpcwD6D5X(v3YLke6)P!gc>^O^w}lfsYJ_h{TuQiJX>&a+TrKCE14;bF`Sy+; zE-{9UkM-CMm%CNN!R^}hhhnGf3hrH?TmEzThs^#wGz<2*bRpXAyh;d5ktzPe`= z5%rW(Z^mCpO)27z+vK(MlD!jN;`t7$p$$$OO+W-rm?>X3{N-qT!#F|EBQ8 zj3F7#j462!G8sNGNHfls)0&#WsRdwV8%0_bE0>hbHXPj3DuY5khb@1WwlEs)2 z=p;E9I+ek3e3 z1$keYoY%DuUb2vlZnCGF-3i{4p^n`JIf7cJo)~tMr&Ji8w$uTnk5p}03AeyJotbU* zoktw4DyU82P%*su$!AjBrJ;055Z>zAViTO~of=Wp*|#;Snc?{^>PlUb_NjU3y%OU2 zcD9F~P)}QBjOi4J^=jNv)FR#DKexszFv!Gp^?JW4p)T>o5>WC_D_?xVzV3W+C>+K0 ziqIp95lw))6Bc$0i3mMIBTxSTAKMD!@>tJ7k>-v5Qg;+DGI29a;X7L+s88?x6vOUI zm;>S6FIkn+ImC=^OzK@5YHaAeA zKe95G2!3aRt(N>nSi-Q$cs1l=6)(~3wK1x>`C&%TOd&-w*T7#g zs3czLJ{8?05JHq{mWhWw*{4^q_$IfSh1Kb7;6SMYHMArXsSmM_^!3g_7il!@(*m2( zFZ@vY1f3LTp&voeaqU9!^7!a$u!}|67#sNb7^{?AvsPst(0|)nLj^JbS!i?-c(nU< zQ;m0okOas0aCc3Y9$Gb-I=wHd?4oW?C&g{&VPmST6zzp&XW_flNW2 zu|BzMEP%{ueI$35E=9jesm(0qi|l>+vCt)4nP1nN-U1#+Ihq2n*5ftsXr%9GOyf$M zW>cv`S39HXz9iV38O9@XmP~Fe$LjeC9ciHwa1o1u-38(PcXiulv)#c|+g(aXGgAH0 z#0C&ko}Vl>j>83MnCD~B%Bz{Fv}{(`>@Rs>n6>~7mVajf)K7slI%a3Wo zev@%{0uvj%()p=k|L_mbAFio@KetoWsoNte@vgah^MK=BEIm}4W+|l~0vd_Ga>qw5 z%b7yU*r9Z(zQo5w(9d~i>J7M0f#-A;AQIi6@rFQ|2`f-B`qW}nh{_KU!>)4d!p&h% z;a>Hug7 zaV0j0nxG4oy4qg&A|>%Tw_Bo+BPl<8dbEoGwaS33SCxkwCA!=^s#5PQJmYosCH5#J zQ}zSuf9o@IYINfta4VnOC*2ooY!Q5s;@{A`C)gnN-DyRhEViA)SOHo_&W`t062aWW z(IW6}sHmu;Vs_HJ3f8T4EYW89F*ZPy(B!<8U796k?H4H$?hCKMJmSxe?zZvjxX0*I9^tvc~6koVb8EtVXIEJA7FeSZ3 zo21Fnp-T;+{iVQ=SMG^=2*VG}CB0yL=yiEkv#gt3VT|OI_&^LEOMO}xo=mzHN!ts3dC=YwKtJ9a zsIDEuph`%a2eu{iIUc|rYdJB=|HXBV1BDApB2uk*o>>hxvy`%6X1@<17C77M zbO3P!STwS|(S|Nx!-rt#=02;k?T+t>Vj@?Y_I>=GD~1qNX&=o1a)D3=t8o!Y7!`>? z@=yl~(NgFLKpYS-d&K;4enJI^{QEP}>5$sf8e;4|upmp^Vc-I_#IaUKe4eq$?G4_9 z?fq7_1$FqpaaPD7uA)9%Wv=@N}i1VDTFaug){h1 zH1ywRJ16VI#jeiz;J-Vj+usDz!J1PA)8X_P{uiOS1^lWprI!d7kgFZ{MBhA$eLn7M zJzMko*8*Gux4<6*>Cf|8V1x_~K3hDi(9hmCSg>71eIo|4p+lMg-wUW{V+@S#K4g;p zvHZ15LSu|~J%`KYdY(~c_Qc?gFJ&O!-K9~uo`T%USmpx{E?)Ut{#I$+cQ@EztE_XI z(uY4s0y;mEZL!H_0p7~LnCX9>lQj7X;-Dn1LqYsq`t9GSrtl`fcr^d-5I1)O+>71j z$u8@edJx4LSL>Agqr~g=XDU))^gZic9Ozu3a^iz|gSLQLu*zoEYipvSj4n^o)bo?Q#oZvQw;VqF2t(O#BD;$7G6siFSE6z?nE z*WG*$fk!GiyCM{W^Or}jW;{UUuNBLv`#>@WDD@?HSMmB&W*c86OiSV}(*_i80-Sh6>4{bJ;+x;HT|oPe&oNN{4IW${s4l0=JruDiJFe(KJVxiQjs6a`suep$ zWVpp4Q2ZZKZwnITG=ji8nI!X+&QOwXzh*2;acm>_OC6_Pph<= zwqG9x7?WM~Momcg_4dVBpB2-*xD8R|y(SRq0xrN{@AFjc@)RX*?xaU5$8&-}Tmu_- zsW8$XRW`)}6k#Ic%#5!=hh4P*k?6fdkTw^iVf|kO(CQ{wQdgoM0A*13$kPJqwCC9h zuf3Jne*tdyH?PZBMx9FAp#^y&E>8h>m8jsvzW`ABZbC*3^}-jY-wxMt2jXf3999lN zxWopxc{tcj#mTVsXJc;lQCYNuO9l3k-p38c#yOV+{udj~kl}DxVWE-=y*LOnAz(N4 zTuTiAt2FyTtR5x*nMgVkV7B`#RdiUt)cg8szRh1giG6wDOE3UFzb0^la_^LpX0pufz@~|b z@p%UQMd^=&@~caL$Hf2$4&ZCNj%(q(D4I|r3S30s8|BQi zm4sq|=PAZ{^xAY4!`CSXttAd59GYV6tR4puJDc0x(MDpN~MGH)C>TG}6V zyp?AY3wYpP3GfC0c8K5r`BEkp?bGqgS?>Te5=n zpjA8qDk(mj=QvqU422q?W0|yxxhdsnbK3wEtmnfctIl%^05z^ZyP2w=?ryss548$lt&>?X{?)&n4bvKbsK)o%OnwYzV@FFOtx` z#hqZh)YIkuBm{i~&r_3BQAKLSWax=zv;8HROxfs;13blM&_%Cv65uz8jt77jDlzLP z!!PeJ*_|cnWIk|$_6qQOX)8tqwVwnHpjDA6vYnq$P1d-#FG9Wk)s17-e>oqpmzwHQ zW;OsRhN3TM5K0RT|4zW+mCX4P)A$)2Xc2wnTkaG`!fF|(b1)>xWkt_@0#m$*h*W|9 zgw3(p;42b5mK;$fr*tMUs)up}jr<4BhaF2mXT2zr!c~ah_^g&d==yzcIs%Qthr{A! z@5a;7$yx@v1iK#IvJhqt=iVy(y-*S%e?1hyro8%ceWhM)YoG#@_3GEhr>kqzIdF_o zZJ}59aTug!rF-PF7g>Y3Rs=9>zsLH~S;(_wWq$iHnXni)Cq2LhL&R+j^w7kOaqEiQ z!pd(;E)7!6(5yGb$aoETKGNea@F&}>DdZEr&#ZV`v*@A~ACjkFJ&u_kO*I z_w{}hiW3#*WL0JE+_@L*q@6#_4uO5YR|T}j7;mv49A&BF-X`P*3t$QcpAWe82QC0x z5hF?8{W+8I$QIW?s%>ZAXfu!=H(&3VKTSubau{;n4+5pcrvpRd;pAT!9jw-C%XQ|8 z+x;>~LF=BU*wun>uXm>mmm|74A6(FQ-nDuFO%Iz#E;0=$UlakNd&ba}^r^-^eTMH_ zFa}^_1?D~w!R7RT)Il&pILZ_E2!x+*>B+0p#;j7sMuEVP3t`Gpl&UY5Di`4tf@Q&8 z0R9Hp3kB{25*olB0Q`CfC@ODia%}&mlfHO;5x2pDb5`R~ly{>y2kvuPrg?&W1K)g;hTdd5-uvu~!r%SMB zt2{5deqm(!5}DT;(X*HbT#x9%|I$L%-Dwp{r`5WJe?sb!#?hXl^UJviBR-fNGzH+;WB@Z*G|diN z#8$*MtJdO&^qan3_jS$r-NWPknQqma zEiA@X5IdA!1GCq{B+$St<+JKrrb;DHsH7nEbUJnc`cW;(^1-+EM9N?rLLkWTMZM!E zC_u5tP+X|0?tjf>s8udgYIZ0b#^^b>{CV#>$Bnewy8ZdJg`|6hGg;oE3-xJERp(b% z09HL2&RskVI=VE|2vB+ka8Uqg1`HMCCm>b^p-pBnGp3mAH31kVPoz&4_Q+wU0fejC!cgu5G$J@7!KroA8LuVzGra+bDYuRMETXewv&f8B3)| zC|5TaqJNeG5$e?Z?MPa|1U7=fly7rs7D)T4k>LI`u(IB^bM;eCz%mL9ytHp`px57k z3f5cC{fo0zdECi`57)w_R1(dtPisR}`xDH`?17f`a@#$?ej;`r@E14!gzyXnEnC9l zbdcku=3rpna|coSxseS`eHyq|71&ZA!0s%$gAq&qLJ^Dmb7GYr{>`K5s}q?WR|D2S zhGE4sfO|b&V?dSa;E^Z*z}MuG{`{}sm zE^u7Wk_fX2qoptAlON=^<9<2(rssWs0qXlIS9XN7T>lO;cio%~UwE8b{sQrQkS|_G zw;yYqwas*To;vlRC)nd*gG5U8-n>%Xhs_?an(enm?Y7z29?lnPz-**{Zvqxk96|Q& zFVm4~Tww1Le9v&qn(Pv`88uG{yTypT248NHhi)1TuM=kj?_0 z76suU3h#Jj=OkKeo@=R0$>y;)VAyrtUCd+HBvwSNsm}{c#_avg8@T~#k?E2Xv{w*0 zj;DYOXCapFjgn>A@uRPravV4(=t>MICvm$Jr;A0JtP^D#G3MRV1{lB}8^r0|p=|-u z$k&bjPecU>nbYg?&4uigOh?FBVF3vtIVy8_+%?IYkNa6bK!Fh?DiwtEWr5mFg4Uk} z0z4Tw?f^;}ikE@^hde##q^o!ie~_<_2d~;Jh*W!1mM$oe*g)rCg@-N|=HebXtV0)+)i z_?uuYV)r%*_|bCpxDSL09=^hOx2H2=lEg%dkoJ+ApkhCJHrbIR+`Tws;x0lpDvA;j z1~N)mt!(2Yn;i~hM2eu6pny<`8un|0Zh$fz_U#Ve$d);VV;~3q53XQ}#K;r`8q6EJ zH)0mUZ%`Cw9F@*WE;+NW$Ll~N$*2*r5K^XN^D)&eR&(|9SyLD{m=4b7B;ICjF zsTuv?_6Rl9Kc$r^Jl^YK-Ws3#JYOoHv;8%<0I11-upkY}F0Ua1(-oWrTb03i{c8!T zAd91V(gdhQ5i%$6>H6T>2JM%(^#vfXf6YLGtyuH9hCd9K^{OdX*p+bhJji)?Y?LgF z7=|3k9+_KJ2)qG0!&f++tp3C(uZuwS5F9dGo_+K71nTkB-qpu2t#e9dK`{=yBcXb` zJ&MXWZ06Io%9bxXO~xZdXuh-rECxYQnkC}Mhb8Le4R4=qF1HM@5PlE`N8x%3&K+*X2(9VMbTy8pz3e1(s!1e5j3?9eJZjlv@h}c*9iygFf z@M^hozz!Dx_2oSw>qP5|MaRBdC?Ylp(P^<0l(JbpoKO(l_kD{13KOiB^IBEjjpU}b z+~=+bliPuaYXD|_=h<<6*6L7Rr8zYzSp5q=R}BgL9!PEQxWWp9XUo?=XoeueickUx zLqnzJNHW@Mp&HO`-SYFX--#?&S4x1)KkMzy{Twf>2x z`yPhdw`V*h92J<&>L{fxu`qa?2t-CzWmGz=7+a4-sMVO2>Wqf+sUDH~|sGclC`iqxC!C#9TWYDNItma3tte5KGtKU70 zq>b?r2Aq^jr`D&|YE8zd^AjoMKTSYMYXQWQwZ?m~YPQXfF}u`CU0?N6YYN~hv}3d< z;;Su|J(8VV@9}v!olnAay&2SaUT>IVwqsvGqJyUupvT$$nqh1j8#G4m!GrjJ1<3A< zlHn&dF;r)8(&`j$u_8Sd^oDu|d7aeQ4NxsP=52LvP7xwo*itizOootnKf#IP?FqhM z`Y&(wja6aY;Rl(vw~sA2B9JvnK61k1s)0mI0O}aSN&v@h2P#ssZNJT?3kZ(qDrXwc z2Y?f_#av5$SdO+!COY=_3gr5lzI2M4@~`^~YbS=?LkU|W-!ii_1MNLq1j2zW@!bR1 zrb5W+J4lI2d}wWViwoJq|k(#xbI zDG|H2siNj7_0!ak_ur*hBw46lVw}ZA$Gtr&?%PN-`k1k#60nRqGSh1HpAp9UGd@pP z6zPL4HKSV&`^ZIg&N!^YKm9lQ!6DMC6CtoZD_6Ud3;qiDIPtrJ!)#J@T6frVBHr-2 zXlxp?6MQRD{*U$wehwRPhSG*Rv_1q02v)K^{%5v8V|zvJHLv`>jOI?@gk9s5HccPm zU9T_&pltaocBJA+q_)6;`vYXr!Fl_P$Ib6`r3xkJ$AKRTWEG^F36rkfF?J8*-mWwq z5#nM_$bR*(6wPXNrJ}*BF z>dcu>&eNH8#ZI=g02ancbqn<>?F7y@-f28>SPu820)?x4K>wlUOXvyhkTJj!-~wWeWg&Q$;GY+R543o0O&A7x`;F1wW7=ewDi#iVzUx z1ol0wpb1neg*>_H=MjCG$DYlp^`um>0aYgSP*8IujcK=_3R`oX0t#VwrZ;D%M3A=qXexlP#yb2GCUt5NFzj@ ztP0nr9SzjOG3g3_Zs%bqKmg|R=bCl+Dg9__MbT6r(34Jltc0}q?3lwcg<=?l`E*2W(njHN9=D41e4GxX{!wW=jKp6HrC3}{1B#_;iOY+Qv}}Re2xC%5u1tx z%1(`+s`q>0uIlw;Ar9x2Zj=f%@#`QpV^;{z7XoRl`x*DAXTzwIJ-%k4_OPE=HhYP> z)4bE@AW)sQd%H~E;;oY_UTDMOP_MPoVSIyE_m(LupX{{Z zxxvd8)_?ru3%h+kQf=#@23GU~7@z;!1_b)GuJ>3lrq^w{!fN_gyMc_r*wytGN*U}Xw%9fC?yWuS`DYJbCTy>8I4Z^E=NlA8M55Da)mxCw z-|Ev(0AZU@C0DR770UUJ=Iw7#r&#G4Z>*A6=t87rBeDRSktS*tP#8HquX;u)JM$Yx4J6|ZZ$K^$5 z8I1Wu=%>3sK9?_kuc%zU$?tuWh{yHzi{o+{CB3=&C)}Phjbj(cOU&?T*~uSpeZLZl zi(M30jy{InjXkbxVgYxM<;p^hS_O<{057U=W#{KHyC^(nh>-xNY^YTm<6e3s!vXJi z^-doL`;10>!tO&Ww<(x;VZGjG{cbl~cU-)1f$3>pyk@{1TzY)6nh{8gVt-K6iosLQ z_H52;;^n(3B`hbvYVJ=aO-2B<{%C24HD9pNRLvHqcQx?rlTQs&6>?{@H9h539WgYEiZ#y*H4DZx zyfMM>)rIOxmw$02x2XNqj7WOg>Kyta*qI3UB3R89kb3Ly<)GiHfZ{=n?|Z36p+of1 z+(q7JsU$rCv2$K;(P&)x79;44Z-HdoF7OZW>Xp1bzvF@Cf19`}R*fgO0uv)0c*<_i zYBMHdb}l|cTYYbf9eA4Z6=@fRFrK~7c$^*2Uo8ugXqp!sKGw5`S4>?`p!wtxs`z^Y zi2H-b(@ui21!OkxYI!bO2R(UZzZw>893T)X7MI6x;BePnQ?JQX$LbCv;Nxk01@pGboH1RmFlht3QR$;ULK3yMvB=B!OaPy z-K44m7g1tKi0MR^yAKx;X(*yTu;-WF4$;nLwDGaj~QV~ zg%--p5o^}8#upMmg2qJ0t1kvPsDZ-C9?Uu)bGL72`%2dfK;u5Ql6_3lFO=h8Aj7}d z#VGOE1ruv>P42K(^WB*cPXA1S{5H8Pw>;&=+i@~u5>}(rt%4!eR2Xvp1&K^ElktU;H>Fha{hB2S z$<}JV+WgbBl#DWDC?ECj%43dC^UT+0p4N7~Xb|lHy*flhns@U8P>Adf=MfZ2rF5uN zE&+L5y}vo4S>bwqr6iWOSDX;Y3mznK;mkL>mt7$h<#vY>%IVyt>R09D+H$K-Q!7>nF$%R6GHTFO&~&jKKEh&{3@2j2>R0y-3DJ(D_eoG*J!fmf zl>t82q4c{~I8oYNTY)zi^QqQH8OBxo{`g!uZQ{=>+}@BW&f>&t7uaFFTQa;sTF zO9ibRB{JZuF@M6PVa0FJ#-|0RPhan}T^DPACJg^#-o|7*kwn%E^^xZ4?rXG+OX`(6 zXAxO)P=#ln6fwH=LTcP+-&O~-oz3dc_ed%NG27uVeR`3qOF;2j!#C6hKPcy^4`ypVNw|mbyXqraz0Xg0 znQu4q9#fZpFl9<4X_%BvqaePq4gZ@Y(Dh1&6gf@;2IUnSdr{6!-i1G?m(6|pDHw&mhlAu-(E_1O|jNZkVhZ|$?r_|j` zwmL-O4`{YJh%rVMZ@c9g>3w!JJf8ocq{TK+smV6CCCrA!_Fj@C{~N%$U>UD}MZ%)f zYJ4%@3i`3?aggiMxO`!=9};4bcGNS#+fqh)^4Dvu{O*NFSCg~gu=t#>2^NR!{K(I= zm;56uda%K_@yEUl<{e(7Kb(8@ETNYir7ft(xU=i*l7$f@F^>paqeage{B&jM!W?KO z8_KzycCJTs)R}wASxJ1X1`SX7-5tcn9r~{51TEMaLpxVw`qwmT&$W&7RUzl#YT`{o zj+3AViwmSop&%PE8sz=}zxL`iJJ2Z!tbY@|!f?P1;-jV42T{s55HQ&(Rdz$ui2OYT zJ}f6zi*4(e6^(m}@%uh5wM+HMFpu+L1LQ>ZC^%Dr28-9Puhl8sO+y0`zRw9G=CB$! z7^_(}H^l8ioOk>PdU*i zKGUX4|ITjzx@*MHbCfBkbt;V--vuG`{9^eah<%1W>iAntfv28^ZS|BqqWC-a+`mDs;( zR4YC**f%*s{>pW=S?^y@2MSUEcbB1-eP}xN-}h=j;tyqIR2ra&L#43DoW&p@_`j+D zkXUO;C2%7n7k}aDlfw2<{77fhCu`lLl=w@I6_yF!^qa|L9@g={`Bs0A49W2i5);R~ z^uOG>{<^8Z=}CZs5a3Duzx|4^jZvT9(%I|=a3_b@!yo?pb@;Q36V4*pmhb#ENc%W` zC2OPN7_9u~J^_51|3N?UgY_rG@b&oj8{@|%>;fodIiJ+Q|NC42^Q!$@iT_ZdZ5kT= zUl*|R6Mn!^KdQ6O$)ExK@A=r(PTWP+(@rea{;y}oCe!vE-(s1Www3t_{bC}#psFj|g8KW}mX7E?J38vfHv1efdCpe#E8 zYPtgmFyPzC&0uUO`maT|#pCa1ZFHH13i3CH>ksM@HwDtT=-%_G*3$+G25>=}d-cN~ zw|kL;0%cXT8>l0>m;K+r{f~LF0zE0ZHy%Rt_ni923Ism93OW!t@L@0FA7LQY!-VSJmEwOqfPanS|9=dBK9c`09zz_Z4=OW4 zGSinYvpQutIfSkI?bq_|afidp2V;Sf-3l-RN}$Lc7-@Ao(T(>heum6;Rt)!K+0>rY zQZ;QdWpJ8yr7xNQpRux5Oz4KJk{Y?oi zp0NHQyWaW$uuAdUBzu3a0PBoYI(0JZcDd1(>uH(nd>Rs~>)v@p)x{kr+TBk2BS$D7 zFKsDgDxColCnmXgmq?=PB8-8dQ!Hy>*?=WmOYZG@cX4MiS!$C*A#h*ZrCkfZOsH!h~~QP+p*aQJ@Lh zs?ssplvft{xFQ4v_@wUc;Y;N&+^{}!`z~`O{kJE2QYPSSBeu6iaPQpJ z;FBhkAA{Pv=1nHWuVO)2LCm^NH zlFNFGpJ0N5mCy|CK7#CS%3}z7WcIsy=j>y5mS%82kY5-?MKVLrYWq866Z)WpJ+m@- z9o8w;yuB(QMptwyl@A2O{+Rqm2SBGHZE@-EM0>JS{`1T>`!*G1224ki1z zjueV>P5vIJyQ26PuWR-OdqpaJNeEzNWcQNtPDjn`AD&y!#P$L9xgvUCx#V7mY_jwRXDpC|&-iZ{6r1 ztq_BnMjPAYA-QmYxsrme{NXK5zPm27;c`l!qbvahHIV0J_kX{7@Fyzhh_JNR15ZXr zF9;(P8)FiWJ)ndDG0#AAA=ZS*0e9~)qmQyNBE9FW8g{-V4#hTa|@hR8u zTTl-%O<>Lp7tmEu2+sI-{h7co~K%`tZC7ro`V1A*a+7?h5ck@ zdSt=S#X4QbMh=pKP#3)9L6ZT|6{3CfK=ss&&y7K*?T!nI72)tQYKc*=HS>e(5Nh>K z5s4d(%HUJmYAO8Do9M?A8Ye(`6+RR${@Tq^)2X0y$b^l@$9#pB?8<~_5_cXOs)0tj zd#g!m)M!RoZ$V6y8@`yhoWxytsl>CR6NG_bIcaA+nkvt^!h4%R*+_z3C=gCXu*)Ae zT6|^i=tc@cwgI#ul}sA_z5Nx^2#aS(!(YW59>Lw1EO#%urmUyO#+ zC{LSqqq(!OgjC_0>_!sa?CoyDls4ItN#pQYgHfa5XNY7>2^Vz_pI^hu0}>t3%5a&W zvSW6Y4Ujo+NSzwV@+8Mo?0=7vB&UBu)#|^Eix((+phgU{Lcl9sdF(F)3gDI|ATA4pW8zV#xF{6NW?BuqN6 zaCU8RIy{+>Q-*cPoGi*CnWf7S+>Gl+$2xz>`A%xx3xj~bTE9q*YJgtv(K1~rsXrtU zyF;~8JZ zpQFThgC7J|7Mi9PI@Tx^`YDyD#m|iW_B+#~Wy+&aOQZ6PqjBBieqf6>q2w%8f8M5Qw>m;jgXlJEZk&+NJR-lY^&Po3u^cQqj@f1$}1O(BW>(14c# z@%Dfp<@LP}j^H=qX}>e_cG+zALsC`P<-&GYOqk%~+~@wOz_*3MGE7-ErcY_d zt~9#!uS43M6)KMX;S3~RyHo33ix)&;zV!`?ZJv4aWZ*@umxq7;EHM;O{0Dm2jMJ2m>w)Ptf{q+iYJT@UV~Fhq0TgBfMlDL z68+rQH5{=38{4}C6-}cMxe4^_rTB5u9QcjAhnQ#dKmZkl?TOWaR0u1PLUv1W7~NBf=BHOcI{M3A5)UELi3Bo{!23`~BaeP{gLl zqbp*u7r@drM%U%ke52DP`v`oG9V{S3hm|k`!%$ zLkbZjBAnO>vHM^xI=qdNCTuVK44rc#4m-jYRP8yJb7C^be#^z*L#E(*&Mq!88ZD2M za!I|#{Q-4L(gx9V;Erwk#N6-@mqf-RUxSAk?Cj}0q!_fE>jRcMx|C`+n~3_<7PFUP zFLY#WaTwt}M2MFl98;b!B?7j^!=|>Hj2E5&R8D*oGb1P_xRD}bg3zT_zi$wsA+ZRm zB*I2K?mIGGN?M#}a~y$|iFL@PsPJa5&Y;1r3?FxT!7F7olVIR9G}|{Mw9q||$GlP` zUpcB2I!uXgzwr0&6Y1c>ao*8MKHZ(6(b(+J1_cn3uH zAu=kinKQsi#(!evnxTf5UnZmRz~sIxNKm~>6kWO@2f6q9?#=L_U^1dsD3==5i+@gf z{BA&t5rda+91VMFAxzj9o*4P3a7lgTaqm4y2CvGK#HD#F_mxRP;eWIhz~RFg^uY#o zfX*vEX&qSz@f^|mP037$WaS+OG1x}K7~TCM^eyKFTFBLgeIk)6i_@w5Vv-5#MP9N9Ui-RO{G+?v)HOsfF^>Lmr=V`74TSOt z8mEuA+2)SV5NNnysBe@z#k3%nNQC$DgOKvxCZD;H*;L{iVZ2J*;LBJ#6gVz(i=4iW zkJ&O?(EK0@?+--6Jl%E!$$Yc7m(a~hRpAhPKq&yw!)sw)pexmIH6D>ID~PECpW7&; zz>mL$`8)}m{Vdz;Ei*xUZTq=yx`Zj7c8im0cQ9o5c2J2kC>`QryLq3%%u#2c@=hBu ze={_>EoLNWf}FDxT)SyhwhQ-K+q!OgNi(S1;iZ1M6+boR@DW{m=X7;nI&+sPlWt|QgMpD-at=@c7_i%GkSt{5sARqe^sVrWKz}9vC{9BXeZ|Lm$kyqaJ7y6rK zg*VM!k@~YG@?NEK%?C%bB(a_$emLcDi>t980&v$tSRy`K^EcroSWu57kT1i}u5jMh zX>xU5$~tvCUu@TdUj?>o^~OG6u{-R9RUqtK05Sz)n=WIF?lomFkO?1(FK#XwVFRDf zxla~Oif>Laa?OsuX$}+-n`8T>3K@X@?gv;(IxDH zX>18y5Fn1=P50Zre!J7p7@~r34JA9EtMBS z9o@C?Rc3d>(k2i4M*diO>yz7<&SpVYMjxgVb5U-t(CjoYQGSw=Jek?ckVm%Tx1ciEMhkavu>M*^rvDRuoxkvsX7ysyry_MqL z6~Atk=+}le=~pPvVfapmw&P0ZkzczN>aC?`+}WL-7ogGgo7@aNKrQ7)!+HCG$g;pv zH7dL(iub%*w_@hkQ{=uRR(VPY3Rmqq=CuerL3041p1z}Tv{J5x7{|u?t#XZmZ09@; zvqzmDWGd9GT5MpgtxjPiX6PNZ?mgoEHW!)9c;p5m=tBo)>A_}QCWpS0g-Hhc$vyby zZ%fQ|FFK1EhfH>ZHf)2J7rZtRo`@0h?Q3|kp0jHa48voMLu(&{UnJChzuQFA=jqs% zSXg7s%b^N!%RbG0c@SC=kQV5oW#3S#FoVj$f-?4)i^9=+xh(fjuA+1L{3Z2viww@7 zRBB_4-yDkTRNU>r+&x)4GP*vmp-jL|`1PyV#R0p2l&S|g?sX3M+i&%@OO<_ezc?HX^eO--YQJh4jHlMc z-5#%?PKVEs+&m+c&or}n^Bd@rxasAqKbI~0lcWT7v^(D@rxmtMKO~XC22;8j>A6^2 z)E1MGddX&YK?o_v?joY&H`tz>s@O0ZsjFukn?~?{oeMf*OnmHO)=C_9U z!jOG_QR~m6yRbU%n}Li9v3V}i-)7$b-clki`W)N$gHvn4;QIdD$5(BBSP6}bY&RI5 z%6I%elv?!wk3=1&6Z1u%iOchoBV>)NVj=81bdNIW*k=02;8XoCuf?$}4MB|tX2`wC z5fUV?_@y)9WAsNFtiLh&z+L$ZDlmP_*dLm0akh&ZUJK-?YSNUn7bKZg+Q3Gj%=Hq> zi`t@Z()#>$(JaLYC=)TrhfK6bGyEjdsj|N`%XhS_e}P_qoEas|F!s6eJM1Trx4TzG z5|v!2njLb!4iW$CiBF=8?aM%onnYK14iWyEdn6e*DW0LUdWdlO*<#^F`D$s z3-#taZ9!8owAHP`COv)4YeN*@L@KAxngp&Z8oX+pKT{d(qMwO7{K;9iX5O@}HN6W+ zxn(y)O*8w;jvNRhs1<}{y2D0N>7qjk7uc#~yCVJ7*oSBoWkU^+7s;fN_7h2P1gSaj z`SUi;uY}u4SsRb{QD;k$c$bRRuND^(HE#hmcNy~aKlqSD^UObsm~?3F+^=9Xz>S?7LoLtDCiTxB z_m4(@Fa13G@b+1oI>Xbwqv+(?&l9o&Ind zZ@=!H7y+A!Eu)Wy;pn2l4bYQ$5y>)GFBpqq3X@^zT)ni2(IR=uFzzP)*(nFb1S%K( zdS~5-H8|I$&wjggJ<9zZGu3_wR;+tdI&CB+>M~G<;Ke=vXU*ofoY{)#b;V|4vI>J{ zj#^$58Xi5G6{@$MO1cc*5Ll^wAI!3ss6Fdw7SpKclqgq^M;?}x4CGCeJ`D11anavj z9y8D^Bkqa(T#73C;}BnSt)H=B-&l&OK9SmMWVGq|Cy7dTYVBxO)iJANGO=oXSvxw4 zZ8K`9dFRuA4`3A6VR%DGBd5k&>=F>a>XwwK;JRjE;NVDP)~WeK(Pg4XJ^ki%VEW=b z``}$QC7@YPQBGbq_My4Eun>9ghSD=N?7gb&JCGy$-*WwqL3wc7m0jDPzSe{8W;C*O zVSI{W(r`jmx#jQgDY>dEps{5HWfyWuOwIyft^N>cC@XGi0DX%OZea3)*ratOhJe(7 zN*tg$!8DA#{F_;@O6#%`Llp9yZNBQKkD}qISNV8#_&dJBm={BPdj5KocjW zEtOCyAJ9tmSRmqUV!+-q6J<60WW`CZ!rb;j>72Oymx1NKYuu9k z$+V9YichHt*mW$(d~*8VO}{_1*Q~+p-)~djC$4`DGN!s?!SEIfM|ssZ=?*`!_IPGt z$DTE+wkhi|g0S4B%VH~=zf51aJ3$iDdZtSqVj#K{>KOrjHWPbE}&OT<#?+P{xVjhyEEj*9hDDb=7nL+(E9K936dySl1o~ zZh6#9uzv6_2i6fZDt#%f=TlFD@SmIZ#)B0qJ>i<}E*;{>0Zo0ub789xVTwEYFBZRl zQ!1!0CoN7FD(CE?Fpjz$_NJh`ampGg_WL$E3h!!Ek*gRzTjgXhudroKJo ztLCk<>0yvZQ|c|7VkVi;6z3n^yODAgzj$ z6GJS(1(mqy!$iPOU0FN!G?8X^CwwhmG4XK6xUvn;2x_Pn>w7#I^n@2LT zP_h@g-VLwwQ6l%Yw8*W)tj8KR1paohS3-DV{|uyRS8zASlf$H6jTKO{1-kdR%AlsR z<+EZMNSwz+7iqNp-dc9Y932zj+VBb)Nup4QOGI3>lfS_!e#z0WFDjO+-_{ZtzkR zKp_Hf-8WwDb=M7uy*evYWKR+^xTh)8f0ZN);mZCrFVs!XhqZ-H{M{4#*fR%hb}`LX zTr!mViS`*a|(C=sS=IpKj5_7MBp_6!6E*8&e^||^|oi3e$TnW%aJE1r9)~|21#~pR| z4mljwU1p65hXazWP?VV9*@Ou-qdD4$*v0s!dXZ3qt_Z^W17cK+HFRuLr@IsN&G{O` ztnF=P-#0JDgx(I-dRtWtlqq8Oq#vLm?1*OfHo7=7uCsZBC9p_x7zp-4`$y9&C8}z; z&W%n#+*GNI;I@H&Ob*Mad)XU)RIWK={8T9j-sSX($6WkGdX^9uj@HcRV|UBdweJ9t zn0QjbC9U%HyPQ zN(|?J>rtWAw;?onYr%r8vD-;Lhdm31?O+jBrNaSCkDof}*X&|R#6jD3NI|-XgFqT5 z!jOu(v|LDS3Z3a`y1L{IN@unZu#j}O2!?V6;o62*FF)~BTEgHNZ(zA(@O5L!~#XyEQlg_^2TDpfEB)Ym}!3Ea2SR)%F{-(nw<@sn(}xy=e! zs?Z4%$ix}96Z^w{4T#MyR-qAN6bNF+Re$Y;PrrR43)PMklpzSzNs47l_?x9ZqFA+6 zO1M2yJ__|{F~F5V7wbnNlmb3ZFzP7>`WA2`_b7JhNKPmEbN))b-00^-_Q91-Ki$$BWA_5Ua5ANPv{=Us!ajyKJ;flnCTC!cF*H3HT(W#l9|w*R?z?H-M_$Y=dAhISUl`P&U*m_0`S_5DL_U7h5YvBjKdBiQ z1)0f?N|2Jp-b}7R=JjwL&*zc!qtq6qh`#x^G4-EZ0VMFP*@N<0#Frc86ftXLPL1Lg%CUkZPUHl!a+o>{O*r^iOp2t1Bhv zO$yM`Zq>46Uo!yT0e~rw`EJfvw*5jf06TCWA#~<#!i3AWzGE#$B$RW!wjx$9|jtOAx+*#4MMdz4^hAgGdyy@j!=U599_E{_QS9{Ki=Ww zr4}K}>DCt;c_D1Dj72saKuyGxgvL3<(h1r>MwoxCadMn9|H%n>GmtJpxRjOUKQWua z+6LVoPBmVZ?Iv^}9fzK?R8Zm#QwF%aZ)3!X?r1&sHnX~obq5^wM~KYdFK*%`{-nTy z|E?bayG*yYFv$I+3i!l=Swpj@c*cWD;3i%8A?ae z?-eV>!aOMMIvVvJPnBZ3fIiZZ=e9UZIe>LT=;XUlOsr2bIb9Pf6meuDlm-J;? zA8HOQ)NvU?$O#0FX=|ki3Q|ZLS;rV>nvgPSLKGeAp#`CHS;u9{3-n=5vh-q?LcdHe zDtnk-E>u#SV{&7T3Z%6`U%&K`JD7!GCA}U@#^2#@>cbz36)GI8j@a)Z(B6C`C+h4N z(mL!!Qj6VoK0n*^pdQLn4pn?rUdUDWSbU@w2<<9gw1dM)@^<_U%9EdX@>iXoli4-P z1jPX>h2HZ=$6oriy}|~&5cgEygklCg-h`?U>3D?%+~=AShyr zhBK5a?7I+J)xSs6x2f1MTe~zIu(yf`h9vSl!$g|v(m^5Ca`#idH324Pa5S89PNQ8N=M8GxkFPrH_J0K^Vu)x zhid#n-+f8l2D5RiW^b4UUBZTs_>t~>p`NYZx<|+iG^hae`fAOBacQL0q;IV)^w{&_H3_9i{Suat7iFa(; z8TfUC$I;Gf4i_tSmwtI6xbq^Sm0c_^H%|GF`Qr-h_LlMX0eyKzGqE*??>x{mZA8CX zx9Gf8^wew5_tAjHO8WSz%zpbjRV<6a(NjgfZ)3nxnE&s`O>l252DT`i5~KWI`q^t-mxh|8+P`MQfRoWusgbEi7RdWx)!MGjnA^9r`}ZE3MTl2c?6uS z4?=CRZ~_d@m|&imtzO07h~!VM9A)vYng~DcRwJb;(zGGUYae|nzy0hbih`yWR_`P6 zE$v=yin)#r3Rat;u$2Buazz5uGC3nyxuS%lcAv3VkZ3(Ps=my0^mXdNu;bDq%)Yx3 zZB~MI7-2b?9iCcp$~j4HciAd^#*L=&5Rv&{z}jv$njuc#pGbwEq+Aew2CIQUapfzn zeivsHZA*6D?a~>{kh3o~ms5|^B ztIMfv?h5F=BEcjc+t;aK6uVJnD}T1W4>*71)ivMnyMo)A!CTx0Mjr@<97I}B>4F_^ zT;$F??bu;F-66CnAH@Y*j%v%4vHaTCSTSK*iDpjD*Xb^u`k&-RsuU$d{$g7Hc`_sLXRnWe(*+rG?ca8eTbTD3 z+Opq7bDKa#ErJ7M5LjWvD8E&Q$P-% zN)Fw~k07Kp&|o2kv497M{)8sJix-U~m}3A(3Kh@F;@!DE329x}AV@^nfwFCzX+O?+ zeC9a*I(~Y8dVbvTIZoEe{Yx4SzFMkl6-^$2MN5-43eM9viu0H5BHeba z1j1Eax3ed6zUy0G-{#|^_e}0UL-iAgY}tq2RbP+&=2Xn#p~#3}+Z855Hqh?_Tgk6O z7{0q#qlYV|JLVmxyyouE-$NhXj%~Bp-aoImJuj5&qBxSXG1kuDl;XRfFUpUQB4TzN zTeqsVKgvr*nRU#`Cb?R9ui&|!WIbEA+Pck~&&9q+u6908t-b=I zVf>j5ler@x1xLa0(dQ^s_S&cWcHhUrU3QV$UBiCScm3XaL_)u(+53Dg`)z6U)#rTN zv3Bh{-_K(*zRu^o0XjCN@A%8hV_$7*P_Z2yx4;L#uJd`1ncJm_s3xzT&TNo(=$Zu0 z43zIC<6u^KuW1*VG`@J9vQ(+2Gqi_8gcb_+9IE%mdtT@5jN&n3GsXZc1=)}14^IGa{`b_urdX-BSpY(oin;x=bx5|1ytG0D>yYOzsxqTkm$G~i@^~iDy z_Dl^Otn<;*Rofn$7|9<>m=F{>tm{-}qdM}$Gc~g$hzo=F;TIy^>s$W0u1b*QRk`x= zhqQFvtc%&z<+bYPv$5>NS9kT71DsAv>$>aS-B{oCt4r9{Z8@9U zr4EaIttYyjGe;)QJRlAeit zryRY~+v9V4v+)L>L-~w8zx-iQ@$KSlq*LX3W?5)nhi)V+HlzP@J*VgG@>!#g;UihE zYTHG~hqOIgpRv4c`uEICQ`vb^P8V}OD{vbLx4Fk$ul|T$deCg#+i9)zFSXYifyYF{EGJ}cPl(x_*DLDE;x2ARHmgJf-N)N9)wYsGjOJj3wuxL@vcE<5;{#hDXiVr5U^PC06{pzVvTr9Zpd z=lcAmY50Dm#dYf;c!mt!tNr?n3D4AY#`%Y8N<*hP(ehW{-i_~1=j2&mK~gX_r#_C~ z7H`#4EY#DTvynMkCfzUGzI*iSxEe0nKCL=bpR26s=xA;Hdb$0W_MtzVt+9vCSAzLyhj!)gZoPrz8!P87`Jyl zlUtH!*zumIPiVj7ka~>~be@*nC~!JaQ@Zu@&W_(VTZkD3l6aF|UQ6x7x%fGim^RCG zC^k6hs{yR=K_@`#1*`9ym4`8MSy zPgD7(#zpPi4K+j5E5|c`HeT%wN%|IH`j;J`_!@y}u&rI_%mP~>PQ)Tw95XLy5!L6P z(hq2H-U$=Uvq^bYjY>gI8zPIM)&P`gdOrasN6jIKiCV#dnq6zPL21LDbJvb^X!|z! z>W5K-)vQrVnsAX5!0hCe_Ml_apqO}LYNGT0G2itjhsVNxV}0j!z{-lU)oc_-?b;o4 zl!CRP^unR@)oZfj%v|g2;yI|jk5lvd1sUj|Dy_OJo@vcX95etV)6la*{*zGPZ;IFXLGLAv2P*mAT4ue}ly1@HA- zJO4Z%0d9@099ef3zm9Z9EtaUUfIm)icL@8FAaT1craGpw%_Kz$ z3wbB_*I(U=H(Ro(5i;xlVAg-!QHF$Gy^`pzznu-5tPL$vHo3#2>-WBIv94$Tqw5}29rV<1vpbqRH9W153vm&mt~O1W{@0P&Z|%MTx|5e2rJF8J zw0OtrRbIzy9%KMpzcV;u;o{rAIJbs!UCMe?Cr`8z;4+kurd$D)8V2!dIC|5|Yb&{E z-#fE+FXrlEZ3nPV6k);t<>^%k+mhpB@uRBy*A3Xf>heUF^nv$`7vDY~cUVbZ?1o4~ zUB8Nq6oI#$&Ye+}LJdp&^$TFLFCq*jUi~0C$y+}pF{A-Dytwcsiu4mXi5dA7=Zmxb zp%saRx{8rwvglM>0B#rHFq2YpkwBy7Fr*rH`eNj}-RtG^d5n~~zzzXmlgt)+*$dh6 zU2C}(OmCRMBN;Ri4Y>}$YymJMD^ty#($<)_Dzn)pS?F}_8o@=uBgj5#=H8C~^|={O zg{0#0T%525)+M)jr@zcnAc~*EA(TGQ-pz?N&V^PM${7h4xd6_)-ivy zS0A&bOTR7Tc^z+aL5}}|<+w4Cd=}trB=JnG4k@{1&nz0T>wmp9XAG#v;&_bX#^1Q# zUSHyQW?zO6f4F&l!C0So8Df2o`N6R%Wo~X^2RQzcNZrXHxvqrFS|5|%@xk1^IQP34 z&;Y=hFcOW92S+9TjReO-AyT9D*K@?*ZN$y%2Tx#wq~VPNLN#_7Xv2r=BTvz{uDa7N zx>e#NQt*yv=IL$xXzo{6)oMa}kkFoO?NhP_Zl6QMq0OyOsW&OWcvEQU7+2iuh; zzC{ru@@xav$Yt&Hvr)A6PWZ+H7*cx5D~s^!?e|wTosv%LmmSLv4^S^OI>daym+JHS zjX!nUh-Wib2R!E|$Bv}trgYqUQW2v(l=`ZCO%1z2ywJle#zx|F_uD-AsbL-7g8~%$ zkK)XYIRcaI;qMv4C4Ib~GM;|_;_=u^-}EJ=A?6`!MYz}4(D(MUvvMa- z=#kvVMGtEJJ@rIY+DmH|mj~Ej=OXxpcYGOMQWmUx2i9~LTMv9h@!P>TDP{hbVS%#E z$Qo=I(}xcjEX=$#nH7)qI-gnC#xQF|tDMfD{U2hIvZ8lam8Pn!|xl<~f zrgk^2uu_(seMay|Aw5B3`kiwm&$tUgg4!)2?>!phFP+j?d&u);wJksb$a2uAn1#2A z%-KlA%3;}WkJ5iQuvYX?rTItsw$HK44_}Y#Gx>TBDM$MaTT;|nbXpVV&8nKbr@Y&V zizQndZC!mbfa|+tW1y#Q2q?n9{1Pbwx5G)4T9LFgdi$xVok*QUp{+3VlJIU#rlXX}xUi zHM??SkUZ_ym`|ZX9jc&M=gmj6C7pbFgg6}L2?e#>%BiLzxv6=ptiObbZ3k&a?no@0 zs>eu7P)L{~-(OznuF*f?BfKw;s*WrUocza3cmFcCfevF$N$N42BV zJdL|r5))miV?+vP+;gS_(aeny$xU<|Co0#qQM<-ASR!3fo+J~bToyccdB}|Y`&H3z`B&dp z`B1X0B@*?n`E9R-Q#krSaO!Ge7E=6-le^KVEp?`R_L+Izpi3tr;aQ{>LQvk@YlX|~ z^ah4a#t@Dy%u(HbYZ;RvQ=&~Nex)B4>Z#yH3Sf=sp00Q%eL zDtp6#bw>oDE7W=1kyafMjgx3Yx#-9}@Zn5BBq#kWFWq~O)jHcy9Ggac-8ZRpR_PAD zqk4fPuZzdH=u)n5NtL8JLW2FRvk?1MKX&nH!hQVt^H<|T!pcbfsmHU#O%K0dPaoTf zCsz-p1s@FjbU*AxUG(A+HHO73zReHnOt{5A@w8vV5^dYhd*#gVz4R%8sR1me0L^g6 z47V%AlXOe1T(bYQ(OpC08qoz4w-R$k=@x<{F|L5_5=CkXpC|!!FlD2K6<4vqFV}8& zQR7=xc+6KvEvFsen4RG3Mi4)6#dgpVZ9OGPCbZJjplcXYVoXC#QUrNi2B^49+VNrZ z?Igk&yf40AQ;>Emy;$^ zmU4ggK~EJc|Il$45#o4I6HQ(!qz?h(Q~@g%0LI}sWy4$O7 z2b+i?Id(7lIKLjF37_xYkev`=wID4wK;D$(#AZ958O~$QnpM1U(|&km(&Dx#VWB{hsNk%HQ$jMD!QW(pd`ZN0orhSZZ`eBZ?k+{f{NNEqGtP!=cf9@yOOcwjVU9k0vS1He5@rV(fO);xJinLy_YoX$Y?T%5C9mNxR|p3Y0t+T%mTQtz1i%ZrP5>zr%9ZSu-aAW4m zPRJ7-w6+~`bWAcJsERLv6a1hz(0>qQ(_(V(BidQH@XU6VPX-wkPo-E~q>m*-#b=4E z-Ig0l@S#GTxE4@9?S&pa-A0@j4Fpl5PTIl1zGM|9q6zHJUzZo91iVLySjODw0I?=- zaVXRur#}w4$4{9+O=FqOf*?l^kqPN?IpSaeTh#FSHtClLXrHioKfN>%SegC_C()sH zitqYCPoX{S!ez#WVHech)KXdWu)Ck18N!R!gSfkO*5rOKi8^UVe--xQR}h1NKCTKX z1rFL}fKzn`9P&l$)s3XYQ>Y44S3qLsj;3#Xzp9C`86*?5sLCW)41jn_DF|8GWf?m? z6@TO?f2x>%?aeho=4By>!PjhYfU_~ywmvTIj^j|0F*Xx_ct9@`MxF2ZRhUK8f-Zaf z(Sa#%j~bR*{M&9Kiw(LDif_xG2vJn!SY$rHhEl!QLZtPunbw33H=Na+6>E;gAIMI0 zObCZ?CZL@MLgzE9k5yC;RJImZGc045vlfS3By2?sxEsWUU0Cuyw!Gn4pQwH2u<{Aq zQx1M$gRB~7*j)Y=4}mqOksD}W$HtjO3#c!O^(1{zr$dgV{Z#!|#C{X8?V8Je^Drhi zgiY%k2pMk~QqNRO8WNyS5LF&JAU4NaIX6Fl{~Q_tE40Xht2CM>ig-VtHt84ikjLRg zIIDQ9=nQADC_^u!@q!W%2{`B>Mc*mUlUz+R?SWuJAl)LU6dtG{8 z!}TbN`}d8wd<`>ZTJkb&^8>|_6x|9d8*E_3r!Cqxg>$YSY-k?@nM}&kuFz>Yy^&IJ zoFU$Lqj;t$1BPQ8jzy{G5|s9LG~dO(DFQ1C(|vgU=w^xZK*vaj)LArtLYz*$@>v%# z=_4kkqR@_{=bM>)@7E0%H{w~&EV}dKAmwX9bRPzWD0a!9V0%pDwG~EpQ1mi@Z}n;o zlWO4Wu{%)WeC5Q*=Snx>4t%(&0P7M?i8E`oSxXA%BykXdDt`!s$848A9DEt%y^N!= zW!X(@A`@yaKLVrYhstQT^R6l>4?t@c{cC0sG6o|%4raE_HcnKXS|DTf@`Nle=Xa6& zWrkMD5LgA#7?7U#jJg&fz>H0ngM~#Cq9pfRM6p@WG``-cT!N4)~~b)>yNdf$y0m#94xflsA2cwJf>3&O@QEjn>XxU8Pd+&C!nrk?~A|RRZ%D8lE{l zChtV8@204{jy>etrxLMv-O|1xmR7B*^U3kaNiG466%}9kz!93xhCFQ}b@ZkZDd*Hc zSy@PIa$lAl67)ix|K^WYmvo~h(kw#9eqq*U330>1w+y;i50Xg`ySfrJT?OBzl;P#+ zR7BQrkOkCUvq1LMsE1 z#0hqMghGXJ%9Ox2{M(}B#y8wu@ysS`;mRL_jn*lPB_*Z?Wl8LFhJF;1RK9A55Kdt#iX^qy z<8Ax!$y){`#(!5MtTh`246Gr4dF$3>%wy@DZq54 zI>XY3_(LUnOezC9JR}dI8ZKMAHXx-RmTw=7^8{nvCmyPw-iyF1Qh4Pjs;O=>8kJ%h z%+9izD>p*QHl4%5W@W(hm{oxTmCGhF6tB{^dl7Tls(AYNJ>z+FJ>x~x+~8H)_I2tu zPr}ou(l>|btFHYSbJT{VU3o$JwgRh0X~NX9x%v&6jaHBf1XNur^j#o6+60?i-w z>(HG?p7)U`w!k>-MHONlU%dE8{4ll_naGybUm-lWPe5ZKD=0YvyL&0}W)w%bT%T*d zuGPh12zNPvuQI>uQS}BQnD{kJFKVBdJ&%AtxiXNUh&9LrA(odgsd|eh=_|wfh?rm$ z`ZXs|U>ofLFJ6>DR>1AWD7qDGEWiA;;!x2Opd74cdW}n_SsxX;Z(a~sl{~n#df4KI z)8_tZb_|AcQRAMept)+xVVVd|$+!QJB}c_Zb9^|M8=2j}FeUcPP`;KT0X6#5$Vypf z8XRSdyK~`x1++k`-|Hn|w0x9p2>2dBj`w4_^N5fS@$>DjYU$of1jE(4ZuNaIb#@ym zIx8I3GG?eedRJ9Z7q`tYXYnMLiO3KX&S}OJH>!7Pr!Cb?P!4^TY4$0a<1Y6d#$-v` z$f}K8E%{HJrjHao10TH80E9)c`q_W>fcNo?Jto$}-zuvK6;K2S$s3j#EUJD9OT<&p8_<>@)L%fd*p$x3?61G;2lvk8r?yal)tw7dlw=Kz2zt>nuPn9%QfbW;q=T}+ z^me#*B39*81{z*a)*hu1)wORl2$jyyzZ25`GH+xW-`w-u&s|G#E8;5@Dz;PGB^itG zxk-ya+~T&iN~*7fdxs1M5QVZDmbQ9vaiPP4`QySHF52=6^R8NuOijPGcnBLH zlY9f-)ZvNL6(C$uQa^aCm1{*vxgO^@|I!V3@oIy2cUwkho5aN1Awk-{&^O>c*OrTgIzyWv<9Yo5qnM5)HW1HMWHe1!S2RkF>r&+-4$wUTVMG zt4a$W({9HSpL!?=*VSqo_z+`K;YfbvlK>KUU!cs~yU*{(P2JBr2INtTZp*19UM#bH zrYitMDz|D01WCnKr@OyE+h?pPERfXA6dpq-lu=n`V7Pv(o-L*eHu?H_aFSkDAfJ3R7f<{0V}+dkJ^4Khy{KAmidLjmiSkEuGwj&SKW`8*t-Qa zYUSMGGnj@&bl1K>i1qo1#)He{;;U zR@ON(s(#8j{U&yr;EqSxj`ztm{YF~7DD&foq;)yI5M3~p|4b!T5+{%--iM8bzn!kE zZ6RZz9p$7}21Ro<=cUEK{S+77>m+}=gPN!5->>L8BE!k2^(v**5BU9r?Sq~%JmwG^ zcY2eb#-7O8kh8OCI5)fuKZYqq!Bzd99BR?D#us{iq{_;pSAvd{xlRwbIH!>2c+s;^ zS1K!$)vyO7%d$K>J$huRKA_d11SG%tq)HoX@$U@1Pg?Uj{0dOLB!lOGDt}}CK-9i2 zWx5ii3%arx(;L(4o?fvp9aj3*V-)` z)_(>9>5*YeR(i<=0-)Az)q=S3HR!Q7O1oc2&8TwgsA^-?9d-hA&6?&60FW7x>NO$#bmr zkB{+zo=@yVGG~H)fciK2VCz-|%xOhxO9Z^Sh0;2wK4L|=L%Vah7U^{!1IvvJW!*X6 z4*6a`;Re2VwNTx-;aUJzHR@0519l^p^#(ud;wmU?Wfxru^JaORyBeu=1I9Q#GS8JS zDccnW>GoXZ$zKS4?oW4wfLa@gyNc}xhy;5gxeIhbHXj!f93ojWfWWmHk(eoE#7l0O zDhp$l>J_v{0kCLJWD00}ZX_`+f)eu28%UvyLR6OUzlIl*@NTIrj_;GrC&2RUeRNON zgf!w*94G&B5_azGd{)Vl0UXhlbm~QRPiuaxVkym)P&(LC-KZQO+Nx?a$v=3ZR|A~! zD4TBh%c=e5lS6BE!C2$uf@!DTO3AO~bUDxxkDVm|(ihgOi(94by1${7Lp7y4%NhNP z!^wK+lG51~WkFoXF$=arZ&zj2-m>Vc>MDKwz>7?L?}r>{%EwdaE5twjX>DkHZR1PP zzAbq?*<2Mssyu0bkwGRF!j|mzuE~f9)%`SyJfaiSUiuYr{5=Y4@Im|H6OyZz52} zso}`yWDMsxRBG z%wVc;%JD(~1t}$|GkK5}TaOJ?^83@6AOgnF8YOfp!sm%h$IJP3_+_T&{JNlCzp3j}i`jMKE_Nbh2db`J(Tuqqleyq~%lXf3I=aJI+M_lwR&ji_Lon`nZdU{CRxt-yfYA1C-6^lE36w$ zyG0GYS77Q3sT14LTkzt>a`!p+gM4a2V@*vDONtdtsQ3Qna~JU)P&n);2J1rTs;J6C zuJn0DkD9WV;9pG0>=tf#%-q&XX}O?r3WRsulgA1{eBs7?*&@k|%;@^OsV7HruV7JG z*aFN7ws>~iF}2-P_B@U$=QWP%t!nTEHFc9rn@V6pIjccCmA3k7EMB!1*@Ep>)0q># z`aspp0%pdJ3e5H^O5MJV)AV8jz=2{|+jtXoAZZM9u-0Y&bt&qd%%G894RIWI9c{$s zNHyLZ*QdB5>=iOTLT1HSY`E^X`^B9p+9M|Lw=~^Fcq>nxeTIx0Q=BiRkKK0Q6_v{w zUQP(&^?iymkRl^fQ^{3A*snS8U((?Ypk)@A3E#hwkjFZP@EGI)bl>j5Qk$vIT!eef z$4-W%NLE{2m{MNhL#~T&slND|Df&%`&IC6*UdfF)3aX2d|C=iR-$%HJ;)$KyOKUq@St0Y|M;Kw z|Jx#jMH9B^;((_6=bq8s2${Z9)Y|(0d8dCf{)_ncStRIYgcSd|XLkZDtUCGC4XOY6 z=6_!MA4c_$w0|e2{u3ep*5yA+{=eh#8=(Kp?f*i$zjyyY+W$iQFW`6igXI4-gZ>z> xe{c)v{@Cq*nn8cul|Syve@5W{V=wKRF}cu;iT`eW7uL Date: Tue, 4 Nov 2025 11:01:49 -0500 Subject: [PATCH 11/17] update implementation.md --- docs/entra-id-implementation.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/entra-id-implementation.md b/docs/entra-id-implementation.md index de868f7c..ab756b84 100644 --- a/docs/entra-id-implementation.md +++ b/docs/entra-id-implementation.md @@ -198,10 +198,12 @@ User groups are fetched separately using the Microsoft Graph API: def get_user_groups(self, access_token: str) -> list: ``` -**Graph API Endpoint:** `{graph_url}/v1.0/me/transitiveMemberOf/microsoft.graph.group` +**Graph API Endpoint:** `{graph_url}/v1.0/me/transitiveMemberOf/microsoft.graph.group?$count=true&$select=id,displayName` **Features:** - Fetches transitive group memberships (includes nested groups) +- Uses `$count=true` for accurate count metadata +- Uses `$select=id,displayName` to optimize the response payload - Returns group display names as a list - Automatically called by `get_user_info()` method - Handles errors gracefully (returns empty list on failure) From 2716a54edf54ef580bf383b87bf8037894eb0ae0 Mon Sep 17 00:00:00 2001 From: ryo Date: Tue, 4 Nov 2025 11:11:03 -0500 Subject: [PATCH 12/17] update the docs --- docs/configuration.md | 86 ++++------------------------------------ docs/entra-id-setup.md | 90 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 91 insertions(+), 85 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index dd19f295..1893a0f2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -127,88 +127,16 @@ cat keycloak/setup/keycloak-client-secrets.txt | `ENTRA_EMAIL_CLAIM` | JWT claim to use for email | `email,upn,preferred_username` | `email` | | `ENTRA_NAME_CLAIM` | JWT claim to use for display name | `name` | `name` | | `ENTRA_GROUPS_CLAIM` | JWT claim to use for groups | `groups` | `groups` | -**Note: Getting Entra ID Credentials** - -To obtain these credentials from Azure Portal: - -1. Navigate to [Azure Portal](https://portal.azure.com) → **Azure Active Directory** (or **Microsoft Entra ID**) -2. Go to **App registrations** → Click **New registration** -3. Configure your application: - - **Name**: `MCP Gateway Registry` - - **Supported account types**: Choose based on your needs - - Single tenant: Only accounts in your organization - - Multi-tenant: Accounts in any organizational directory - - **Redirect URI**: - - Platform: `Web` - - URL: `https://your-registry-url/auth/callback` -4. After registration, copy the **Application (client) ID** → This is your `ENTRA_CLIENT_ID` -5. Copy the **Directory (tenant) ID** → This is your `ENTRA_TENANT_ID` -6. Go to **Certificates & secrets** → **Client secrets** → **New client secret** - - Add description: `MCP Gateway Secret` - - Choose expiration period - - Copy the **Value** (not the Secret ID) → This is your `ENTRA_CLIENT_SECRET` -7. Go to **API permissions**: - - Add **Microsoft Graph** permissions: - - `User.Read` (Delegated) - Read user profile - - `openid` (Delegated) - OpenID Connect sign-in - - `profile` (Delegated) - View users' basic profile - - `email` (Delegated) - View users' email address - - Click **Grant admin consent** if you have admin rights -8. Go to **Authentication**: - - Add redirect URI: `https://your-registry-url/auth/callback` - - Under **Implicit grant and hybrid flows**: Enable **ID tokens** - - Under **Advanced settings**: Set **Allow public client flows** to `Yes` (required for device code flow) - -**Sovereign Cloud Support** - -For non-global Azure clouds, configure `ENTRA_GRAPH_URL` and `ENTRA_M2M_SCOPE`: -```bash -# US Government Cloud -ENTRA_TENANT_ID=your-tenant-id -ENTRA_GRAPH_URL=https://graph.microsoft.us -ENTRA_M2M_SCOPE=https://graph.microsoft.us/.default - -# China Cloud (operated by 21Vianet) -ENTRA_TENANT_ID=your-tenant-id -ENTRA_GRAPH_URL=https://microsoftgraph.chinacloudapi.cn -ENTRA_M2M_SCOPE=https://microsoftgraph.chinacloudapi.cn/.default - -# Germany Cloud -ENTRA_TENANT_ID=your-tenant-id -ENTRA_GRAPH_URL=https://graph.microsoft.de -ENTRA_M2M_SCOPE=https://graph.microsoft.de/.default -``` - -**Token Kind Configuration** - -The `ENTRA_TOKEN_KIND` variable determines how user information is extracted: - -**Screenshot:** -![Azure Token Kind](img/entra-token-kind.png) -```bash -# Use ID token for user info (recommended - fast, standard OIDC) -ENTRA_TOKEN_KIND=id +**Setup Instructions** -# Use access token for user info (alternative) -ENTRA_TOKEN_KIND=access -``` - -- `id` (default): Extracts user info from ID token (OpenID Connect standard, fast) -- `access`: Extracts user info from access token -- Automatic fallback to Microsoft Graph API if token extraction fails - -**Multi-Tenant Configuration** +For detailed instructions on obtaining Entra ID credentials and configuring your Azure AD app registration, see the [Microsoft Entra ID Setup Guide](entra-id-setup.md). -To support any Microsoft organizational account: - -```bash -ENTRA_TENANT_ID=common -# or for only organizational accounts (exclude personal accounts) -ENTRA_TENANT_ID=organizations -# or for only personal Microsoft accounts -ENTRA_TENANT_ID=consumers -``` +**Quick Reference:** +- **Azure Portal**: [portal.azure.com](https://portal.azure.com) → Azure Active Directory → App registrations +- **Required Permissions**: `User.Read`, `openid`, `profile`, `email` +- **Redirect URI**: `https://your-registry-url/auth/callback` +- **Sovereign Clouds**: Update both `ENTRA_GRAPH_URL` and `ENTRA_M2M_SCOPE` (see setup guide) ### Optional Variables diff --git a/docs/entra-id-setup.md b/docs/entra-id-setup.md index 419921af..27f1beb1 100644 --- a/docs/entra-id-setup.md +++ b/docs/entra-id-setup.md @@ -20,6 +20,7 @@ This guide provides step-by-step instructions for setting up Microsoft Entra ID - **Supported account types**: - For single tenant: *Accounts in this organizational directory only* - For multi-tenant: *Accounts in any organizational directory* + - (See [Multi-Tenant Configuration](#multi-tenant-setup) for detailed setup) - **Redirect URI**: - Type: **Web** - URI: `https://your-registry-domain/auth/callback` @@ -39,13 +40,15 @@ This guide provides step-by-step instructions for setting up Microsoft Entra ID 2. **Configure API Permissions** - Go to **API permissions** - Click **Add a permission** > **Microsoft Graph** > **Delegated permissions** - - Add the following permissions: + - Add the following **required** permissions: - `email` - Read user email address - `openid` - Sign users in - `profile` - Read user profile - `User.Read` - Read user's full profile + - **Optional** - For group membership retrieval, add: + - `Group.Read.All` - Read all groups (enables user group retrieval) - Click **Add permissions** - - **Grant admin consent** for the permissions + - **Grant admin consent** for the permissions (required for group permissions) ## Step 3: Create Client Secret @@ -100,24 +103,79 @@ ENTRA_EMAIL_CLAIM=email ENTRA_NAME_CLAIM=name ``` +### Token Kind Configuration + + +The `ENTRA_TOKEN_KIND` variable determines how user information is extracted: + +**Screenshot:** +![Azure Token Kind](img/entra-token-kind.png) + +- **`id` (default, recommended)**: Extracts user info from ID token + - Fast: Local JWT decoding, no network calls + - Standard: OpenID Connect standard approach + - Contains standard user claims: username, email, name, groups + +- **`access`**: Extracts user info from access token + - Used when ID token is not available + - May not contain all user claims + +- **Automatic fallback**: If token extraction fails, the system automatically falls back to Microsoft Graph API + +**Example Configuration:** +```bash +# Use ID token for user info (recommended - fast, standard OIDC) +ENTRA_TOKEN_KIND=id + +# Use access token for user info (alternative) +ENTRA_TOKEN_KIND=access +``` + +### Multi-Tenant Configuration + +To support different types of Microsoft accounts: + +```bash +# Support any Microsoft organizational account +ENTRA_TENANT_ID=common + +# Support only organizational accounts (exclude personal accounts) +ENTRA_TENANT_ID=organizations + +# Support only personal Microsoft accounts +ENTRA_TENANT_ID=consumers +``` + ### Sovereign Cloud Configuration -For non-global Azure clouds, update **both** `ENTRA_TENANT_ID` and `ENTRA_GRAPH_URL`: +For non-global Azure clouds, update **both** `ENTRA_GRAPH_URL` and `ENTRA_M2M_SCOPE`: +**US Government Cloud:** ```bash -# US Government Cloud ENTRA_TENANT_ID=your-tenant-id ENTRA_GRAPH_URL=https://graph.microsoft.us +ENTRA_M2M_SCOPE=https://graph.microsoft.us/.default +``` -# China Cloud (operated by 21Vianet) +**China Cloud (operated by 21Vianet):** +```bash ENTRA_TENANT_ID=your-tenant-id ENTRA_GRAPH_URL=https://microsoftgraph.chinacloudapi.cn +ENTRA_M2M_SCOPE=https://microsoftgraph.chinacloudapi.cn/.default +``` -# Germany Cloud +**Germany Cloud:** +```bash ENTRA_TENANT_ID=your-tenant-id ENTRA_GRAPH_URL=https://graph.microsoft.de +ENTRA_M2M_SCOPE=https://graph.microsoft.de/.default ``` +**Important Notes:** +- Ensure your app registration is in the correct cloud tenant +- Verify all OAuth endpoints (auth_url, token_url, jwks_url) match your cloud +- Update the login URLs in `auth_server/oauth2_providers.yml` for sovereign clouds + **Note**: URLs, scopes, and default claim mappings are configured in `auth_server/oauth2_providers.yml`. Environment variables for claim mappings are only needed if you want to override the defaults. ## Step 5: Enable Entra ID Provider @@ -164,9 +222,23 @@ entra_id: ## Step 7: Optional Configurations +### Group Membership Access + +To retrieve user group memberships from Azure AD, ensure the following permissions are granted: + +1. **In Azure Portal** → Your app registration → **API permissions** +2. Add **Microsoft Graph** → **Delegated permissions**: + - `Group.Read.All` - Read all groups + - Or `Directory.Read.All` - Read directory data (includes groups) +3. Click **Grant admin consent** (requires admin privileges) + +**Note**: Without these permissions, the `groups` field in user info will be empty, but authentication will still work. + ### Multi-Tenant Setup + For multi-tenant applications, set `ENTRA_TENANT_ID=common` and ensure the app registration is configured for multi-tenant access. +**Account Type Options:** ```bash # Support any Microsoft organizational account ENTRA_TENANT_ID=common @@ -178,6 +250,12 @@ ENTRA_TENANT_ID=organizations ENTRA_TENANT_ID=consumers ``` +**App Registration Configuration:** +1. Go to your app registration → **Authentication** +2. Under **Supported account types**, select: + - **Accounts in any organizational directory (Any Azure AD directory - Multitenant)** for `common` or `organizations` + - **Personal Microsoft accounts only** for `consumers` + ### Machine-to-Machine (M2M) Authentication For service accounts and automated processes: From d52e0af23c5dc959d764f99f57ff122dc6eab3ff Mon Sep 17 00:00:00 2001 From: "yulin.deng" <1016068291@qq.com> Date: Wed, 5 Nov 2025 11:04:57 +0800 Subject: [PATCH 13/17] fix import --- auth_server/providers/entra.py | 19 +++++++++++++------ auth_server/providers/factory.py | 22 ++++++---------------- auth_server/server.py | 6 +++--- docker/Dockerfile.auth | 6 +++--- registry/api/server_routes.py | 5 +++-- 5 files changed, 28 insertions(+), 30 deletions(-) diff --git a/auth_server/providers/entra.py b/auth_server/providers/entra.py index 9910a638..4de261ef 100644 --- a/auth_server/providers/entra.py +++ b/auth_server/providers/entra.py @@ -6,6 +6,7 @@ from typing import Any, Dict, Optional from urllib.parse import urlencode from .base import AuthProvider +from ..utils.config_loader import get_provider_config logging.basicConfig( level=logging.INFO, @@ -297,12 +298,19 @@ def _fetch_user_info_from_graph( response = requests.get(self.userinfo_url, headers=headers, timeout=10) response.raise_for_status() graph_data = response.json() + logger.info(f"User info fetched from Microsoft Graph API: {graph_data}") + + entra_config = get_provider_config('entra_id') or {} + + username_claim = entra_config.get('username_claim') + email_claim = entra_config.get('email_claim') + name_claim = entra_config.get('name_claim') # Map Microsoft Graph response to standard format - username = graph_data.get('userPrincipalName') - email = graph_data.get('mail') or graph_data.get('userPrincipalName') - name = graph_data.get('displayName') + username = graph_data.get(username_claim) + email = graph_data.get(email_claim) + name = graph_data.get(name_claim) or graph_data.get("DisplayName") user_info = { 'username': username, 'email': email, @@ -314,8 +322,7 @@ def _fetch_user_info_from_graph( 'office_location': graph_data.get('officeLocation'), 'groups': [] } - - logger.info(f"User info retrieved from Graph API: {username}") + logger.info(f"User info fetched from Microsoft Graph API: {user_info}") return user_info except requests.RequestException as e: @@ -402,7 +409,7 @@ def get_user_info( # Get user groups separately using access_token (required for Graph API) groups = self.get_user_groups(access_token) user_info["groups"] = groups - + logger.info(f"User info retrieved: {user_info.get('username')} with {len(groups)} groups") return user_info diff --git a/auth_server/providers/factory.py b/auth_server/providers/factory.py index 3407bdbb..b80f69d5 100644 --- a/auth_server/providers/factory.py +++ b/auth_server/providers/factory.py @@ -2,20 +2,12 @@ import logging import os -import sys -from pathlib import Path -from typing import Optional, Dict, Any - +from typing import Optional from .base import AuthProvider from .cognito import CognitoProvider from .keycloak import KeycloakProvider from .entra import EntraIDProvider - -# Add parent directory to path for utils import -parent_dir = Path(__file__).parent.parent -if str(parent_dir) not in sys.path: - sys.path.insert(0, str(parent_dir)) -from utils.config_loader import get_provider_config +from ..utils.config_loader import get_provider_config logging.basicConfig( level=logging.INFO, @@ -136,15 +128,13 @@ def _create_cognito_provider() -> CognitoProvider: def _create_entra_id_provider() -> EntraIDProvider: """Create and configure Microsoft Entra ID provider.""" # Load OAuth2 configuration using shared loader - entra_config = get_provider_config('entra_id') or {} - # Required configuration from environment variables - tenant_id = os.environ.get('ENTRA_TENANT_ID') - client_id = os.environ.get('ENTRA_CLIENT_ID') - client_secret = os.environ.get('ENTRA_CLIENT_SECRET') - # Endpoint URLs from oauth2_providers.yml (already have environment variable substitution) + tenant_id = entra_config.get("tenant_id") + client_id = entra_config.get('client_id') + client_secret = entra_config.get('client_secret') + auth_url = entra_config.get('auth_url') token_url = entra_config.get('token_url') jwks_url = entra_config.get('jwks_url') diff --git a/auth_server/server.py b/auth_server/server.py index 055422a0..22e2d293 100644 --- a/auth_server/server.py +++ b/auth_server/server.py @@ -31,13 +31,13 @@ from string import Template # Import metrics middleware -from metrics_middleware import add_auth_metrics_middleware +from .metrics_middleware import add_auth_metrics_middleware # Import provider factory -from providers.factory import get_auth_provider +from .providers.factory import get_auth_provider # Import shared configuration loader -from utils.config_loader import get_oauth2_config +from .utils.config_loader import get_oauth2_config # Configure logging logging.basicConfig( diff --git a/docker/Dockerfile.auth b/docker/Dockerfile.auth index 7f407c5e..e419bbc2 100644 --- a/docker/Dockerfile.auth +++ b/docker/Dockerfile.auth @@ -14,13 +14,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app # Copy only auth server files -COPY auth_server/ /app/ +COPY auth_server/ /app/auth_server/ # Install uv and setup Python environment RUN pip install uv && \ uv venv .venv --python 3.12 && \ . .venv/bin/activate && \ - uv pip install --requirement pyproject.toml + uv pip install --requirement /app/auth_server/pyproject.toml # Create logs directory RUN mkdir -p /app/logs @@ -33,4 +33,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ CMD curl -f http://localhost:8888/health || exit 1 # Start the auth server -CMD ["/bin/bash", "-c", "source .venv/bin/activate && uvicorn server:app --host 0.0.0.0 --port 8888"] \ No newline at end of file +CMD ["/bin/bash", "-c", "source .venv/bin/activate && cd /app && uvicorn auth_server.server:app --host 0.0.0.0 --port 8888"] \ No newline at end of file diff --git a/registry/api/server_routes.py b/registry/api/server_routes.py index 53bbba1d..aaece50c 100644 --- a/registry/api/server_routes.py +++ b/registry/api/server_routes.py @@ -184,7 +184,8 @@ async def toggle_service_route( from ..health.service import health_service from ..core.nginx_service import nginx_service from ..auth.dependencies import user_has_ui_permission_for_service - + from starlette import status + if not service_path.startswith("/"): service_path = "/" + service_path @@ -198,7 +199,7 @@ async def toggle_service_route( if not user_has_ui_permission_for_service('toggle_service', service_name, user_context.get('ui_permissions', {})): logger.warning(f"User {user_context['username']} attempted to toggle service {service_name} without toggle_service permission") raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, + status_code=status.HTTP_403_FORBIDDEN, detail=f"You do not have permission to toggle {service_name}" ) From 1a88f4a978bf9385611c17952a1cf039ea3d7ed9 Mon Sep 17 00:00:00 2001 From: "yulin.deng" <1016068291@qq.com> Date: Fri, 7 Nov 2025 21:36:00 +0800 Subject: [PATCH 14/17] fix generate_creds.sh --- credentials-provider/entra/generate_tokens.py | 604 +++++++++++++++--- credentials-provider/generate_creds.sh | 118 +++- 2 files changed, 608 insertions(+), 114 deletions(-) diff --git a/credentials-provider/entra/generate_tokens.py b/credentials-provider/entra/generate_tokens.py index 9dcd9426..9ae95b05 100644 --- a/credentials-provider/entra/generate_tokens.py +++ b/credentials-provider/entra/generate_tokens.py @@ -1,12 +1,18 @@ #!/usr/bin/env python3 """ -Generate Microsoft Entra ID (Azure AD) tokens for testing and development. +Generate Microsoft Entra ID (Azure AD) tokens for MCP agents. This script supports multiple authentication flows: 1. Client Credentials Flow (for M2M/service accounts) -2. Authorization Code Flow (requires browser interaction) +2. Device Code Flow (requires browser interaction) Usage: + # Generate tokens for all agents + python generate_tokens.py --all-agents + + # Generate token for specific agent + python generate_tokens.py --agent-name agent-my-agent + # Client credentials flow (M2M) python generate_tokens.py --tenant-id --client-id \ --client-secret --flow client_credentials @@ -22,12 +28,24 @@ """ import argparse +import glob import json +import logging import os import sys import time import requests -from typing import Dict, Any, Optional +from datetime import datetime, timezone +from typing import Dict, Any, Optional, List + + +class Colors: + """ANSI color codes for console output""" + RED = '\033[0;31m' + GREEN = '\033[0;32m' + YELLOW = '\033[1;33m' + BLUE = '\033[0;34m' + NC = '\033[0m' # No Color class EntraTokenGenerator: @@ -35,10 +53,11 @@ class EntraTokenGenerator: def __init__( self, - tenant_id: str, - client_id: str, + tenant_id: Optional[str] = None, + client_id: Optional[str] = None, client_secret: Optional[str] = None, - authority: Optional[str] = None + authority: Optional[str] = None, + verbose: bool = False ): """Initialize token generator. @@ -47,15 +66,45 @@ def __init__( client_id: Application (client) ID client_secret: Client secret (required for client credentials flow) authority: Custom authority URL (defaults to global Azure AD) + verbose: Enable verbose logging """ self.tenant_id = tenant_id self.client_id = client_id self.client_secret = client_secret - - self.authority = authority or f"https://login.microsoftonline.com/{tenant_id}" - self.token_url = f"{self.authority}/oauth2/v2.0/token" - self.device_code_url = f"{self.authority}/oauth2/v2.0/devicecode" - self.auth_url = f"{self.authority}/oauth2/v2.0/authorize" + self.verbose = verbose + self.setup_logging() + + if tenant_id: + self.authority = authority or f"https://login.microsoftonline.com/{tenant_id}" + self.token_url = f"{self.authority}/oauth2/v2.0/token" + self.device_code_url = f"{self.authority}/oauth2/v2.0/devicecode" + self.auth_url = f"{self.authority}/oauth2/v2.0/authorize" + + def setup_logging(self): + """Setup logging configuration""" + level = logging.DEBUG if self.verbose else logging.INFO + logging.basicConfig( + level=level, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + self.logger = logging.getLogger(__name__) + + def log(self, message: str): + """Log info message if verbose mode is enabled""" + if self.verbose: + print(f"{Colors.BLUE}[INFO]{Colors.NC} {message}") + + def error(self, message: str): + """Print error message""" + print(f"{Colors.RED}[ERROR]{Colors.NC} {message}", file=sys.stderr) + + def success(self, message: str): + """Print success message""" + print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {message}") + + def warning(self, message: str): + """Print warning message""" + print(f"{Colors.YELLOW}[WARNING]{Colors.NC} {message}") def get_device_code_token( self, @@ -161,13 +210,26 @@ def get_client_credentials_token( 'scope': scope } - response = requests.post(self.token_url, data=data) - response.raise_for_status() + try: + response = requests.post(self.token_url, data=data) + response.raise_for_status() - token_data = response.json() - print("✓ Token generated successfully!") + token_data = response.json() + print("✓ Token generated successfully!") - return token_data + return token_data + except requests.exceptions.HTTPError as e: + # Try to get detailed error message from response + error_detail = "No additional details" + try: + error_data = response.json() + error_detail = error_data.get('error_description', error_data.get('error', str(error_data))) + except Exception as e: + error_detail = response.text if response.text else str(e) + + self.error(f"HTTP Error: {e}") + self.error(f"Details: {error_detail}") + raise def refresh_token( self, @@ -228,15 +290,341 @@ def decode_token(self, token: str) -> Dict[str, Any]: decoded_bytes = base64.urlsafe_b64decode(payload) return json.loads(decoded_bytes) + def load_agent_config(self, agent_name: str, oauth_tokens_dir: str) -> Optional[Dict[str, Any]]: + """Load agent configuration from JSON file""" + config_file = os.path.join(oauth_tokens_dir, f"{agent_name}.json") + + if not os.path.exists(config_file): + self.error(f"Config file not found: {config_file}") + return None + + self.log(f"Loading config from: {config_file}") + + try: + with open(config_file, 'r') as f: + config = json.load(f) + return config + except json.JSONDecodeError as e: + self.error(f"Failed to parse JSON config file: {e}") + return None + except Exception as e: + self.error(f"Failed to load config file: {e}") + return None + + def find_agent_configs(self, oauth_tokens_dir: str) -> List[str]: + """Find all agent-{}.json files for Entra ID, excluding agent-{}-token.json files""" + if not os.path.exists(oauth_tokens_dir): + self.warning(f"OAuth tokens directory not found: {oauth_tokens_dir}") + return [] + + # Find all agent-*.json files + pattern = os.path.join(oauth_tokens_dir, "agent-*.json") + all_files = glob.glob(pattern) + + # Filter out token files (agent-*-token.json) and non-Entra configs + agent_configs = [] + skipped_configs = [] + + for file_path in all_files: + filename = os.path.basename(file_path) + if filename.endswith('-token.json'): + continue + + # Use the full filename without extension as agent name + agent_name = filename[:-5] # Remove '.json' (5 chars) + + # Check if this config is for Entra ID + try: + with open(file_path, 'r') as f: + config = json.load(f) + auth_provider = config.get('auth_provider', '').lower() + + # Check if config has Entra-specific fields or provider is set to entra + has_entra_fields = any([ + 'tenant_id' in config, + 'entra_tenant_id' in config, + auth_provider == 'entra', + auth_provider == 'azure', + auth_provider == 'azuread' + ]) + + # Skip if explicitly set to another provider + if auth_provider and auth_provider not in ['entra', 'azure', 'azuread', '']: + skipped_configs.append((agent_name, auth_provider)) + continue + + # Only include if it has Entra fields or no provider specified + if has_entra_fields or not auth_provider: + agent_configs.append(agent_name) + else: + skipped_configs.append((agent_name, 'unknown')) + + except (json.JSONDecodeError, Exception) as e: + self.warning(f"Failed to parse {filename}: {e}") + continue + + if skipped_configs and self.verbose: + self.log(f"Skipped {len(skipped_configs)} non-Entra configs:") + for name, provider in skipped_configs: + self.log(f" - {name} (provider: {provider})") + + return sorted(agent_configs) + + def save_token_files(self, agent_name: str, token_data: Dict[str, Any], + tenant_id: str, client_id: str, client_secret: str, + scope: str, oauth_tokens_dir: str) -> bool: + """Save token to both .env and .json files""" + access_token = token_data['access_token'] + expires_in = token_data.get('expires_in') + + # Create output directory + os.makedirs(oauth_tokens_dir, exist_ok=True) + + # Generate timestamps + generated_at = datetime.now(timezone.utc).isoformat() + expires_at = None + if expires_in: + expiry_timestamp = datetime.now(timezone.utc).timestamp() + expires_in + expires_at = datetime.fromtimestamp(expiry_timestamp, timezone.utc).isoformat() + + # Save .env file + env_file = os.path.join(oauth_tokens_dir, f"{agent_name}.env") + try: + with open(env_file, 'w') as f: + f.write(f"# Generated access token for {agent_name}\n") + f.write(f"# Generated at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + f.write(f'export ACCESS_TOKEN="{access_token}"\n') + f.write(f'export TENANT_ID="{tenant_id}"\n') + f.write(f'export CLIENT_ID="{client_id}"\n') + f.write(f'export CLIENT_SECRET="{client_secret}"\n') + f.write('export AUTH_PROVIDER="entra"\n') + except Exception as e: + self.error(f"Failed to save .env file: {e}") + return False + + # Save .json file with metadata + json_file = os.path.join(oauth_tokens_dir, f"{agent_name}-token.json") + token_json = { + "agent_name": agent_name, + "access_token": access_token, + "token_type": token_data.get("token_type", "Bearer"), + "expires_in": expires_in, + "generated_at": generated_at, + "expires_at": expires_at, + "provider": "entra", + "tenant_id": tenant_id, + "client_id": client_id, + "scope": scope, + "metadata": { + "generated_by": "generate_tokens.py", + "script_version": "1.0", + "token_format": "JWT", + "auth_method": "client_credentials" + } + } + + try: + with open(json_file, 'w') as f: + json.dump(token_json, f, indent=2) + except Exception as e: + self.error(f"Failed to save JSON file: {e}") + return False + + self.success(f"Token saved to: {env_file}") + self.success(f"Token metadata saved to: {json_file}") + + # Display token info (redacted for security) + def redact_sensitive_value(value: str, show_chars: int = 8) -> str: + if not value or len(value) <= show_chars: + return "*" * len(value) if value else "" + return value[:show_chars] + "*" * (len(value) - show_chars) + + redacted_token = redact_sensitive_value(access_token, 8) + print(f"\nAccess Token: {redacted_token}") + if expires_in: + print(f"Expires in: {expires_in} seconds") + if expires_at: + expiry_time = datetime.fromisoformat(expires_at.replace('Z', '+00:00')) + print(f"Expires at: {expiry_time.strftime('%Y-%m-%d %H:%M:%S UTC')}") + print() + + return True + + def generate_token_for_agent(self, agent_name: str, tenant_id: str = None, + client_id: str = None, client_secret: str = None, + scope: str = None, oauth_tokens_dir: str = None, + flow: str = "client_credentials") -> bool: + """Generate token for a single agent""" + if oauth_tokens_dir is None: + oauth_tokens_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + '.oauth-tokens') + + # Load config from JSON if parameters not provided + config = None + if not all([tenant_id, client_id, client_secret]): + config = self.load_agent_config(agent_name, oauth_tokens_dir) + if not config: + return False + + # Use provided parameters or fall back to config + if not tenant_id: + tenant_id = config.get('tenant_id') or config.get('entra_tenant_id') + if not client_id: + client_id = config.get('client_id') or config.get('entra_client_id') + if not client_secret: + client_secret = config.get('client_secret') or config.get('entra_client_secret') + if not scope: + scope = config.get('scope', 'https://graph.microsoft.com/.default') + + # Validate required parameters + if not tenant_id: + self.error("TENANT_ID is required. Provide via --tenant-id or in config file.") + return False + if not client_id: + self.error("CLIENT_ID is required. Provide via --client-id or in config file.") + return False + if not client_secret: + self.error("CLIENT_SECRET is required. Provide via --client-secret or in config file.") + return False + + # Update instance variables + self.tenant_id = tenant_id + self.client_id = client_id + self.client_secret = client_secret + self.authority = f"https://login.microsoftonline.com/{tenant_id}" + self.token_url = f"{self.authority}/oauth2/v2.0/token" + + print(f"Requesting access token for agent: {agent_name}") + + # Get token from Entra ID + try: + if flow == "client_credentials": + token_data = self.get_client_credentials_token(scope=scope) + elif flow == "device_code": + token_data = self.get_device_code_token(scope=scope) + else: + self.error(f"Unsupported flow: {flow}") + return False + + if not token_data: + return False + + self.success("Access token generated successfully!") + + # Save token files + return self.save_token_files(agent_name, token_data, tenant_id, client_id, + client_secret, scope, oauth_tokens_dir) + + except Exception as e: + self.error(f"Failed to generate token: {e}") + return False + + def generate_tokens_for_all_agents(self, oauth_tokens_dir: str = None, + tenant_id: str = None, scope: str = None, + flow: str = "client_credentials") -> bool: + """Generate tokens for all agents found in .oauth-tokens directory""" + if oauth_tokens_dir is None: + oauth_tokens_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + '.oauth-tokens') + + self.log(f"Searching for Entra ID agent configs in: {oauth_tokens_dir}") + + # Get all agent files first to show total + all_pattern = os.path.join(oauth_tokens_dir, "agent-*.json") + all_files = [f for f in glob.glob(all_pattern) if not f.endswith('-token.json')] + total_files = len(all_files) + + agent_configs = self.find_agent_configs(oauth_tokens_dir) + + if not agent_configs: + if total_files > 0: + self.warning( + f"No Entra ID agent configurations found (found {total_files} agent config(s) for other providers)") + self.warning("To generate tokens for Entra ID agents, ensure config files have:") + self.warning(" - 'tenant_id' field, OR") + self.warning(" - 'auth_provider': 'entra'") + else: + self.warning("No agent configuration files found in directory") + return True + + skipped_count = total_files - len(agent_configs) + if skipped_count > 0: + print(f"Skipped {skipped_count} non-Entra config(s) (use --verbose to see details)") + + self.success(f"Found {len(agent_configs)} Entra ID agent configuration(s): {', '.join(agent_configs)}") + + success_count = 0 + total_count = len(agent_configs) + + for agent_name in agent_configs: + print(f"\n{'=' * 60}") + print(f"Processing agent: {agent_name}") + print('=' * 60) + + try: + if self.generate_token_for_agent(agent_name, tenant_id=tenant_id, + scope=scope, oauth_tokens_dir=oauth_tokens_dir, + flow=flow): + success_count += 1 + else: + self.error(f"Failed to generate token for agent: {agent_name}") + except Exception as e: + self.error(f"Exception while processing agent {agent_name}: {e}") + if self.verbose: + import traceback + traceback.print_exc() + + print(f"\n{'=' * 60}") + print(f"Token generation complete: {success_count}/{total_count} successful") + print('=' * 60) + + return success_count == total_count + def main(): """Main entry point.""" parser = argparse.ArgumentParser( - description='Generate Microsoft Entra ID tokens', + description='Generate Microsoft Entra ID tokens for MCP agents', formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=__doc__ + epilog=""" +Examples: + # Generate tokens for all agents in .oauth-tokens directory + python generate_tokens.py --all-agents + + # Generate token for specific agent + python generate_tokens.py --agent-name agent-my-agent + + # Generate token with custom parameters + python generate_tokens.py --agent-name agent-my-agent --tenant-id --client-id + + # Generate tokens for all agents with custom Tenant ID + python generate_tokens.py --all-agents --tenant-id + + # Traditional usage (non-agent mode) + python generate_tokens.py --tenant-id --client-id --flow client_credentials + """ + ) + + # Agent-related arguments + parser.add_argument( + '--agent-name', + type=str, + help='Specific agent name to generate token for' ) + parser.add_argument( + '--all-agents', + action='store_true', + help='Generate tokens for all agents found in .oauth-tokens directory' + ) + + parser.add_argument( + '--oauth-dir', + type=str, + help='OAuth tokens directory (default: ../../.oauth-tokens)' + ) + + # Entra ID configuration parser.add_argument( '--tenant-id', default=os.environ.get('ENTRA_TENANT_ID'), @@ -258,13 +646,13 @@ def main(): parser.add_argument( '--flow', choices=['device_code', 'client_credentials', 'refresh'], - default='device_code', - help='Authentication flow to use (default: device_code)' + default='client_credentials', + help='Authentication flow to use (default: client_credentials for agents, device_code for legacy)' ) parser.add_argument( '--scope', - help='OAuth2 scopes (space-separated)' + help='OAuth2 scopes (space-separated or comma-separated)' ) parser.add_argument( @@ -274,7 +662,7 @@ def main(): parser.add_argument( '--output', - help='Output file path to save tokens (default: print to stdout)' + help='Output file path to save tokens (legacy mode only)' ) parser.add_argument( @@ -288,80 +676,132 @@ def main(): help='Custom authority URL (for sovereign clouds)' ) + parser.add_argument( + '--verbose', + '-v', + action='store_true', + help='Verbose output' + ) + args = parser.parse_args() - # Validate required arguments - if not args.tenant_id: - parser.error("--tenant-id is required (or set ENTRA_TENANT_ID)") + # Validate argument combinations + agent_mode = args.all_agents or args.agent_name + legacy_mode = not agent_mode - if not args.client_id: - parser.error("--client-id is required (or set ENTRA_CLIENT_ID)") + if args.all_agents and args.agent_name: + parser.error("Cannot specify both --all-agents and --agent-name") # Create token generator generator = EntraTokenGenerator( tenant_id=args.tenant_id, client_id=args.client_id, client_secret=args.client_secret, - authority=args.authority + authority=args.authority, + verbose=args.verbose ) - # Generate tokens based on flow - token_data = None + # Determine oauth tokens directory + oauth_tokens_dir = args.oauth_dir + if oauth_tokens_dir is None: + oauth_tokens_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.oauth-tokens') + try: - if args.flow == 'device_code': - scope = args.scope or "openid profile email User.Read offline_access" - token_data = generator.get_device_code_token(scope=scope) - - elif args.flow == 'client_credentials': - scope = args.scope or "https://graph.microsoft.com/.default" - token_data = generator.get_client_credentials_token(scope=scope) - - elif args.flow == 'refresh': - if not args.refresh_token: - parser.error("--refresh-token is required for refresh flow") - scope = args.scope or "openid profile email User.Read offline_access" - token_data = generator.refresh_token( - refresh_token=args.refresh_token, - scope=scope - ) - - # Add metadata - token_data['generated_at'] = time.time() - token_data['expires_at'] = time.time() + token_data.get('expires_in', 3600) - - # Decode token if requested - if args.decode and 'access_token' in token_data: - print("\n" + "=" * 70) - print("TOKEN CLAIMS") - print("=" * 70) - claims = generator.decode_token(token_data['access_token']) - print(json.dumps(claims, indent=2)) - print("=" * 70 + "\n") - - # Output tokens - if args.output: - with open(args.output, 'w') as f: - json.dump(token_data, f, indent=2) - print(f"\n✓ Tokens saved to: {args.output}") + # Agent mode - batch processing + if agent_mode: + if args.all_agents: + # Generate tokens for all agents + success = generator.generate_tokens_for_all_agents( + oauth_tokens_dir=oauth_tokens_dir, + tenant_id=args.tenant_id, + scope=args.scope, + flow=args.flow + ) + else: + # Generate token for specific agent + success = generator.generate_token_for_agent( + agent_name=args.agent_name, + tenant_id=args.tenant_id, + client_id=args.client_id, + client_secret=args.client_secret, + scope=args.scope, + oauth_tokens_dir=oauth_tokens_dir, + flow=args.flow + ) + + sys.exit(0 if success else 1) + + # Legacy mode - single token generation else: - print("\n" + "=" * 70) - print("TOKENS") - print("=" * 70) - print(json.dumps(token_data, indent=2)) - print("=" * 70) - - # Display useful information - print("\nToken Information:") - print(f" Token Type: {token_data.get('token_type', 'Bearer')}") - print(f" Expires In: {token_data.get('expires_in', 'N/A')} seconds") - if 'scope' in token_data: - print(f" Scopes: {token_data['scope']}") - - return 0 - + # Validate required arguments for legacy mode + if not args.tenant_id: + parser.error("--tenant-id is required (or set ENTRA_TENANT_ID)") + + if not args.client_id: + parser.error("--client-id is required (or set ENTRA_CLIENT_ID)") + + # Generate tokens based on flow + token_data = None + if args.flow == 'device_code': + scope = args.scope or "openid profile email User.Read offline_access" + token_data = generator.get_device_code_token(scope=scope) + + elif args.flow == 'client_credentials': + scope = args.scope or "https://graph.microsoft.com/.default" + token_data = generator.get_client_credentials_token(scope=scope) + + elif args.flow == 'refresh': + if not args.refresh_token: + parser.error("--refresh-token is required for refresh flow") + scope = args.scope or "openid profile email User.Read offline_access" + token_data = generator.refresh_token( + refresh_token=args.refresh_token, + scope=scope + ) + + # Add metadata + token_data['generated_at'] = time.time() + token_data['expires_at'] = time.time() + token_data.get('expires_in', 3600) + + # Decode token if requested + if args.decode and 'access_token' in token_data: + print("\n" + "=" * 70) + print("TOKEN CLAIMS") + print("=" * 70) + claims = generator.decode_token(token_data['access_token']) + print(json.dumps(claims, indent=2)) + print("=" * 70 + "\n") + + # Output tokens + if args.output: + with open(args.output, 'w') as f: + json.dump(token_data, f, indent=2) + print(f"\n✓ Tokens saved to: {args.output}") + else: + print("\n" + "=" * 70) + print("TOKENS") + print("=" * 70) + print(json.dumps(token_data, indent=2)) + print("=" * 70) + + # Display useful information + print("\nToken Information:") + print(f" Token Type: {token_data.get('token_type', 'Bearer')}") + print(f" Expires In: {token_data.get('expires_in', 'N/A')} seconds") + if 'scope' in token_data: + print(f" Scopes: {token_data['scope']}") + + return 0 + + except KeyboardInterrupt: + generator.warning("Operation interrupted by user") + sys.exit(1) except Exception as e: - print(f"\n✗ Error: {e}", file=sys.stderr) - return 1 + generator.error(f"Unexpected error: {e}") + if args.verbose: + import traceback + traceback.print_exc() + sys.exit(1) if __name__ == '__main__': diff --git a/credentials-provider/generate_creds.sh b/credentials-provider/generate_creds.sh index f8c3f9e2..de3743bd 100755 --- a/credentials-provider/generate_creds.sh +++ b/credentials-provider/generate_creds.sh @@ -2,25 +2,32 @@ # # OAuth Credentials Orchestrator Script # -# This script orchestrates OAuth authentication for both ingress and egress flows, -# and generates MCP configuration files for VS Code and Roocode. +# This script orchestrates OAuth authentication for multiple flows and generates +# MCP configuration files for VS Code and Roocode. # -# Default behavior: Run both ingress and egress authentication flows -# - ingress: Cognito M2M authentication for MCP Gateway -# - egress: External provider authentication (default: Atlassian) +# Supported authentication types: +# - ingress: Cognito/Entra M2M authentication for MCP Gateway +# - egress: External provider authentication (Atlassian, Google, GitHub, etc.) +# - agentcore: AWS Bedrock AgentCore gateway authentication +# - keycloak: Keycloak agent token generation (batch mode) +# - entra: Microsoft Entra ID agent token generation (batch mode) # -# If both are requested and ingress fails, the script stops (egress won't run). -# If only egress is requested and it fails, the script continues to generate configs. +# Default behavior: Run all authentication types +# If ingress fails when multiple types are requested, the script stops. +# If other types fail, the script continues and generates configs with available tokens. # # Usage: -# ./oauth_creds.sh # Run both ingress and egress (default) -# ./oauth_creds.sh --ingress-only # Run only ingress authentication -# ./oauth_creds.sh --egress-only # Run only egress authentication -# ./oauth_creds.sh --both # Explicitly run both (same as default) -# ./oauth_creds.sh --provider google # Run both with Google as egress provider -# ./oauth_creds.sh --verbose # Enable verbose logging -# ./oauth_creds.sh --force # Force new token generation -# ./oauth_creds.sh --help # Show this help +# ./generate_creds.sh # Run all authentication types (default) +# ./generate_creds.sh --ingress-only # Run only ingress authentication +# ./generate_creds.sh --egress-only # Run only egress authentication +# ./generate_creds.sh --agentcore-only # Run only AgentCore token generation +# ./generate_creds.sh --keycloak-only # Run only Keycloak agent tokens +# ./generate_creds.sh --entra-only # Run only Entra ID agent tokens +# ./generate_creds.sh --both # Run only ingress and egress +# ./generate_creds.sh --all # Explicitly run all (same as default) +# ./generate_creds.sh --provider google # Run all with Google as egress provider +# ./generate_creds.sh --verbose # Enable verbose logging +# ./generate_creds.sh --help # Show this help set -e # Exit on error @@ -52,6 +59,7 @@ RUN_INGRESS=true RUN_EGRESS=true RUN_AGENTCORE=true RUN_KEYCLOAK=true +RUN_ENTRA=true # Read provider and server name from environment variables with defaults EGRESS_PROVIDER="${EGRESS_PROVIDER_NAME:-atlassian}" EGRESS_MCP_SERVER_NAME="${EGRESS_MCP_SERVER_NAME:-}" @@ -99,8 +107,9 @@ OPTIONS: --egress-only Run only egress authentication (external providers) --agentcore-only Run only AgentCore token generation --keycloak-only Run only Keycloak agent token generation - --both Run only ingress and egress (excludes agentcore and keycloak) - --all Run ingress, egress, agentcore, and keycloak authentication + --entra-only Run only Entra ID agent token generation + --both Run only ingress and egress (excludes agentcore, keycloak, and entra) + --all Run all authentication types (ingress, egress, agentcore, keycloak, and entra) --provider PROVIDER Specify egress provider (default: atlassian) Supported: atlassian, google, github, microsoft, etc. --force, -f Force new token generation, ignore existing tokens @@ -113,14 +122,15 @@ EXAMPLES: ./generate_creds.sh --egress-only # Only external provider authentication ./generate_creds.sh --agentcore-only # Only AgentCore token generation ./generate_creds.sh --keycloak-only # Only Keycloak agent token generation - ./generate_creds.sh --both # Run only ingress and egress (no agentcore/keycloak) + ./generate_creds.sh --entra-only # Only Entra ID agent token generation + ./generate_creds.sh --both # Run only ingress and egress (no agentcore/keycloak/entra) ./generate_creds.sh --provider google # All flows with Google as egress ./generate_creds.sh --force --verbose # Force new tokens with debug output BEHAVIOR: - - Default: Runs all four authentication types (ingress, egress, agentcore, and keycloak) + - Default: Runs all authentication types (ingress, egress, agentcore, keycloak, and entra) - If multiple are requested and ingress fails → script stops - - If egress, agentcore, or keycloak fails → continues with remaining tasks and config generation + - If egress, agentcore, keycloak, or entra fails → continues with remaining tasks and config generation - Always attempts to generate MCP configuration files with available tokens - Summary shows clear pass/fail status for each authentication type @@ -148,6 +158,7 @@ while [[ $# -gt 0 ]]; do RUN_EGRESS=false RUN_AGENTCORE=false RUN_KEYCLOAK=false + RUN_ENTRA=false shift ;; --egress-only) @@ -155,6 +166,7 @@ while [[ $# -gt 0 ]]; do RUN_EGRESS=true RUN_AGENTCORE=false RUN_KEYCLOAK=false + RUN_ENTRA=false shift ;; --agentcore-only) @@ -162,6 +174,7 @@ while [[ $# -gt 0 ]]; do RUN_EGRESS=false RUN_AGENTCORE=true RUN_KEYCLOAK=false + RUN_ENTRA=false shift ;; --keycloak-only) @@ -169,6 +182,15 @@ while [[ $# -gt 0 ]]; do RUN_EGRESS=false RUN_AGENTCORE=false RUN_KEYCLOAK=true + RUN_ENTRA=false + shift + ;; + --entra-only) + RUN_INGRESS=false + RUN_EGRESS=false + RUN_AGENTCORE=false + RUN_KEYCLOAK=false + RUN_ENTRA=true shift ;; --both) @@ -176,6 +198,7 @@ while [[ $# -gt 0 ]]; do RUN_EGRESS=true RUN_AGENTCORE=false RUN_KEYCLOAK=false + RUN_ENTRA=false shift ;; --all) @@ -183,6 +206,7 @@ while [[ $# -gt 0 ]]; do RUN_EGRESS=true RUN_AGENTCORE=true RUN_KEYCLOAK=true + RUN_ENTRA=true shift ;; --provider) @@ -215,9 +239,6 @@ run_ingress_auth() { local cmd="python '$SCRIPT_DIR/oauth/ingress_oauth.py'" - if [ "$FORCE" = true ]; then - cmd="$cmd --force" - fi if [ "$VERBOSE" = true ]; then cmd="$cmd --verbose" @@ -245,9 +266,6 @@ run_egress_auth() { cmd="$cmd --mcp-server-name '$EGRESS_MCP_SERVER_NAME'" fi - if [ "$FORCE" = true ]; then - cmd="$cmd --force" - fi if [ "$VERBOSE" = true ]; then cmd="$cmd --verbose" @@ -268,11 +286,8 @@ run_egress_auth() { run_agentcore_auth() { log_info " Running AgentCore token generation..." - local cmd="python '$SCRIPT_DIR/agentcore-auth/generate_access_token.py'" + local cmd="python '$SCRIPT_DIR/agentcore-auth/generate_access_token.py' --all" - if [ "$FORCE" = true ]; then - cmd="$cmd --force" - fi if [ "$VERBOSE" = true ]; then cmd="$cmd --debug" @@ -310,6 +325,27 @@ run_keycloak_auth() { fi } +# Function to run Entra ID agent token generation +run_entra_auth() { + log_info " Running Entra ID agent token generation..." + + local cmd="python '$SCRIPT_DIR/entra/generate_tokens.py' --all-agents" + + if [ "$VERBOSE" = true ]; then + cmd="$cmd --verbose" + fi + + log_debug "Executing: $cmd" + + if eval "$cmd"; then + log_info "✅ Entra ID agent token generation completed successfully" + return 0 + else + log_error "❌ Entra ID agent token generation failed" + return 1 + fi +} + # Function to generate MCP configuration files generate_mcp_configs() { log_info " Generating MCP configuration files..." @@ -620,12 +656,13 @@ add_noauth_services() { # Main execution main() { log_info " Starting OAuth Credentials Orchestrator" - log_info "Configuration: ingress=$RUN_INGRESS, egress=$RUN_EGRESS (provider=$EGRESS_PROVIDER), agentcore=$RUN_AGENTCORE, keycloak=$RUN_KEYCLOAK" + log_info "Configuration: ingress=$RUN_INGRESS, egress=$RUN_EGRESS (provider=$EGRESS_PROVIDER), agentcore=$RUN_AGENTCORE, keycloak=$RUN_KEYCLOAK, entra=$RUN_ENTRA" local ingress_success=false local egress_success=false local agentcore_success=false local keycloak_success=false + local entra_success=false # Run ingress authentication if requested if [ "$RUN_INGRESS" = true ]; then @@ -633,7 +670,7 @@ main() { ingress_success=true else # If multiple are requested and ingress fails, stop here - if [ "$RUN_EGRESS" = true ] || [ "$RUN_AGENTCORE" = true ] || [ "$RUN_KEYCLOAK" = true ]; then + if [ "$RUN_EGRESS" = true ] || [ "$RUN_AGENTCORE" = true ] || [ "$RUN_KEYCLOAK" = true ] || [ "$RUN_ENTRA" = true ]; then log_error "Ingress authentication failed. Stopping before other authentication types (as multiple were requested)." exit 1 fi @@ -666,6 +703,15 @@ main() { log_warn "Keycloak authentication failed, but continuing to generate configs" fi fi + + # Run Entra ID authentication if requested + if [ "$RUN_ENTRA" = true ]; then + if run_entra_auth; then + entra_success=true + else + log_warn "Entra ID authentication failed, but continuing to generate configs" + fi + fi # Generate MCP configuration files generate_mcp_configs @@ -703,6 +749,14 @@ main() { log_info " ❌ Keycloak authentication: FAILED" fi fi + + if [ "$RUN_ENTRA" = true ]; then + if [ "$entra_success" = true ]; then + log_info " ✅ Entra ID authentication: SUCCESS" + else + log_info " ❌ Entra ID authentication: FAILED" + fi + fi log_info " OAuth credentials orchestration completed!" log_info " Check ./.oauth-tokens/ for generated token and config files" From 4724e48681d7cccaaf46dac8a29a9c7dd0ab2e4b Mon Sep 17 00:00:00 2001 From: "yulin.deng" <1016068291@qq.com> Date: Sat, 15 Nov 2025 00:28:30 +0800 Subject: [PATCH 15/17] fix --- credentials-provider/entra/generate_tokens.py | 802 +----------------- credentials-provider/oauth/ingress_oauth.py | 130 ++- 2 files changed, 130 insertions(+), 802 deletions(-) diff --git a/credentials-provider/entra/generate_tokens.py b/credentials-provider/entra/generate_tokens.py index 9ae95b05..9f86c1eb 100644 --- a/credentials-provider/entra/generate_tokens.py +++ b/credentials-provider/entra/generate_tokens.py @@ -1,808 +1,12 @@ #!/usr/bin/env python3 """ -Generate Microsoft Entra ID (Azure AD) tokens for MCP agents. +Microsoft Entra ID Token Generator -This script supports multiple authentication flows: -1. Client Credentials Flow (for M2M/service accounts) -2. Device Code Flow (requires browser interaction) - -Usage: - # Generate tokens for all agents - python generate_tokens.py --all-agents - - # Generate token for specific agent - python generate_tokens.py --agent-name agent-my-agent - - # Client credentials flow (M2M) - python generate_tokens.py --tenant-id --client-id \ - --client-secret --flow client_credentials - - # Save tokens to file - python generate_tokens.py --tenant-id --client-id \ - --output tokens.json - -Environment Variables: - ENTRA_TENANT_ID: Azure AD tenant ID - ENTRA_CLIENT_ID: Application (client) ID - ENTRA_CLIENT_SECRET: Client secret (for client credentials flow) """ -import argparse -import glob -import json -import logging -import os -import sys -import time -import requests -from datetime import datetime, timezone -from typing import Dict, Any, Optional, List - - -class Colors: - """ANSI color codes for console output""" - RED = '\033[0;31m' - GREEN = '\033[0;32m' - YELLOW = '\033[1;33m' - BLUE = '\033[0;34m' - NC = '\033[0m' # No Color - - -class EntraTokenGenerator: - """Generate tokens from Microsoft Entra ID.""" - - def __init__( - self, - tenant_id: Optional[str] = None, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, - authority: Optional[str] = None, - verbose: bool = False - ): - """Initialize token generator. - - Args: - tenant_id: Azure AD tenant ID - client_id: Application (client) ID - client_secret: Client secret (required for client credentials flow) - authority: Custom authority URL (defaults to global Azure AD) - verbose: Enable verbose logging - """ - self.tenant_id = tenant_id - self.client_id = client_id - self.client_secret = client_secret - self.verbose = verbose - self.setup_logging() - - if tenant_id: - self.authority = authority or f"https://login.microsoftonline.com/{tenant_id}" - self.token_url = f"{self.authority}/oauth2/v2.0/token" - self.device_code_url = f"{self.authority}/oauth2/v2.0/devicecode" - self.auth_url = f"{self.authority}/oauth2/v2.0/authorize" - - def setup_logging(self): - """Setup logging configuration""" - level = logging.DEBUG if self.verbose else logging.INFO - logging.basicConfig( - level=level, - format='%(asctime)s - %(levelname)s - %(message)s' - ) - self.logger = logging.getLogger(__name__) - - def log(self, message: str): - """Log info message if verbose mode is enabled""" - if self.verbose: - print(f"{Colors.BLUE}[INFO]{Colors.NC} {message}") - - def error(self, message: str): - """Print error message""" - print(f"{Colors.RED}[ERROR]{Colors.NC} {message}", file=sys.stderr) - - def success(self, message: str): - """Print success message""" - print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {message}") - - def warning(self, message: str): - """Print warning message""" - print(f"{Colors.YELLOW}[WARNING]{Colors.NC} {message}") - - def get_device_code_token( - self, - scope: str = "openid profile email User.Read offline_access" - ) -> Dict[str, Any]: - """Get tokens using device code flow (interactive). - - This is the recommended flow for CLI tools and testing. - User will need to visit a URL and enter a code. - - Args: - scope: OAuth2 scopes to request - - Returns: - Dictionary containing access_token, refresh_token, etc. - """ - print("Starting device code flow...") - - # Request device code - data = { - 'client_id': self.client_id, - 'scope': scope - } - - response = requests.post(self.device_code_url, data=data) - response.raise_for_status() - device_code_data = response.json() - - # Display instructions to user - print("\n" + "=" * 70) - print("DEVICE CODE AUTHENTICATION") - print("=" * 70) - print(f"\n1. Visit: {device_code_data['verification_uri']}") - print(f"2. Enter code: {device_code_data['user_code']}") - print(f"\nWaiting for authentication (expires in {device_code_data['expires_in']} seconds)...") - print("=" * 70 + "\n") - - # Poll for token - device_code = device_code_data['device_code'] - interval = device_code_data.get('interval', 5) - expires_in = device_code_data['expires_in'] - start_time = time.time() - - while True: - if time.time() - start_time > expires_in: - raise TimeoutError("Device code expired before user completed authentication") - - time.sleep(interval) - - token_data = { - 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', - 'device_code': device_code, - 'client_id': self.client_id - } - - # Add client_secret if available (required for confidential clients) - if self.client_secret: - token_data['client_secret'] = self.client_secret - - response = requests.post(self.token_url, data=token_data) - result = response.json() - - if response.status_code == 200: - print("✓ Authentication successful!") - return result - - error = result.get('error') - if error == 'authorization_pending': - print(".", end="", flush=True) - continue - elif error == 'slow_down': - interval += 5 - continue - elif error in ['authorization_declined', 'bad_verification_code', 'expired_token']: - raise ValueError(f"Authentication failed: {error}") - else: - raise ValueError(f"Unexpected error: {error} - {result.get('error_description')}") - - def get_client_credentials_token( - self, - scope: str = "https://graph.microsoft.com/.default" - ) -> Dict[str, Any]: - """Get token using client credentials flow (M2M). - - This flow is for service-to-service authentication. - Requires client_secret to be set. - - Args: - scope: OAuth2 scope (use .default for all configured permissions) - - Returns: - Dictionary containing access_token - """ - if not self.client_secret: - raise ValueError("Client secret is required for client credentials flow") - - print("Requesting token using client credentials flow...") - - data = { - 'grant_type': 'client_credentials', - 'client_id': self.client_id, - 'client_secret': self.client_secret, - 'scope': scope - } - - try: - response = requests.post(self.token_url, data=data) - response.raise_for_status() - - token_data = response.json() - print("✓ Token generated successfully!") - - return token_data - except requests.exceptions.HTTPError as e: - # Try to get detailed error message from response - error_detail = "No additional details" - try: - error_data = response.json() - error_detail = error_data.get('error_description', error_data.get('error', str(error_data))) - except Exception as e: - error_detail = response.text if response.text else str(e) - - self.error(f"HTTP Error: {e}") - self.error(f"Details: {error_detail}") - raise - - def refresh_token( - self, - refresh_token: str, - scope: str = "openid profile email User.Read offline_access" - ) -> Dict[str, Any]: - """Refresh an access token. - - Args: - refresh_token: The refresh token - scope: OAuth2 scopes to request - - Returns: - Dictionary containing new access_token and refresh_token - """ - print("Refreshing access token...") - - data = { - 'grant_type': 'refresh_token', - 'refresh_token': refresh_token, - 'client_id': self.client_id, - 'scope': scope - } - - if self.client_secret: - data['client_secret'] = self.client_secret - - response = requests.post(self.token_url, data=data) - response.raise_for_status() - - token_data = response.json() - print("✓ Token refreshed successfully!") - - return token_data - - def decode_token(self, token: str) -> Dict[str, Any]: - """Decode JWT token (without validation) to inspect claims. - - Args: - token: JWT token string - - Returns: - Dictionary containing token claims - """ - import base64 - - # Split token into parts - parts = token.split('.') - if len(parts) != 3: - raise ValueError("Invalid JWT token format") - - # Decode payload (add padding if needed) - payload = parts[1] - padding = 4 - len(payload) % 4 - if padding != 4: - payload += '=' * padding - - decoded_bytes = base64.urlsafe_b64decode(payload) - return json.loads(decoded_bytes) - - def load_agent_config(self, agent_name: str, oauth_tokens_dir: str) -> Optional[Dict[str, Any]]: - """Load agent configuration from JSON file""" - config_file = os.path.join(oauth_tokens_dir, f"{agent_name}.json") - - if not os.path.exists(config_file): - self.error(f"Config file not found: {config_file}") - return None - - self.log(f"Loading config from: {config_file}") - - try: - with open(config_file, 'r') as f: - config = json.load(f) - return config - except json.JSONDecodeError as e: - self.error(f"Failed to parse JSON config file: {e}") - return None - except Exception as e: - self.error(f"Failed to load config file: {e}") - return None - - def find_agent_configs(self, oauth_tokens_dir: str) -> List[str]: - """Find all agent-{}.json files for Entra ID, excluding agent-{}-token.json files""" - if not os.path.exists(oauth_tokens_dir): - self.warning(f"OAuth tokens directory not found: {oauth_tokens_dir}") - return [] - - # Find all agent-*.json files - pattern = os.path.join(oauth_tokens_dir, "agent-*.json") - all_files = glob.glob(pattern) - - # Filter out token files (agent-*-token.json) and non-Entra configs - agent_configs = [] - skipped_configs = [] - - for file_path in all_files: - filename = os.path.basename(file_path) - if filename.endswith('-token.json'): - continue - - # Use the full filename without extension as agent name - agent_name = filename[:-5] # Remove '.json' (5 chars) - - # Check if this config is for Entra ID - try: - with open(file_path, 'r') as f: - config = json.load(f) - auth_provider = config.get('auth_provider', '').lower() - - # Check if config has Entra-specific fields or provider is set to entra - has_entra_fields = any([ - 'tenant_id' in config, - 'entra_tenant_id' in config, - auth_provider == 'entra', - auth_provider == 'azure', - auth_provider == 'azuread' - ]) - - # Skip if explicitly set to another provider - if auth_provider and auth_provider not in ['entra', 'azure', 'azuread', '']: - skipped_configs.append((agent_name, auth_provider)) - continue - - # Only include if it has Entra fields or no provider specified - if has_entra_fields or not auth_provider: - agent_configs.append(agent_name) - else: - skipped_configs.append((agent_name, 'unknown')) - - except (json.JSONDecodeError, Exception) as e: - self.warning(f"Failed to parse {filename}: {e}") - continue - - if skipped_configs and self.verbose: - self.log(f"Skipped {len(skipped_configs)} non-Entra configs:") - for name, provider in skipped_configs: - self.log(f" - {name} (provider: {provider})") - - return sorted(agent_configs) - - def save_token_files(self, agent_name: str, token_data: Dict[str, Any], - tenant_id: str, client_id: str, client_secret: str, - scope: str, oauth_tokens_dir: str) -> bool: - """Save token to both .env and .json files""" - access_token = token_data['access_token'] - expires_in = token_data.get('expires_in') - - # Create output directory - os.makedirs(oauth_tokens_dir, exist_ok=True) - - # Generate timestamps - generated_at = datetime.now(timezone.utc).isoformat() - expires_at = None - if expires_in: - expiry_timestamp = datetime.now(timezone.utc).timestamp() + expires_in - expires_at = datetime.fromtimestamp(expiry_timestamp, timezone.utc).isoformat() - - # Save .env file - env_file = os.path.join(oauth_tokens_dir, f"{agent_name}.env") - try: - with open(env_file, 'w') as f: - f.write(f"# Generated access token for {agent_name}\n") - f.write(f"# Generated at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") - f.write(f'export ACCESS_TOKEN="{access_token}"\n') - f.write(f'export TENANT_ID="{tenant_id}"\n') - f.write(f'export CLIENT_ID="{client_id}"\n') - f.write(f'export CLIENT_SECRET="{client_secret}"\n') - f.write('export AUTH_PROVIDER="entra"\n') - except Exception as e: - self.error(f"Failed to save .env file: {e}") - return False - - # Save .json file with metadata - json_file = os.path.join(oauth_tokens_dir, f"{agent_name}-token.json") - token_json = { - "agent_name": agent_name, - "access_token": access_token, - "token_type": token_data.get("token_type", "Bearer"), - "expires_in": expires_in, - "generated_at": generated_at, - "expires_at": expires_at, - "provider": "entra", - "tenant_id": tenant_id, - "client_id": client_id, - "scope": scope, - "metadata": { - "generated_by": "generate_tokens.py", - "script_version": "1.0", - "token_format": "JWT", - "auth_method": "client_credentials" - } - } - - try: - with open(json_file, 'w') as f: - json.dump(token_json, f, indent=2) - except Exception as e: - self.error(f"Failed to save JSON file: {e}") - return False - - self.success(f"Token saved to: {env_file}") - self.success(f"Token metadata saved to: {json_file}") - - # Display token info (redacted for security) - def redact_sensitive_value(value: str, show_chars: int = 8) -> str: - if not value or len(value) <= show_chars: - return "*" * len(value) if value else "" - return value[:show_chars] + "*" * (len(value) - show_chars) - - redacted_token = redact_sensitive_value(access_token, 8) - print(f"\nAccess Token: {redacted_token}") - if expires_in: - print(f"Expires in: {expires_in} seconds") - if expires_at: - expiry_time = datetime.fromisoformat(expires_at.replace('Z', '+00:00')) - print(f"Expires at: {expiry_time.strftime('%Y-%m-%d %H:%M:%S UTC')}") - print() - - return True - - def generate_token_for_agent(self, agent_name: str, tenant_id: str = None, - client_id: str = None, client_secret: str = None, - scope: str = None, oauth_tokens_dir: str = None, - flow: str = "client_credentials") -> bool: - """Generate token for a single agent""" - if oauth_tokens_dir is None: - oauth_tokens_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), - '.oauth-tokens') - - # Load config from JSON if parameters not provided - config = None - if not all([tenant_id, client_id, client_secret]): - config = self.load_agent_config(agent_name, oauth_tokens_dir) - if not config: - return False - - # Use provided parameters or fall back to config - if not tenant_id: - tenant_id = config.get('tenant_id') or config.get('entra_tenant_id') - if not client_id: - client_id = config.get('client_id') or config.get('entra_client_id') - if not client_secret: - client_secret = config.get('client_secret') or config.get('entra_client_secret') - if not scope: - scope = config.get('scope', 'https://graph.microsoft.com/.default') - - # Validate required parameters - if not tenant_id: - self.error("TENANT_ID is required. Provide via --tenant-id or in config file.") - return False - if not client_id: - self.error("CLIENT_ID is required. Provide via --client-id or in config file.") - return False - if not client_secret: - self.error("CLIENT_SECRET is required. Provide via --client-secret or in config file.") - return False - - # Update instance variables - self.tenant_id = tenant_id - self.client_id = client_id - self.client_secret = client_secret - self.authority = f"https://login.microsoftonline.com/{tenant_id}" - self.token_url = f"{self.authority}/oauth2/v2.0/token" - - print(f"Requesting access token for agent: {agent_name}") - - # Get token from Entra ID - try: - if flow == "client_credentials": - token_data = self.get_client_credentials_token(scope=scope) - elif flow == "device_code": - token_data = self.get_device_code_token(scope=scope) - else: - self.error(f"Unsupported flow: {flow}") - return False - - if not token_data: - return False - - self.success("Access token generated successfully!") - - # Save token files - return self.save_token_files(agent_name, token_data, tenant_id, client_id, - client_secret, scope, oauth_tokens_dir) - - except Exception as e: - self.error(f"Failed to generate token: {e}") - return False - - def generate_tokens_for_all_agents(self, oauth_tokens_dir: str = None, - tenant_id: str = None, scope: str = None, - flow: str = "client_credentials") -> bool: - """Generate tokens for all agents found in .oauth-tokens directory""" - if oauth_tokens_dir is None: - oauth_tokens_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), - '.oauth-tokens') - - self.log(f"Searching for Entra ID agent configs in: {oauth_tokens_dir}") - - # Get all agent files first to show total - all_pattern = os.path.join(oauth_tokens_dir, "agent-*.json") - all_files = [f for f in glob.glob(all_pattern) if not f.endswith('-token.json')] - total_files = len(all_files) - - agent_configs = self.find_agent_configs(oauth_tokens_dir) - - if not agent_configs: - if total_files > 0: - self.warning( - f"No Entra ID agent configurations found (found {total_files} agent config(s) for other providers)") - self.warning("To generate tokens for Entra ID agents, ensure config files have:") - self.warning(" - 'tenant_id' field, OR") - self.warning(" - 'auth_provider': 'entra'") - else: - self.warning("No agent configuration files found in directory") - return True - - skipped_count = total_files - len(agent_configs) - if skipped_count > 0: - print(f"Skipped {skipped_count} non-Entra config(s) (use --verbose to see details)") - - self.success(f"Found {len(agent_configs)} Entra ID agent configuration(s): {', '.join(agent_configs)}") - - success_count = 0 - total_count = len(agent_configs) - - for agent_name in agent_configs: - print(f"\n{'=' * 60}") - print(f"Processing agent: {agent_name}") - print('=' * 60) - - try: - if self.generate_token_for_agent(agent_name, tenant_id=tenant_id, - scope=scope, oauth_tokens_dir=oauth_tokens_dir, - flow=flow): - success_count += 1 - else: - self.error(f"Failed to generate token for agent: {agent_name}") - except Exception as e: - self.error(f"Exception while processing agent {agent_name}: {e}") - if self.verbose: - import traceback - traceback.print_exc() - - print(f"\n{'=' * 60}") - print(f"Token generation complete: {success_count}/{total_count} successful") - print('=' * 60) - - return success_count == total_count - - def main(): - """Main entry point.""" - parser = argparse.ArgumentParser( - description='Generate Microsoft Entra ID tokens for MCP agents', - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - # Generate tokens for all agents in .oauth-tokens directory - python generate_tokens.py --all-agents - - # Generate token for specific agent - python generate_tokens.py --agent-name agent-my-agent - - # Generate token with custom parameters - python generate_tokens.py --agent-name agent-my-agent --tenant-id --client-id - - # Generate tokens for all agents with custom Tenant ID - python generate_tokens.py --all-agents --tenant-id - - # Traditional usage (non-agent mode) - python generate_tokens.py --tenant-id --client-id --flow client_credentials - """ - ) - - # Agent-related arguments - parser.add_argument( - '--agent-name', - type=str, - help='Specific agent name to generate token for' - ) - - parser.add_argument( - '--all-agents', - action='store_true', - help='Generate tokens for all agents found in .oauth-tokens directory' - ) - - parser.add_argument( - '--oauth-dir', - type=str, - help='OAuth tokens directory (default: ../../.oauth-tokens)' - ) - - # Entra ID configuration - parser.add_argument( - '--tenant-id', - default=os.environ.get('ENTRA_TENANT_ID'), - help='Azure AD tenant ID (or env: ENTRA_TENANT_ID)' - ) - - parser.add_argument( - '--client-id', - default=os.environ.get('ENTRA_CLIENT_ID'), - help='Application (client) ID (or env: ENTRA_CLIENT_ID)' - ) - - parser.add_argument( - '--client-secret', - default=os.environ.get('ENTRA_CLIENT_SECRET'), - help='Client secret (or env: ENTRA_CLIENT_SECRET)' - ) - - parser.add_argument( - '--flow', - choices=['device_code', 'client_credentials', 'refresh'], - default='client_credentials', - help='Authentication flow to use (default: client_credentials for agents, device_code for legacy)' - ) - - parser.add_argument( - '--scope', - help='OAuth2 scopes (space-separated or comma-separated)' - ) - - parser.add_argument( - '--refresh-token', - help='Refresh token (for refresh flow)' - ) - - parser.add_argument( - '--output', - help='Output file path to save tokens (legacy mode only)' - ) - - parser.add_argument( - '--decode', - action='store_true', - help='Decode and display token claims' - ) - - parser.add_argument( - '--authority', - help='Custom authority URL (for sovereign clouds)' - ) - - parser.add_argument( - '--verbose', - '-v', - action='store_true', - help='Verbose output' - ) - - args = parser.parse_args() - - # Validate argument combinations - agent_mode = args.all_agents or args.agent_name - legacy_mode = not agent_mode - - if args.all_agents and args.agent_name: - parser.error("Cannot specify both --all-agents and --agent-name") - - # Create token generator - generator = EntraTokenGenerator( - tenant_id=args.tenant_id, - client_id=args.client_id, - client_secret=args.client_secret, - authority=args.authority, - verbose=args.verbose - ) - - # Determine oauth tokens directory - oauth_tokens_dir = args.oauth_dir - if oauth_tokens_dir is None: - oauth_tokens_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.oauth-tokens') - - try: - # Agent mode - batch processing - if agent_mode: - if args.all_agents: - # Generate tokens for all agents - success = generator.generate_tokens_for_all_agents( - oauth_tokens_dir=oauth_tokens_dir, - tenant_id=args.tenant_id, - scope=args.scope, - flow=args.flow - ) - else: - # Generate token for specific agent - success = generator.generate_token_for_agent( - agent_name=args.agent_name, - tenant_id=args.tenant_id, - client_id=args.client_id, - client_secret=args.client_secret, - scope=args.scope, - oauth_tokens_dir=oauth_tokens_dir, - flow=args.flow - ) - - sys.exit(0 if success else 1) - - # Legacy mode - single token generation - else: - # Validate required arguments for legacy mode - if not args.tenant_id: - parser.error("--tenant-id is required (or set ENTRA_TENANT_ID)") - - if not args.client_id: - parser.error("--client-id is required (or set ENTRA_CLIENT_ID)") - - # Generate tokens based on flow - token_data = None - if args.flow == 'device_code': - scope = args.scope or "openid profile email User.Read offline_access" - token_data = generator.get_device_code_token(scope=scope) - - elif args.flow == 'client_credentials': - scope = args.scope or "https://graph.microsoft.com/.default" - token_data = generator.get_client_credentials_token(scope=scope) - - elif args.flow == 'refresh': - if not args.refresh_token: - parser.error("--refresh-token is required for refresh flow") - scope = args.scope or "openid profile email User.Read offline_access" - token_data = generator.refresh_token( - refresh_token=args.refresh_token, - scope=scope - ) - - # Add metadata - token_data['generated_at'] = time.time() - token_data['expires_at'] = time.time() + token_data.get('expires_in', 3600) - - # Decode token if requested - if args.decode and 'access_token' in token_data: - print("\n" + "=" * 70) - print("TOKEN CLAIMS") - print("=" * 70) - claims = generator.decode_token(token_data['access_token']) - print(json.dumps(claims, indent=2)) - print("=" * 70 + "\n") - - # Output tokens - if args.output: - with open(args.output, 'w') as f: - json.dump(token_data, f, indent=2) - print(f"\n✓ Tokens saved to: {args.output}") - else: - print("\n" + "=" * 70) - print("TOKENS") - print("=" * 70) - print(json.dumps(token_data, indent=2)) - print("=" * 70) - - # Display useful information - print("\nToken Information:") - print(f" Token Type: {token_data.get('token_type', 'Bearer')}") - print(f" Expires In: {token_data.get('expires_in', 'N/A')} seconds") - if 'scope' in token_data: - print(f" Scopes: {token_data['scope']}") - - return 0 - - except KeyboardInterrupt: - generator.warning("Operation interrupted by user") - sys.exit(1) - except Exception as e: - generator.error(f"Unexpected error: {e}") - if args.verbose: - import traceback - traceback.print_exc() - sys.exit(1) + raise NotImplementedError("This script is no longer supported. ") if __name__ == '__main__': - sys.exit(main()) + main() diff --git a/credentials-provider/oauth/ingress_oauth.py b/credentials-provider/oauth/ingress_oauth.py index 31156696..03ef00a6 100644 --- a/credentials-provider/oauth/ingress_oauth.py +++ b/credentials-provider/oauth/ingress_oauth.py @@ -3,11 +3,11 @@ Ingress OAuth Authentication Script This script handles OAuth authentication for ingress (inbound) connections to the MCP Gateway. -It supports both Cognito and Keycloak M2M (Machine-to-Machine) authentication based on AUTH_PROVIDER. +It supports Cognito, Keycloak, and Entra ID M2M (Machine-to-Machine) authentication based on AUTH_PROVIDER. The script: 1. Validates required INGRESS OAuth environment variables -2. Performs M2M authentication using client_credentials grant (Cognito or Keycloak) +2. Performs M2M authentication using client_credentials grant (Cognito, Keycloak, or Entra ID) 3. Saves tokens to ingress.json in the OAuth tokens directory 4. Does not generate MCP configuration files (handled by oauth_creds.sh) @@ -24,6 +24,12 @@ - KEYCLOAK_M2M_CLIENT_ID: Keycloak M2M client ID - KEYCLOAK_M2M_CLIENT_SECRET: Keycloak M2M client secret +For AUTH_PROVIDER=entra_id: +- ENTRA_TENANT_ID: Microsoft Entra ID Tenant ID +- ENTRA_CLIENT_ID: Entra ID Application (Client) ID +- ENTRA_CLIENT_SECRET: Entra ID Client Secret +- ENTRA_M2M_SCOPE: Scope for M2M authentication (default: https://graph.microsoft.com/.default) + Usage: python ingress_oauth.py python ingress_oauth.py --verbose @@ -81,6 +87,12 @@ def _validate_environment_variables() -> None: "KEYCLOAK_M2M_CLIENT_ID", "KEYCLOAK_M2M_CLIENT_SECRET" ] + elif auth_provider == "entra_id": + required_vars = [ + "ENTRA_TENANT_ID", + "ENTRA_CLIENT_ID", + "ENTRA_CLIENT_SECRET" + ] else: # cognito (default) required_vars = [ "INGRESS_OAUTH_USER_POOL_ID", @@ -194,6 +206,89 @@ def _perform_keycloak_m2m_authentication( raise +def _perform_entra_m2m_authentication( + client_id: str, + client_secret: str, + tenant_id: str, + scope: str +) -> Dict[str, Any]: + """Perform M2M (client credentials) OAuth 2.0 authentication with Microsoft Entra ID.""" + try: + # Generate token URL for Entra ID + token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + + # Prepare the token request + payload = { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "scope": scope + } + + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json" + } + + logger.info(f"Requesting M2M token from {token_url}") + logger.debug(f"Using client_id: {client_id[:10]}...") + logger.debug(f"Scope: {scope}") + + response = requests.post( + token_url, + data=payload, + headers=headers, + timeout=30 + ) + + if not response.ok: + logger.error(f"M2M token request failed with status {response.status_code}. Response: {response.text}") + raise ValueError(f"Token request failed: {response.text}") + + token_data = response.json() + + if "access_token" not in token_data: + logger.error(f"Access token not found in M2M response. Keys found: {list(token_data.keys())}") + raise ValueError("No access token in response") + + # Calculate expiry time + expires_at = None + if "expires_in" in token_data: + expires_at = time.time() + token_data["expires_in"] + else: + # Fallback: assume 3600 seconds (1 hour) validity if not specified + logger.warning("No expires_in in token response, assuming 3600 seconds validity") + expires_at = time.time() + 3600 + token_data["expires_in"] = 3600 + + # Prepare result + result = { + "access_token": token_data["access_token"], + "refresh_token": token_data.get("refresh_token"), # M2M typically doesn't have refresh tokens + "expires_at": expires_at, + "token_type": token_data.get("token_type", "Bearer"), + "provider": "entra_m2m", + "client_id": client_id, + "tenant_id": tenant_id, + "scope": scope + } + + logger.info("M2M token obtained successfully!") + + if expires_at: + expires_in = int(expires_at - time.time()) + logger.info(f"Token expires in: {expires_in} seconds") + + return result + + except requests.exceptions.RequestException as e: + logger.error(f"Network error during M2M token request: {e}") + raise + except Exception as e: + logger.error(f"Failed to obtain M2M token: {e}") + raise + + def _perform_m2m_authentication( client_id: str, client_secret: str, @@ -313,6 +408,12 @@ def _save_ingress_tokens(token_data: Dict[str, Any]) -> str: "realm": token_data["realm"], "usage_notes": "This token is for INGRESS authentication to the MCP Gateway (Keycloak M2M)" }) + elif provider == "entra_m2m": + save_data.update({ + "tenant_id": token_data["tenant_id"], + "scope": token_data["scope"], + "usage_notes": "This token is for INGRESS authentication to the MCP Gateway (Entra ID M2M)" + }) else: # cognito_m2m save_data.update({ "user_pool_id": token_data["user_pool_id"], @@ -364,7 +465,7 @@ def _load_existing_tokens() -> Optional[Dict[str, Any]]: def main() -> int: """Main entry point.""" parser = argparse.ArgumentParser( - description="Ingress OAuth Authentication for MCP Gateway (Cognito or Keycloak M2M)", + description="Ingress OAuth Authentication for MCP Gateway (Cognito, Keycloak, or Entra ID M2M)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: @@ -384,6 +485,12 @@ def main() -> int: KEYCLOAK_REALM # Keycloak realm name KEYCLOAK_M2M_CLIENT_ID # Keycloak M2M client ID KEYCLOAK_M2M_CLIENT_SECRET # Keycloak M2M client secret + +For AUTH_PROVIDER=entra_id: + ENTRA_TENANT_ID # Microsoft Entra ID Tenant ID + ENTRA_CLIENT_ID # Entra ID Application (Client) ID + ENTRA_CLIENT_SECRET # Entra ID Client Secret + ENTRA_M2M_SCOPE # Scope for M2M authentication (optional, default: https://graph.microsoft.com/.default) """ ) @@ -433,6 +540,23 @@ def main() -> int: keycloak_url=keycloak_url, realm=realm ) + elif auth_provider == "entra_id": + # Get Entra ID configuration from environment + client_id = os.getenv("ENTRA_CLIENT_ID") + client_secret = os.getenv("ENTRA_CLIENT_SECRET") + tenant_id = os.getenv("ENTRA_TENANT_ID") + scope = os.getenv("ENTRA_M2M_SCOPE", "https://graph.microsoft.com/.default") + + logger.info(f"Tenant ID: {tenant_id}") + logger.info(f"Client ID: {client_id[:10]}...") + logger.info(f"Scope: {scope}") + + token_data = _perform_entra_m2m_authentication( + client_id=client_id, + client_secret=client_secret, + tenant_id=tenant_id, + scope=scope + ) else: # cognito (default) # Get Cognito configuration from environment client_id = os.getenv("INGRESS_OAUTH_CLIENT_ID") From 991e169db377e165f9fb67fd5f1e6064bab86f5b Mon Sep 17 00:00:00 2001 From: ryo Date: Fri, 14 Nov 2025 12:07:43 -0500 Subject: [PATCH 16/17] update not support language --- credentials-provider/entra/generate_tokens.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/credentials-provider/entra/generate_tokens.py b/credentials-provider/entra/generate_tokens.py index 9f86c1eb..16b38a92 100644 --- a/credentials-provider/entra/generate_tokens.py +++ b/credentials-provider/entra/generate_tokens.py @@ -5,7 +5,7 @@ """ def main(): - raise NotImplementedError("This script is no longer supported. ") + raise NotImplementedError("It is super difficult to retrieve Admin or similar role username/password to generate App secret_id/secret_key. Hence, this script is not supported not. We are working on an alternative approach.") if __name__ == '__main__': From 451689ae3f3105d5f1f312bc12a5c35a5b564493 Mon Sep 17 00:00:00 2001 From: "yulin.deng" <1016068291@qq.com> Date: Mon, 17 Nov 2025 23:24:17 +0800 Subject: [PATCH 17/17] fix --- auth_server/scopes.yml | 5 +++++ auth_server/server.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/auth_server/scopes.yml b/auth_server/scopes.yml index 74f011f3..a4f5fad2 100644 --- a/auth_server/scopes.yml +++ b/auth_server/scopes.yml @@ -126,6 +126,11 @@ group_mappings: - registry-users-lob1 registry-users-lob2: - registry-users-lob2 + # Entra groups mapping + mcp-registry-developer: + - mcp-registry-admin + - mcp-servers-unrestricted/read + - mcp-servers-unrestricted/execute # ==================== MCP SERVER SCOPES ==================== # Unrestricted read access: Wildcard access to all servers with all methods and tools diff --git a/auth_server/server.py b/auth_server/server.py index 38b2a5d7..2fa271e1 100644 --- a/auth_server/server.py +++ b/auth_server/server.py @@ -1679,7 +1679,7 @@ async def oauth2_callback( # For Entra ID, prioritize ID token claims over userinfo endpoint try: # For Entra ID, use provider's get_user_info method - auth_provider = get_auth_provider('entra_id') + auth_provider = get_auth_provider('entra') user_info = auth_provider.get_user_info( access_token=token_data.get("access_token"), # Required for Graph API calls