diff --git a/.env.example b/.env.example index 7da35381..ab933b21 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,16 @@ # ============================================================================= -# 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 +# ============================================================================= +# 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 # ============================================================================= @@ -117,24 +124,30 @@ 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 CONFIGURATION (if AUTH_PROVIDER=entra) +# MICROSOFT ENTRA ID (AZURE AD) OAUTH2 CONFIGURATION (if AUTH_PROVIDER=entra) # ============================================================================= -# Azure AD Tenant ID (Directory/tenant ID from Azure Portal) -# Format: GUID (e.g., 12345678-1234-1234-1234-123456789012) -# Get from: Azure Portal → Azure Active Directory → Overview → Tenant ID -ENTRA_TENANT_ID=your-tenant-id-here - -# Entra ID Application (client) ID -# Format: GUID (e.g., 87654321-4321-4321-4321-210987654321) -# Get from: Azure Portal → App registrations → Your App → Application (client) ID -ENTRA_CLIENT_ID=your-client-id-here - -# Entra ID Client Secret (Application secret value) -# Get from: Azure Portal → App registrations → Your App → Certificates & secrets -# NOTE: Copy the secret VALUE immediately after creation (not the secret ID) -ENTRA_CLIENT_SECRET=your-client-secret-here +# 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 # Enable Entra ID in OAuth2 providers (set to true when using Entra ID) ENTRA_ENABLED=false @@ -145,6 +158,37 @@ ENTRA_GROUP_ADMIN_ID=your-admin-group-object-id-here # Users Group Example ENTRA_GROUP_USERS_ID=your-users-group-object-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 + +# 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 offers id,access token kinds +# id token: OIDC get user info, JWT +# access token: OAuth 2.0, Graph API +ENTRA_ROLE_TOKEN_KIND=id + # ============================================================================= # APPLICATION SECURITY # ============================================================================= @@ -221,4 +265,4 @@ EXTERNAL_REGISTRY_TAGS=anthropic-registry,workday-asor # 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/.gitignore b/.gitignore index d5d9b285..e88273b8 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 @@ -178,7 +181,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/__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 0ff84e9b..63718e6b 100644 --- a/auth_server/oauth2_providers.yml +++ b/auth_server/oauth2_providers.yml @@ -36,25 +36,6 @@ providers: name_claim: "name" enabled: false - entra: - display_name: "Microsoft Entra ID" - client_id: "${ENTRA_CLIENT_ID}" - client_secret: "${ENTRA_CLIENT_SECRET}" - 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/oidc/userinfo" - logout_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/logout" - # Request basic OIDC scopes - email and groups require optional claims configuration in Azure Portal - scopes: ["openid", "email", "profile"] - response_type: "code" - grant_type: "authorization_code" - # Claims mapping for user info - username_claim: "preferred_username" - groups_claim: "groups" - email_claim: "email" - name_claim: "name" - enabled: false - github: display_name: "GitHub" client_id: "${GITHUB_CLIENT_ID}" @@ -89,6 +70,31 @@ providers: name_claim: "name" enabled: false # Disabled by default + entra: + 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" + # Entra ID specific claim mapping (with sensible defaults) + username_claim: "${ENTRA_USERNAME_CLAIM:-preferred_username}" + groups_claim: "${ENTRA_GROUPS_CLAIM:-groups}" + email_claim: "${ENTRA_EMAIL_CLAIM:-email}" + name_claim: "${ENTRA_NAME_CLAIM:-name}" + # 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: max_age_seconds: 28800 # 8 hours diff --git a/auth_server/providers/__init__.py b/auth_server/providers/__init__.py index b1d9ccf7..95e80bb7 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/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 543ff2cc..1118a636 100644 --- a/auth_server/providers/entra.py +++ b/auth_server/providers/entra.py @@ -1,14 +1,12 @@ -"""Microsoft Entra ID (Azure AD) authentication provider implementation.""" - import logging +import os import time -from typing import Any, Dict, Optional -from urllib.parse import urlencode - import jwt import requests - +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, @@ -19,45 +17,76 @@ class EntraIdProvider(AuthProvider): - """Microsoft Entra ID (Azure AD) authentication provider. - - This provider implements OAuth2/OIDC authentication using Microsoft Entra ID - (formerly Azure Active Directory). It supports: - - User authentication via OAuth2 authorization code flow - - Machine-to-machine authentication via client credentials flow - - JWT token validation using Azure AD JWKS - - Group-based authorization with Azure AD security groups - """ + """Microsoft Entra ID authentication provider implementation.""" def __init__( - self, - tenant_id: str, - client_id: str, - client_secret: str + self, + tenant_id: str, + client_id: str, + client_secret: str, + 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", + groups_claim: str = "groups", + email_claim: str = "email", + name_claim: str = "name" ): """Initialize Entra ID provider. - + Args: - tenant_id: Azure AD tenant ID (GUID) - client_id: App registration client ID (GUID) - client_secret: App registration client secret + tenant_id: Azure AD tenant ID (or 'common' for multi-tenant) + client_id: Azure AD application (client) ID + client_secret: Azure AD client secret + 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') + 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 self.client_secret = client_secret - # JWKS cache + # 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 - # Entra ID endpoints + # Microsoft Entra ID endpoints - from configuration base_url = f"https://login.microsoftonline.com/{tenant_id}" - self.auth_url = f"{base_url}/oauth2/v2.0/authorize" - self.token_url = f"{base_url}/oauth2/v2.0/token" - self.userinfo_url = "https://graph.microsoft.com/oidc/userinfo" - self.jwks_url = f"{base_url}/discovery/v2.0/keys" - self.logout_url = f"{base_url}/oauth2/v2.0/logout" + 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 + self.email_claim = email_claim + self.name_claim = name_claim # Entra ID supports two issuer formats: # v2.0 endpoint: https://login.microsoftonline.com/{tenant}/v2.0 @@ -66,7 +95,9 @@ def __init__( self.issuer_v1 = f"https://sts.windows.net/{tenant_id}/" self.valid_issuers = [self.issuer_v2, self.issuer_v1] - 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}, graph_url={self.graph_url}, " + f"claims: username={username_claim}, email={email_claim}, groups={groups_claim}, name={name_claim}") def validate_token( self, @@ -130,8 +161,8 @@ def validate_token( token, signing_key, algorithms=['RS256'], - issuer=token_issuer, - audience=[self.client_id, f'api://{self.client_id}'], # Accept both formats + issuer=self.issuer, + audience=[self.client_id, f"api://{self.client_id}"], options={ "verify_exp": True, "verify_iat": True, @@ -139,24 +170,23 @@ def validate_token( } ) - logger.debug(f"Token validation successful for user: {claims.get('preferred_username', 'unknown')}") + # 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 [] - # Extract user info from claims - # For M2M tokens, group memberships are in 'roles' claim instead of 'groups' - # For user tokens, they're in 'groups' claim - groups = claims.get('groups', []) - if not groups and 'roles' in claims: - # M2M token - use roles claim as groups - groups = claims.get('roles', []) - logger.debug(f"M2M token detected, using roles claim as groups: {groups}") + logger.debug(f"Token validation successful for user: {username}") return { 'valid': True, - 'username': claims.get('preferred_username', claims.get('sub')), - 'email': claims.get('email'), + 'username': username, + 'email': email, 'groups': groups, - 'scopes': claims.get('scope', '').split() if claims.get('scope') else [], - 'client_id': claims.get('azp', self.client_id), + 'scopes': claims.get('scp', '').split() if claims.get('scp') else [], + 'client_id': claims.get('azp', claims.get('appid', self.client_id)), 'method': 'entra', 'data': claims } @@ -172,19 +202,11 @@ def validate_token( raise ValueError(f"Token validation failed: {e}") def get_jwks(self) -> Dict[str, Any]: - """Get JSON Web Key Set from Entra ID with caching. - - Returns: - Dictionary containing the JWKS data - - Raises: - ValueError: If JWKS cannot be retrieved - """ + """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): + (current_time - self._jwks_cache_time) < self._jwks_cache_ttl): logger.debug("Using cached JWKS") return self._jwks_cache @@ -208,37 +230,20 @@ def exchange_code_for_token( code: str, redirect_uri: str ) -> Dict[str, Any]: - """Exchange authorization code for access token. - - Args: - code: Authorization code from OAuth2 flow - redirect_uri: Redirect URI used in the authorization request - - Returns: - Dictionary containing token response: - - access_token: The access token - - id_token: The ID token - - refresh_token: The refresh token (if available) - - token_type: "Bearer" - - expires_in: Token expiration time in seconds - - Raises: - ValueError: If code exchange fails - """ + """Exchange authorization code for access token.""" try: 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 + 'redirect_uri': redirect_uri, + 'scope': ' '.join(self.scopes) } - headers = { - 'Content-Type': 'application/x-www-form-urlencoded' - } + headers = {'Content-Type': 'application/x-www-form-urlencoded'} response = requests.post(self.token_url, data=data, headers=headers, timeout=10) response.raise_for_status() @@ -252,37 +257,192 @@ def exchange_code_for_token( logger.error(f"Failed to exchange code for token: {e}") raise ValueError(f"Token exchange failed: {e}") - def get_user_info( - self, - access_token: str - ) -> Dict[str, Any]: - """Get user information from Entra ID. - + def _extract_user_info_from_token( + self, + token: str, + token_type: str + ) -> Optional[Dict[str, Any]]: + """Extract user information from JWT token. + Args: - access_token: Valid access token - + token: JWT token string + token_type: Type of token ('id' or 'access') + Returns: - Dictionary containing user information: - - username: User's preferred_username - - email: User's email - - groups: User's group memberships (Object IDs) + 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]: + """Fetch user information from Microsoft Graph API. + + Args: + access_token: OAuth2 access token + + Returns: + Dict containing user information + Raises: - ValueError: If user info cannot be retrieved + ValueError: If Graph API request fails """ try: - logger.debug("Fetching user info from Entra ID") - + 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() + logger.info(f"User info fetched from Microsoft Graph API: {graph_data}") + + entra_config = get_provider_config('entra') 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(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, + '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': [] + } + logger.info(f"User info fetched from Microsoft Graph API: {user_info}") + 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 [] - user_info = response.json() - logger.debug(f"User info retrieved for: {user_info.get('preferred_username', 'unknown')}") + 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 requests.RequestException as e: + except Exception as e: logger.error(f"Failed to get user info: {e}") raise ValueError(f"User info retrieval failed: {e}") @@ -304,13 +464,12 @@ def get_auth_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 email profile', - 'redirect_uri': redirect_uri, - 'state': state - } + params = {'client_id': self.client_id, + 'response_type': 'code', + 'scope': scope or ' '.join(self.scopes), + 'redirect_uri': redirect_uri, + 'state': state, + } auth_url = f"{self.auth_url}?{urlencode(params)}" logger.debug(f"Generated auth URL: {auth_url}") @@ -363,7 +522,8 @@ def refresh_token( 'grant_type': 'refresh_token', 'refresh_token': refresh_token, 'client_id': self.client_id, - 'client_secret': self.client_secret + 'client_secret': self.client_secret, + 'scope': ' '.join(refresh_scopes) } headers = { @@ -435,19 +595,13 @@ def get_m2m_token( 'grant_type': 'client_credentials', 'client_id': client_id or self.client_id, 'client_secret': client_secret or self.client_secret, - 'scope': scope - } - - headers = { - 'Content-Type': 'application/x-www-form-urlencoded' + '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) response.raise_for_status() - token_data = response.json() logger.debug("M2M token generation successful") - return token_data except requests.RequestException as e: @@ -475,4 +629,4 @@ def get_provider_info(self) -> Dict[str, Any]: 'v2': self.issuer_v2, 'v1': self.issuer_v1 } - } + } \ No newline at end of file diff --git a/auth_server/providers/factory.py b/auth_server/providers/factory.py index 999e45e9..28ff6ecf 100644 --- a/auth_server/providers/factory.py +++ b/auth_server/providers/factory.py @@ -3,11 +3,11 @@ import logging import os from typing import Optional - from .base import AuthProvider from .cognito import CognitoProvider from .keycloak import KeycloakProvider from .entra import EntraIdProvider +from ..utils.config_loader import get_provider_config logging.basicConfig( level=logging.INFO, @@ -18,17 +18,17 @@ def get_auth_provider( - provider_type: Optional[str] = None + provider_type: Optional[str] = None ) -> AuthProvider: """Factory function to get the appropriate auth provider. - + Args: provider_type: Type of provider to create ('cognito', 'keycloak', or 'entra'). If None, uses AUTH_PROVIDER environment variable. - + Returns: AuthProvider instance configured for the specified provider - + Raises: ValueError: If provider type is unknown or required config is missing """ @@ -74,7 +74,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, @@ -94,10 +95,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: @@ -106,15 +107,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, @@ -125,11 +126,34 @@ def _create_cognito_provider() -> CognitoProvider: def _create_entra_provider() -> EntraIdProvider: - """Create and configure 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') + """Create and configure Microsoft Entra ID provider.""" + # Load OAuth2 configuration using shared loader + entra_config = get_provider_config('entra') or {} + + # 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') + 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 = [] @@ -139,19 +163,43 @@ def _create_entra_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}'") + 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 + client_secret=client_secret, + 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, + groups_claim=groups_claim, + email_claim=email_claim, + name_claim=name_claim ) @@ -172,4 +220,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/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 a19fef05..e56ea978 100644 --- a/auth_server/server.py +++ b/auth_server/server.py @@ -28,13 +28,15 @@ import secrets import urllib.parse import httpx -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 OAuth2 config loader +from .utils.config_loader import OAuth2ConfigLoader, get_oauth2_config # Configure logging logging.basicConfig( @@ -841,6 +843,7 @@ async def health_check(): """Health check endpoint""" return {"status": "healthy", "service": "simplified-auth-server"} + @app.get("/validate") async def validate_request(request: Request): """ @@ -859,8 +862,6 @@ async def validate_request(request: Request): Raises: HTTPException: If the token is missing, invalid, or configuration is incomplete """ - - try: # Extract headers # Check for X-Authorization first (custom header used by this gateway) @@ -1072,7 +1073,7 @@ async def validate_request(request: Request): 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})") raise HTTPException( @@ -1408,64 +1409,9 @@ def main(): 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() +# Global OAuth2 configuration using the new config loader +# This will use the singleton OAuth2ConfigLoader instance +OAUTH2_CONFIG = get_oauth2_config() # Initialize SECRET_KEY and signer for session management SECRET_KEY = os.environ.get('SECRET_KEY') @@ -1677,7 +1623,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"]: try: @@ -1729,7 +1674,7 @@ async def oauth2_callback( else: logger.warning("No ID token found in Keycloak 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 @@ -1740,28 +1685,21 @@ async def oauth2_callback( elif provider == "entra": # For Entra ID, prioritize ID token claims over userinfo endpoint try: - if "id_token" in token_data: - import jwt - # Decode without verification (we trust the token since we just got it from Microsoft) - 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 can return groups as either 'groups' or 'roles' depending on configuration - groups = id_token_claims.get("groups", []) - if not groups: - groups = id_token_claims.get("roles", []) - - mapped_user = { - "username": id_token_claims.get("preferred_username") or id_token_claims.get("email") or id_token_claims.get("upn") or id_token_claims.get("sub"), - "email": id_token_claims.get("email") or id_token_claims.get("preferred_username"), - "name": id_token_claims.get("name") or id_token_claims.get("given_name"), - "groups": groups - } - logger.info(f"User extracted from Entra ID token: {mapped_user}") - else: - logger.warning("No ID token found in Entra 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') + + 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"Entra ID token parsing failed: {e}, falling back to userInfo endpoint") 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..6df76735 --- /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') + + 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') + + Returns: + Provider configuration dictionary or None if not found + + Example: + >>> entra_config = get_provider_config('entra') + >>> 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/credentials-provider/entra/generate_tokens.py b/credentials-provider/entra/generate_tokens.py new file mode 100644 index 00000000..16b38a92 --- /dev/null +++ b/credentials-provider/entra/generate_tokens.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +""" +Microsoft Entra ID Token Generator + +""" + +def main(): + 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__': + main() diff --git a/credentials-provider/oauth/ingress_oauth.py b/credentials-provider/oauth/ingress_oauth.py index c5bba315..586a7081 100644 --- a/credentials-provider/oauth/ingress_oauth.py +++ b/credentials-provider/oauth/ingress_oauth.py @@ -7,7 +7,7 @@ The script: 1. Validates required INGRESS OAuth environment variables -2. Performs M2M authentication using client_credentials grant +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) @@ -190,13 +190,13 @@ def _perform_keycloak_m2m_authentication( } 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 @@ -270,13 +270,13 @@ def _perform_entra_m2m_authentication( } 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 diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example new file mode 100644 index 00000000..7a59f99f --- /dev/null +++ b/docker-compose.override.yml.example @@ -0,0 +1,69 @@ +# 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 + + # 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 claim mappings + # These determine which claims from the ID token are used for user information + # - ENTRA_USERNAME_CLAIM=preferred_username # Default: preferred_username + # - ENTRA_GROUPS_CLAIM=groups # Default: groups + # - 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: + # profiles: + # - disabled + # keycloak-db: + # 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 7b379336..aebad07b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,7 +79,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 @@ -112,7 +112,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' - KEYCLOAK_ENABLED=${KEYCLOAK_ENABLED:-false} - KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080} - KEYCLOAK_EXTERNAL_URL=${KEYCLOAK_EXTERNAL_URL:-http://localhost:8080} @@ -121,16 +121,21 @@ 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} - # Entra ID configuration + # 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_ENABLED=${ENTRA_ENABLED:-false} + # 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: - - ${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 @@ -154,7 +159,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 @@ -170,13 +175,13 @@ 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: - - registry +# depends_on: +# - registry restart: unless-stopped # Real Server Fake Tools MCP Server 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/docs/auth.md b/docs/auth.md index 2b361aac..1a6e00fc 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -59,6 +59,14 @@ 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) +# 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 EGRESS_OAUTH_CLIENT_SECRET_1=your_external_provider_client_secret diff --git a/docs/configuration.md b/docs/configuration.md index e3d6d7a5..fc000d3c 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`**: 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`) | `keycloak` | ✅ | | `AWS_REGION` | AWS region for services | `us-east-1` | ✅ | ### Keycloak Configuration (if AUTH_PROVIDER=keycloak) @@ -105,6 +106,38 @@ 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) + +#### 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...` | ✅ | + +#### 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_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` | + +**Setup Instructions** + +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). + +**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 | Variable | Description | Example | Default | diff --git a/docs/entra-id-implementation.md b/docs/entra-id-implementation.md new file mode 100644 index 00000000..b435c691 --- /dev/null +++ b/docs/entra-id-implementation.md @@ -0,0 +1,605 @@ +# 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, + 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", + groups_claim: str = "groups", + email_claim: str = "email", + name_claim: str = "name" +): +``` + +**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 +- `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'`) +- `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:** +- 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 + +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 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 + +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 provides flexible user information extraction with automatic fallback mechanisms: + +```python +def get_user_info( + self, + access_token: str, + id_token: Optional[str] = None +) -> Dict[str, Any]: +``` + +**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) or preferred_username +- `email`: Mail address or UPN +- `name`: Display name +- `given_name`: First name (Graph API only) +- `family_name`: Last name (Graph API only) +- `id`: Object ID +- `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?$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) + +### 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: 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 + +```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: + 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" + # 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 +# 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 Configuration Variables + +```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: + +### 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 + +### 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", "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 +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 +import os + +# Set environment variables +os.environ['ENTRA_TOKEN_KIND'] = 'id' # Use ID token for user info + +# Initialize provider with all required parameters +provider = EntraIdProvider( + tenant_id="your-tenant-id", + client_id="your-client-id", + 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 +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" +) + +# 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 groups separately (if needed) +groups = provider.get_user_groups(token_data["access_token"]) +``` + +### Machine-to-Machine Authentication + +```python +# 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" +) + +# Validate 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 + +1. **Token Validation Failures** + - 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 + +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 f6d2f0d0..0c4e1de2 100644 --- a/docs/entra-id-setup.md +++ b/docs/entra-id-setup.md @@ -1,646 +1,320 @@ -# Microsoft Entra ID Setup Guide +# Microsoft Entra ID (Azure AD) Setup Guide -This guide provides step-by-step instructions for setting up Microsoft Entra ID (formerly Azure Active Directory) authentication for the MCP Gateway Registry. - -## Table of Contents - -1. [Prerequisites](#prerequisites) -2. [Azure Portal Configuration](#azure-portal-configuration) -3. [Environment Configuration](#environment-configuration) -4. [Group Configuration](#group-configuration) -5. [Testing the Setup](#testing-the-setup) -6. [Troubleshooting](#troubleshooting) - ---- +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 -Before you begin, ensure you have: - -- Access to an Azure account with permissions to create App Registrations -- Azure Active Directory (Entra ID) tenant -- Admin rights to configure App Registrations and assign users to groups -- The MCP Gateway Registry codebase - ---- - -## Azure Portal Configuration - -### Step 1: Create an App Registration - -1. Navigate to the [Azure Portal](https://portal.azure.com) -2. Go to **Azure Active Directory** → **App registrations** -3. Click **New registration** -4. Configure the app registration: - - **Name**: `mcp-gateway-web` (or your preferred name) - - **Supported account types**: Select the appropriate option: - - **Single tenant** (recommended): Only users in your organization - - **Multi-tenant**: Users from any Azure AD tenant - - **Redirect URI**: - - Platform: **Web** - - URI: `http://localhost/auth/callback` (for local development) - - For production, use: `https://your-domain.com/auth/callback` -5. Click **Register** - -### Step 2: Note Your Application IDs - -After creating the app registration, note the following values (you'll need them later): - -1. From the app registration **Overview** page: - - **Application (client) ID**: This is your `ENTRA_CLIENT_ID` - - **Directory (tenant) ID**: This is your `ENTRA_TENANT_ID` - -### Step 3: Create a Client Secret - -1. In your app registration, click **Certificates & secrets** in the left menu -2. Click **New client secret** -3. Configure the secret: - - **Description**: `mcp-gateway-auth` (or your preferred description) - - **Expires**: Choose an appropriate expiration period (recommended: 24 months) -4. Click **Add** -5. **IMPORTANT**: Copy the **Value** immediately (not the Secret ID) - - This is your `ENTRA_CLIENT_SECRET` - - You cannot retrieve this value later - if you lose it, you'll need to create a new secret - -### Step 4: Configure Redirect URIs - -1. In your app registration, click **Authentication** in the left menu -2. Under **Platform configurations** → **Web**, add redirect URIs: - - For local development: `http://localhost/auth/callback` - - For production: `https://your-domain.com/auth/callback` -3. Under **Implicit grant and hybrid flows**, ensure nothing is checked (not needed for authorization code flow) -4. Click **Save** - -### Step 5: Add API Permissions - -To get user email and group information, you need to configure API permissions: - -1. Click **API permissions** in the left menu -2. Click **Add a permission** -3. Select **Microsoft Graph** -4. Select **Delegated permissions** -5. Search for and add the following permissions: - - `User.Read` (should already be present) - - `email` - Read user's email address - - `profile` - Read user's basic profile - - `GroupMember.Read.All` - Read groups user belongs to -6. Click **Add permissions** -7. **CRITICAL**: Click **Grant admin consent for [Your Tenant]** - - This step is required for the permissions to work - - You need admin privileges to grant consent - -### Step 6: Configure Optional Claims - -To include email, username, and groups in the ID token: - -1. Click **Token configuration** in the left menu -2. Click **Add optional claim** -3. Select **ID** token type -4. Add these claims: - - `email` - User's email address - - `preferred_username` - User's UPN (User Principal Name) - - `groups` - Security group Object IDs -5. Click **Add** -6. When prompted "Turn on the Microsoft Graph email, profile permission", click **Add** - -### Step 7: Configure Group Claims - -1. Still in **Token configuration** -2. Click **Add groups claim** -3. Select **Security groups** -4. Under "Customize token properties by type": - - **ID**: Check "Group ID" - - **Access**: Check "Group ID" -5. Click **Add** - -### Step 8: Create Security Groups - -Create Azure AD security groups for authorization: - -1. Go to **Azure Active Directory** → **Groups** -2. Click **New group** -3. Create an admin group: - - **Group type**: Security - - **Group name**: `Mcp-test-admin` (or your preferred name) - - **Group description**: MCP Gateway administrators - - **Membership type**: Assigned -4. Click **Create** -5. Repeat for a users group: - - **Group name**: `mcp-test-users` (or your preferred name) - - **Group description**: MCP Gateway users - -### Step 9: Note Group Object IDs - -For each group you created: - -1. Click on the group name -2. From the **Overview** page, copy the **Object Id** -3. Note these IDs - you'll need them for `scopes.yml` configuration - -### Step 10: Add Users to Groups - -1. For each group, click on the group name -2. Click **Members** in the left menu -3. Click **Add members** -4. Search for and select users -5. Click **Select** - -### Step 11: Configure App for API Access (Optional) - -If you plan to use machine-to-machine (M2M) authentication: - -1. Click **Expose an API** in the left menu -2. Click **Add** next to "Application ID URI" -3. Accept the default (`api://{client-id}`) or customize it -4. Click **Save** -5. Click **Add a scope** -6. Configure the scope: - - **Scope name**: `.default` - - **Who can consent**: Admins only - - **Admin consent display name**: Access MCP Gateway - - **Admin consent description**: Allow the application to access MCP Gateway - - **State**: Enabled -7. Click **Add scope** - ---- - -## Environment Configuration - -### Step 1: Update .env File - -1. Copy `.env.example` to `.env` if you haven't already: - ```bash - cp .env.example .env - ``` - -2. Edit the `.env` file and configure Entra ID settings: +- 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* + - (See [Multi-Tenant Configuration](#multi-tenant-setup) for detailed setup) + - **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 **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 (required for group 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: + +### Required Variables ```bash -# ============================================================================= -# AUTHENTICATION PROVIDER CONFIGURATION -# ============================================================================= -# Choose authentication provider: 'cognito', 'keycloak', or 'entra' -AUTH_PROVIDER=entra - -# ============================================================================= -# MICROSOFT ENTRA ID CONFIGURATION -# ============================================================================= - -# Azure AD Tenant ID (from Azure Portal → App registration → Overview) -ENTRA_TENANT_ID=12345678-1234-1234-1234-123456789012 - -# Entra ID Application (client) ID (from Azure Portal → App registration → Overview) -ENTRA_CLIENT_ID=87654321-4321-4321-4321-210987654321 - -# Entra ID Client Secret (from Azure Portal → App registration → Certificates & secrets) -ENTRA_CLIENT_SECRET=your-secret-value-here - -# Enable Entra ID in OAuth2 providers -ENTRA_ENABLED=true - -# Azure AD Group Object IDs (from Azure Portal → Groups → Overview) -ENTRA_GROUP_ADMIN_ID=16c7e67e-e8ae-498c-ba2e-0593c0159e43 -ENTRA_GROUP_USERS_ID=62c07ac1-03d0-4924-90c7-a0255f23bd1d +# 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 ``` -3. Update other required settings: +### Optional Configuration Variables ```bash -# ============================================================================= -# REGISTRY CONFIGURATION -# ============================================================================= -# For local development -REGISTRY_URL=http://localhost - -# For production with custom domain -# REGISTRY_URL=https://mcpgateway.mycorp.com - -# ============================================================================= -# AUTH SERVER CONFIGURATION -# ============================================================================= -# For local development -AUTH_SERVER_EXTERNAL_URL=http://localhost - -# For production with custom domain -# AUTH_SERVER_EXTERNAL_URL=https://mcpgateway.mycorp.com - -# ============================================================================= -# APPLICATION SECURITY -# ============================================================================= -# CRITICAL: CHANGE THIS SECRET KEY IMMEDIATELY! -SECRET_KEY=your-super-secure-random-64-character-string-here +# 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 ``` ---- +### Token Kind Configuration -## Group Configuration -### Configure scopes.yml +The `ENTRA_TOKEN_KIND` variable determines how user information is extracted: -The `auth_server/scopes.yml` file maps Azure AD groups to MCP Gateway scopes and permissions. +**Screenshot:** +![Azure Token Kind](img/entra-token-kind.png) -1. Open `auth_server/scopes.yml` +- **`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 -2. Update the Entra ID group mappings section with your group Object IDs: - -```yaml -group_mappings: - # Entra ID group mappings (by Azure AD Group Object IDs) - # Admin group - "object_id": - - mcp-registry-admin - - registry-admins +**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 ``` -3. Replace the group Object IDs with your actual group IDs from Azure Portal - -### Understanding Scope Mappings - -- **mcp-registry-admin**: Full administrative access to the registry - - Can list, register, modify, and toggle services - - Has unrestricted read and execute access to MCP servers - -- **mcp-registry-user**: Limited user access - - Can list and view specific services - - Has restricted read access to MCP servers - -- **mcp-registry-developer**: Development access - - Can list, register, and health check services - - Has restricted read and execute access - -- **mcp-registry-operator**: Operations access - - Can list, health check, and toggle services - - Has restricted read and execute access - ---- - -## Testing the Setup - -### Step 1: Start the Services - -1. Build and start the Docker containers: - ```bash - docker-compose up -d --build - ``` - -2. Check that services are running: - ```bash - docker-compose ps - ``` - -### Step 2: Test User Authentication - -1. Open your browser and navigate to: - ``` - http://localhost - ``` +### Multi-Tenant Configuration -2. You should see the MCP Gateway Registry login page +To support different types of Microsoft accounts: -3. Click the **Sign in with Microsoft Entra ID** button - -4. You will be redirected to Microsoft's login page - -5. Sign in with a user account that belongs to one of your configured groups - -6. After successful authentication, you should be redirected back to the registry - -### Step 3: Verify User Information - -1. Check the auth server logs to verify user information is being received: - ```bash - docker-compose logs auth-server | grep "Raw user info" - ``` - -2. You should see output similar to: - ``` - Raw user info from entra: { - 'sub': 'abc123...', - 'email': 'user@yourdomain.onmicrosoft.com', - 'preferred_username': 'user@yourdomain.onmicrosoft.com', - 'groups': ['16c7e67e-...', '62c07ac1-...'], - 'name': 'First Last' - } - ``` - -3. Verify the mapped scopes: - ```bash - docker-compose logs auth-server | grep "Mapped user info" - ``` - -4. You should see: - ``` - Mapped user info: { - 'username': 'user@yourdomain.onmicrosoft.com', - 'email': 'user@yourdomain.onmicrosoft.com', - 'name': 'First Last', - 'groups': ['mcp-registry-admin', 'mcp-servers-unrestricted/read', ...] - } - ``` - -### Step 4: Test Authorization - -1. Log in with an admin user (member of the admin group) - -2. Verify you can access admin functions: - - Register new services - - Modify service configurations - - Toggle services on/off - -3. Log in with a regular user (member of the users group) - -4. Verify restricted access: - - Can view services - - Cannot register or modify services - -### Step 5: Test Machine-to-Machine (M2M) Authentication - -If you configured API access for M2M authentication: - -1. Create a service principal for your AI agent: - ```bash - # This is done in Azure Portal → App registrations - # Create a new app registration for the AI agent - ``` - -2. Test M2M token generation: - ```bash - curl -X POST "https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=client_credentials" \ - -d "client_id={agent-client-id}" \ - -d "client_secret={agent-client-secret}" \ - -d "scope=api://{mcp-gateway-client-id}/.default" - ``` - -3. Use the access token to call MCP Gateway APIs - ---- - -## Troubleshooting +```bash +# Support any Microsoft organizational account +ENTRA_TENANT_ID=common -### Issue: Missing email and groups claims +# Support only organizational accounts (exclude personal accounts) +ENTRA_TENANT_ID=organizations -**Symptoms:** -``` -Raw user info from entra: {'sub': '...', 'name': 'User Name', 'family_name': '...', 'given_name': '...'} -Mapped user info: {'username': None, 'email': None, 'groups': []} +# Support only personal Microsoft accounts +ENTRA_TENANT_ID=consumers ``` -**Solution:** -1. Verify you completed [Step 5: Add API Permissions](#step-5-add-api-permissions) -2. Ensure you clicked **Grant admin consent** -3. Complete [Step 6: Configure Optional Claims](#step-6-configure-optional-claims) -4. Complete [Step 7: Configure Group Claims](#step-7-configure-group-claims) -5. Wait 5-10 minutes for Azure AD to propagate changes -6. Clear browser cookies and try logging in again +### Sovereign Cloud Configuration -### Issue: Token validation fails with "Invalid issuer" +For non-global Azure clouds, update **both** `ENTRA_GRAPH_URL` and `ENTRA_M2M_SCOPE`: -**Symptoms:** -``` -Token validation failed: Invalid issuer: https://sts.windows.net/{tenant}/ +**US Government Cloud:** +```bash +ENTRA_TENANT_ID=your-tenant-id +ENTRA_GRAPH_URL=https://graph.microsoft.us +ENTRA_M2M_SCOPE=https://graph.microsoft.us/.default ``` -**Solution:** -The Entra ID provider supports both v1.0 and v2.0 token formats. This error should not occur with the current implementation. If you see this: - -1. Check that `ENTRA_TENANT_ID` in `.env` matches your actual tenant ID -2. Verify the token is being issued by Microsoft Entra ID -3. Check auth server logs for more details - -### Issue: User cannot access any resources - -**Symptoms:** -User can log in but sees "Access Denied" or "Insufficient Permissions" - -**Solution:** -1. Verify the user is added to at least one security group in Azure AD -2. Check that group Object IDs in `scopes.yml` match the groups in Azure Portal -3. Verify the group mappings include the necessary scopes -4. Check auth server logs to see what groups are being received: - ```bash - docker-compose logs auth-server | grep "groups" - ``` - -### Issue: Redirect URI mismatch error - -**Symptoms:** -``` -AADSTS50011: The redirect URI 'http://localhost/auth/callback' does not match the redirect URIs configured for the application +**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 ``` -**Solution:** -1. Go to Azure Portal → App registrations → Your app → Authentication -2. Verify the redirect URI exactly matches what's in the error message -3. Add any missing redirect URIs -4. Ensure `AUTH_SERVER_EXTERNAL_URL` in `.env` matches the base URL - -### Issue: "Groups overage" claim +**Germany Cloud:** +```bash +ENTRA_TENANT_ID=your-tenant-id +ENTRA_GRAPH_URL=https://graph.microsoft.de +ENTRA_M2M_SCOPE=https://graph.microsoft.de/.default +``` -**Symptoms:** -Groups claim contains `_claim_names` and `_claim_sources` instead of group IDs +**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 -**Solution:** -This occurs when a user is a member of more than 200 groups. You need to: +**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. -1. Modify the auth provider to fetch groups via Microsoft Graph API -2. See the alternative implementation in `docs/ENTRA-ID-APP-CONFIGURATION.md` Step 5 +## Step 5: Enable Entra ID Provider -### Issue: Client secret expired +Ensure the Entra ID provider is enabled in the `auth_server/oauth2_providers.yml` configuration: -**Symptoms:** -``` -AADSTS7000215: Invalid client secret provided +```yaml +entra: + 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" + # 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 ``` -**Solution:** -1. Go to Azure Portal → App registrations → Your app → Certificates & secrets -2. Create a new client secret -3. Update `ENTRA_CLIENT_SECRET` in `.env` -4. Restart the services: - ```bash - docker-compose restart auth-server - ``` +## Step 6: Test the Setup -### Issue: Cannot grant admin consent +1. **Restart Services** + - Restart the authentication server and registry services -**Symptoms:** -You don't see the "Grant admin consent" button or get an error when clicking it +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 -**Solution:** -1. You need Global Administrator, Application Administrator, or Cloud Application Administrator role -2. Contact your Azure AD administrator to grant the permissions -3. Alternatively, users can consent individually (not recommended for production) +## Step 7: Optional Configurations ---- +### Group Membership Access -## Additional Resources +To retrieve user group memberships from Azure AD, ensure the following permissions are granted: -- [Microsoft Entra ID Documentation](https://learn.microsoft.com/en-us/entra/) -- [OAuth 2.0 Authorization Code Flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow) -- [Optional Claims Configuration](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims) -- [Configure Group Claims](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims#configure-groups-optional-claims) -- [Microsoft Graph Permissions Reference](https://learn.microsoft.com/en-us/graph/permissions-reference) +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. -## Production Deployment - -### Update Redirect URIs - -For production, update redirect URIs: -``` -https://your-domain.com/oauth2/callback/entra -``` +### Multi-Tenant Setup -### Environment Variables +For multi-tenant applications, set `ENTRA_TENANT_ID=common` and ensure the app registration is configured for multi-tenant access. -Update production `.env`: +**Account Type Options:** ```bash -ENTRA_REDIRECT_URI=https://your-domain.com/oauth2/callback/entra -AUTH_SERVER_EXTERNAL_URL=https://your-domain.com:8888 -``` - -### SSL/TLS Configuration - -Ensure your production deployment uses HTTPS for all OAuth flows. - ---- - -## Advanced Configuration +# Support any Microsoft organizational account +ENTRA_TENANT_ID=common -### Custom Claims +# Support only organizational accounts (exclude personal accounts) +ENTRA_TENANT_ID=organizations -To add custom claims to tokens: -1. Go to **Token configuration** -2. Click **Add optional claim** -3. Select token type and claims -4. Configure claim conditions +# Support only personal Microsoft accounts +ENTRA_TENANT_ID=consumers +``` -### Group Filtering +**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` -To limit which groups are included in tokens: -1. Go to **Token configuration** -2. Click **Add groups claim** -3. Configure **Groups assigned to the application** +### Machine-to-Machine (M2M) Authentication +For service accounts and automated processes: -### Enterprise Applications +1. **Configure App Permissions** + - In your app registration, go to **API permissions** + - Add **Application permissions** (not delegated) as needed + - Grant admin consent -For advanced management: -1. Go to **Enterprise applications** -2. Find your app registration -3. Configure: - - User assignment required - - Visibility settings - - Provisioning (if needed) +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. -## Adding New Users +## Troubleshooting -### Option 1: Add User to Existing Group (Recommended) +### Common Issues -**In Azure Portal:** -1. Go to **Microsoft Entra ID** → **Groups** -2. Click on **MCP Registry Admins** (or appropriate group) -3. Click **Members** → **Add members** -4. Search and select the new user -5. Click **Select** +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) -**Access will be immediate** - user can login and see servers/agents. +2. **Insufficient Permissions** + - Verify all required API permissions are granted with admin consent + - Check that the user has appropriate permissions in Entra ID -### Option 2: Create New Group for User +3. **Token Validation Failures** + - Verify client ID, tenant ID, and client secret are correct + - Check token audience and issuer configuration -**If you need different permissions:** +4. **Sovereign Cloud Issues** + - 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 -1. **Create new group in Azure:** - - **Group name**: `MCP Registry LOB3 Users` - - **Members**: Add the new user +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 -2. **Get the group Object ID** from the group overview page +### Logs and Debugging -3. **Add to scopes.yml:** -```yaml -group_mappings: - # Add new group mapping - "new-group-object-id-here": - - registry-users-lob1 # or whatever permission level needed -``` +Enable debug logging to troubleshoot authentication issues: -4. **Restart auth server:** ```bash -cp auth_server/scopes.yml ~/mcp-gateway/auth_server/scopes.yml -docker-compose restart auth-server +# Set log level to DEBUG in your environment +AUTH_LOG_LEVEL=DEBUG ``` ---- - -## API Reference - -### Token Endpoint -``` -POST https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token -``` +Check authentication server logs for detailed error messages and token validation information. -### Authorization Endpoint -``` -GET https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize -``` +## Security Considerations -### User Info Endpoint -``` -GET https://graph.microsoft.com/v1.0/me -``` - ---- - -## Security Best Practices - -1. **Client Secret Management** - - Store client secrets securely (use Azure Key Vault in production) - - Rotate secrets regularly (set expiration and create new secrets) - - Never commit secrets to version control - -2. **Token Configuration** - - Keep token expiration times reasonable (default: 1 hour for access tokens) - - Use refresh tokens for long-running sessions - - Implement proper token revocation - -3. **Group Management** - - Use security groups (not distribution lists or Microsoft 365 groups) - - Apply principle of least privilege - - Regularly audit group memberships - -4. **HTTPS in Production** - - Always use HTTPS in production environments - - Configure proper SSL/TLS certificates - - Update redirect URIs to use HTTPS - -5. **Monitoring and Logging** - - Enable Azure AD audit logs - - Monitor sign-in logs for suspicious activity - - Set up alerts for authentication failures - -6. **Multi-Factor Authentication** - - Enable MFA for all users (configured in Azure AD) - - Use conditional access policies - - Enforce MFA for admin accounts - ---- +- **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 completing the setup: - -1. **Configure Additional Services**: Add more MCP servers to the registry -2. **Set Up Custom Domain**: Configure HTTPS and custom domain names -3. **Configure M2M Authentication**: Set up service principals for AI agents -4. **Implement Monitoring**: Set up observability and alerting -5. **Production Deployment**: Deploy to your production environment - -For more information, see: -- [Complete Setup Guide](./complete-setup-guide.md) -- [Observability Documentation](./OBSERVABILITY.md) -- [FAQ](./FAQ.md) +After successful setup, refer to the [Implementation Documentation](./entra-id-implementation.md) for technical details and advanced configuration options. diff --git a/docs/img/entra-token-kind.png b/docs/img/entra-token-kind.png new file mode 100644 index 00000000..779f0d2f Binary files /dev/null and b/docs/img/entra-token-kind.png differ 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 2cf065e7..dad2d2ec 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 diff --git a/pyproject.toml b/pyproject.toml index 0008b09d..8810f413 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", "cisco-ai-mcp-scanner>=3.0.1", ] diff --git a/registry/api/server_routes.py b/registry/api/server_routes.py index cd36caef..41b56380 100644 --- a/registry/api/server_routes.py +++ b/registry/api/server_routes.py @@ -188,7 +188,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 @@ -202,7 +203,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}" ) diff --git a/registry/auth/dependencies.py b/registry/auth/dependencies.py index 06ffc0f5..6a60ee7e 100644 --- a/registry/auth/dependencies.py +++ b/registry/auth/dependencies.py @@ -581,12 +581,39 @@ def nginx_proxied_auth( return enhanced_auth(session) -def create_session_cookie(username: str, auth_method: str = "traditional", provider: str = "local") -> str: - """Create a session cookie for a user.""" +def create_session_cookie(username: str, auth_method: str = "traditional", provider: str = "local", groups: List[str] = None) -> str: + """ + Create a session cookie for a user. + + Security Note: For traditional auth, this function grants admin privileges. + Only call this function AFTER validating credentials with validate_login_credentials(). + + Args: + username: The authenticated username + auth_method: Authentication method ('traditional' or 'oauth2') + provider: Authentication provider + groups: User groups (for OAuth2). If None and auth_method is 'traditional', + defaults to admin group. + + Returns: + Signed session cookie string + + Raises: + ValueError: If attempting to create traditional session for non-admin user + """ + # For traditional auth users, validate and default to admin group + if groups is None and auth_method == "traditional": + # Security check: Traditional auth only supports the configured admin user + if username != settings.admin_user: + logger.error(f"Security violation: Attempted to create traditional session for non-admin user: {username}") + raise ValueError(f"Traditional authentication only supports the configured admin user") + groups = ['mcp-registry-admin'] + session_data = { "username": username, "auth_method": auth_method, - "provider": provider + "provider": provider, + "groups": groups or [] } return signer.dumps(session_data) diff --git a/uv.lock b/uv.lock index 9630debe..4f464506 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'", @@ -416,14 +416,14 @@ wheels = [ [[package]] name = "faker" -version = "37.3.0" +version = "38.0.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/04/05/206c151fe8ca9c8e46963d6c8b6e2e281f272009dad30fe3792005393a5e/faker-38.0.0.tar.gz", hash = "sha256:797aa03fa86982dfb6206918acc10ebf3655bdaa89ddfd3e668d7cc69537331a", size = 1935705, upload-time = "2025-11-12T01:47:39.586Z" } 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/4d/1e/e6d1940d2c2617d7e6a0a3fdd90e506ff141715cdc4c3ecd7217d937e656/faker-38.0.0-py3-none-any.whl", hash = "sha256:ad4ea6fbfaac2a75d92943e6a79c81f38ecff92378f6541dea9a677ec789a5b2", size = 1975561, upload-time = "2025-11-12T01:47:36.672Z" }, ] [[package]] @@ -1128,7 +1128,9 @@ dependencies = [ { name = "aiohttp" }, { name = "bandit" }, { name = "cisco-ai-mcp-scanner" }, + { name = "factory-boy" }, { name = "faiss-cpu" }, + { name = "faker" }, { name = "fastapi" }, { name = "httpcore", extra = ["asyncio"] }, { name = "httpx" }, @@ -1145,6 +1147,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, + { name = "pytest" }, { name = "python-dotenv" }, { name = "python-multipart" }, { name = "pytz" }, @@ -1190,8 +1193,10 @@ requires-dist = [ { name = "bandit", specifier = ">=1.8.3" }, { name = "cisco-ai-mcp-scanner", specifier = ">=3.0.1" }, { 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" }, @@ -1216,6 +1221,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" }, @@ -1950,7 +1956,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.0" +version = "9.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1959,21 +1965,22 @@ 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/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } 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/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, ] [[package]] name = "pytest-asyncio" -version = "1.0.0" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/d4/14f53324cb1a6381bef29d698987625d80052bb33932d8e7cbf9b337b17c/pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f", size = 46960, upload-time = "2025-05-26T04:54:40.484Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/05/ce271016e351fddc8399e546f6e23761967ee09c8c568bbfbecb0c150171/pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3", size = 15976, upload-time = "2025-05-26T04:54:39.035Z" }, + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] [[package]]