diff --git a/.env.example b/.env.example index a885f9e0..7da35381 100644 --- a/.env.example +++ b/.env.example @@ -30,7 +30,7 @@ AUTH_SERVER_EXTERNAL_URL=https://your-domain.com # ============================================================================= # AUTHENTICATION PROVIDER CONFIGURATION # ============================================================================= -# Choose authentication provider: 'cognito' or 'keycloak' +# Choose authentication provider: 'cognito', 'keycloak', or 'entra' AUTH_PROVIDER=keycloak # ============================================================================= @@ -109,7 +109,7 @@ AWS_REGION=us-east-1 # Format: {region}_{random_string} COGNITO_USER_POOL_ID=us-east-1_XXXXXXXXX -# Cognito App Client ID +# Cognito App Client ID # Get this from Amazon Cognito console > User Pools > App Integration > App clients COGNITO_CLIENT_ID=your_cognito_client_id_here @@ -117,6 +117,34 @@ 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) +# ============================================================================= + +# 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 + +# Enable Entra ID in OAuth2 providers (set to true when using Entra ID) +ENTRA_ENABLED=false + +# Azure AD Group Object IDs for authorization (configured in scopes.yml) +# Admin Group Example +ENTRA_GROUP_ADMIN_ID=your-admin-group-object-id-here +# Users Group Example +ENTRA_GROUP_USERS_ID=your-users-group-object-id-here + # ============================================================================= # APPLICATION SECURITY # ============================================================================= @@ -170,9 +198,9 @@ DOCKERHUB_TOKEN=your_dockerhub_access_token # GITHUB_USERNAME=your_github_username # GITHUB_TOKEN=your_github_personal_access_token -# Container registry organization names -DOCKERHUB_ORG=mcpgateway -GITHUB_ORG=agentic-community +# # Container registry organization names +# DOCKERHUB_ORG=mcpgateway +# GITHUB_ORG=agentic-community # ============================================================================= # EXTERNAL REGISTRY CONFIGURATION diff --git a/README.md b/README.md index 9bd59b5f..af018d5a 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ Interactive terminal interface for chatting with AI models and discovering MCP t ## What's New - **🔗 Agent-to-Agent (A2A) Protocol Support** - Agents can now register, discover, and communicate with other agents through a secure, centralized registry. Enable autonomous agent ecosystems with Keycloak-based access control and fine-grained permissions. [A2A Guide](docs/a2a.md) +- **🏢 Microsoft Entra ID Integration** - Enterprise SSO with Microsoft Entra ID (Azure AD) authentication. Group-based access control, conditional access policies, and seamless integration with existing Microsoft 365 environments. [Entra ID Setup Guide](docs/entra-id-setup.md) - **🤖 Agentic CLI for MCP Registry** - Talk to the Registry in natural language using a Claude Code-like interface. Discover tools, ask questions, and execute MCP commands conversationally. [Learn more](docs/mcp-registry-cli.md) - **💬 Interactive MCP-Registry CLI** - Terminal-based chat interface with AI-powered MCP tool discovery. Supports Amazon Bedrock and Anthropic API. [MCP-Registry CLI](docs/mcp-registry-cli.md) - **🔒 MCP Server Security Scanning** - Integrated vulnerability scanning with [Cisco AI Defence MCP Scanner](https://github.com/cisco-ai-defense/mcp-scanner). Automatic security scans during server registration, periodic registry-wide scans with detailed markdown reports, and automatic disabling of servers with security issues. @@ -551,7 +552,7 @@ The following GitHub issues represent our current development roadmap and planne - **[#195 - Add A2A (Agent-to-Agent) Protocol Support to Registry](https://github.com/agentic-community/mcp-gateway-registry/issues/195)** ✅ **COMPLETE** Agents can now register, discover, and communicate with other agents through the secure registry. Full implementation includes agent lifecycle management, Keycloak-based access control, fine-grained permissions, comprehensive testing, and documentation. [A2A Guide](docs/a2a.md) -- **[#128 - Add Microsoft Entra ID (Azure AD) Authentication Provider](https://github.com/agentic-community/mcp-gateway-registry/issues/128)** 🚧 **IN PROGRESS** +- **[#128 - Add Microsoft Entra ID (Azure AD) Authentication Provider](https://github.com/agentic-community/mcp-gateway-registry/issues/128)** ✅ **COMPLETE** Extend authentication support beyond Keycloak to include Microsoft Entra ID integration. Enables enterprise SSO for organizations using Azure Active Directory. - **[#170 - Architectural Proposal: Separate Gateway and Registry Containers](https://github.com/agentic-community/mcp-gateway-registry/issues/170)** 🚧 **IN PROGRESS** diff --git a/auth_server/oauth2_providers.yml b/auth_server/oauth2_providers.yml index c2376cd6..944bf131 100644 --- a/auth_server/oauth2_providers.yml +++ b/auth_server/oauth2_providers.yml @@ -36,6 +36,25 @@ providers: name_claim: "name" enabled: true + 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: true + github: display_name: "GitHub" client_id: "${GITHUB_CLIENT_ID}" diff --git a/auth_server/providers/entra.py b/auth_server/providers/entra.py new file mode 100644 index 00000000..543ff2cc --- /dev/null +++ b/auth_server/providers/entra.py @@ -0,0 +1,478 @@ +"""Microsoft Entra ID (Azure AD) authentication provider implementation.""" + +import logging +import time +from typing import Any, Dict, Optional +from urllib.parse import urlencode + +import jwt +import requests + +from .base import AuthProvider + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", +) + +logger = logging.getLogger(__name__) + + +class EntraIdProvider(AuthProvider): + """Microsoft Entra ID (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 + """ + + def __init__( + self, + tenant_id: str, + client_id: str, + client_secret: str + ): + """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 + """ + self.tenant_id = tenant_id + self.client_id = client_id + self.client_secret = client_secret + + # JWKS cache + 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 + 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" + + # Entra ID supports two issuer formats: + # v2.0 endpoint: https://login.microsoftonline.com/{tenant}/v2.0 + # v1.0/M2M endpoint: https://sts.windows.net/{tenant}/ + self.issuer_v2 = f"{base_url}/v2.0" + 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}'") + + def validate_token( + self, + token: str, + **kwargs: Any + ) -> Dict[str, Any]: + """Validate Entra ID JWT token. + + Args: + token: The JWT access token to validate + **kwargs: Additional provider-specific arguments + + Returns: + Dictionary containing: + - valid: True if token is valid + - username: User's preferred_username or sub claim + - email: User's email address + - groups: List of Azure AD group Object IDs + - scopes: List of token scopes + - client_id: Client ID that issued the token + - method: 'entra' + - data: Raw token claims + + Raises: + ValueError: If token validation fails + """ + try: + logger.debug("Validating Entra ID JWT token") + + # Get JWKS for validation + jwks = self.get_jwks() + + # Decode token header to get key ID + unverified_header = jwt.get_unverified_header(token) + kid = unverified_header.get('kid') + + if not kid: + raise ValueError("Token missing 'kid' in header") + + # Find matching key + signing_key = None + for key in jwks.get('keys', []): + if key.get('kid') == kid: + from jwt import PyJWK + signing_key = PyJWK(key).key + break + + if not signing_key: + raise ValueError(f"No matching key found for kid: {kid}") + + # First, decode without validation to check issuer + unverified_claims = jwt.decode(token, options={"verify_signature": False}) + token_issuer = unverified_claims.get('iss') + + # Check if issuer is valid (v1.0 or v2.0) + if token_issuer not in self.valid_issuers: + raise ValueError(f"Invalid issuer: {token_issuer}. Expected one of: {self.valid_issuers}") + + # Validate and decode token with the correct issuer + claims = jwt.decode( + token, + signing_key, + algorithms=['RS256'], + issuer=token_issuer, + audience=[self.client_id, f'api://{self.client_id}'], # Accept both formats + options={ + "verify_exp": True, + "verify_iat": True, + "verify_aud": True + } + ) + + logger.debug(f"Token validation successful for user: {claims.get('preferred_username', 'unknown')}") + + # 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}") + + return { + 'valid': True, + 'username': claims.get('preferred_username', claims.get('sub')), + 'email': claims.get('email'), + 'groups': groups, + 'scopes': claims.get('scope', '').split() if claims.get('scope') else [], + 'client_id': claims.get('azp', self.client_id), + 'method': 'entra', + 'data': claims + } + + except jwt.ExpiredSignatureError: + logger.warning("Token validation failed: Token has expired") + raise ValueError("Token has expired") + except jwt.InvalidTokenError as e: + logger.warning(f"Token validation failed: Invalid token - {e}") + raise ValueError(f"Invalid token: {e}") + except Exception as e: + logger.error(f"Entra ID token validation error: {e}") + raise ValueError(f"Token validation failed: {e}") + + def get_jwks(self) -> Dict[str, Any]: + """Get JSON Web Key Set from Entra ID with caching. + + Returns: + Dictionary containing the JWKS data + + Raises: + ValueError: If JWKS cannot be retrieved + """ + current_time = time.time() + + # Check if cache is still valid + if (self._jwks_cache and + (current_time - self._jwks_cache_time) < self._jwks_cache_ttl): + logger.debug("Using cached JWKS") + return self._jwks_cache + + try: + logger.debug(f"Fetching JWKS from {self.jwks_url}") + response = requests.get(self.jwks_url, timeout=10) + response.raise_for_status() + + self._jwks_cache = response.json() + self._jwks_cache_time = current_time + + logger.debug("JWKS fetched and cached successfully") + return self._jwks_cache + + except Exception as e: + logger.error(f"Failed to retrieve JWKS from Entra ID: {e}") + raise ValueError(f"Cannot retrieve JWKS: {e}") + + def exchange_code_for_token( + self, + code: str, + redirect_uri: str + ) -> Dict[str, Any]: + """Exchange authorization code for access token. + + 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 + """ + try: + logger.debug("Exchanging authorization code for token") + + data = { + 'grant_type': 'authorization_code', + 'code': code, + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'redirect_uri': redirect_uri + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded' + } + + response = requests.post(self.token_url, data=data, headers=headers, timeout=10) + response.raise_for_status() + + token_data = response.json() + logger.debug("Token exchange successful") + + return token_data + + except requests.RequestException as e: + logger.error(f"Failed to exchange code for token: {e}") + raise ValueError(f"Token exchange failed: {e}") + + def get_user_info( + self, + access_token: str + ) -> Dict[str, Any]: + """Get user information from Entra ID. + + Args: + access_token: Valid access token + + Returns: + Dictionary containing user information: + - username: User's preferred_username + - email: User's email + - groups: User's group memberships (Object IDs) + + Raises: + ValueError: If user info cannot be retrieved + """ + try: + logger.debug("Fetching user info from Entra ID") + + headers = {'Authorization': f'Bearer {access_token}'} + response = requests.get(self.userinfo_url, headers=headers, timeout=10) + response.raise_for_status() + + user_info = response.json() + logger.debug(f"User info retrieved for: {user_info.get('preferred_username', 'unknown')}") + + return user_info + + except requests.RequestException as e: + logger.error(f"Failed to get user info: {e}") + raise ValueError(f"User info retrieval failed: {e}") + + def get_auth_url( + self, + redirect_uri: str, + state: str, + scope: Optional[str] = None + ) -> str: + """Get Entra ID authorization URL. + + Args: + redirect_uri: URI to redirect to after authorization + state: State parameter for CSRF protection + scope: Optional scope parameter (defaults to openid email profile) + + Returns: + Full authorization URL + """ + logger.debug(f"Generating auth URL with redirect_uri: {redirect_uri}") + + params = { + 'client_id': self.client_id, + 'response_type': 'code', + 'scope': scope or 'openid email profile', + 'redirect_uri': redirect_uri, + 'state': state + } + + auth_url = f"{self.auth_url}?{urlencode(params)}" + logger.debug(f"Generated auth URL: {auth_url}") + + return auth_url + + def get_logout_url( + self, + redirect_uri: str + ) -> str: + """Get Entra ID logout URL. + + Args: + redirect_uri: URI to redirect to after logout + + Returns: + Full logout URL + """ + logger.debug(f"Generating logout URL with redirect_uri: {redirect_uri}") + + params = { + 'client_id': self.client_id, + 'post_logout_redirect_uri': redirect_uri + } + + logout_url = f"{self.logout_url}?{urlencode(params)}" + logger.debug(f"Generated logout URL: {logout_url}") + + return logout_url + + def refresh_token( + self, + refresh_token: str + ) -> Dict[str, Any]: + """Refresh an access token using a refresh token. + + Args: + refresh_token: The refresh token + + Returns: + Dictionary containing new token response + + Raises: + ValueError: If token refresh fails + """ + try: + logger.debug("Refreshing access token") + + data = { + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token, + 'client_id': self.client_id, + 'client_secret': self.client_secret + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded' + } + + response = requests.post(self.token_url, data=data, headers=headers, timeout=10) + response.raise_for_status() + + token_data = response.json() + logger.debug("Token refresh successful") + + return token_data + + except requests.RequestException as e: + logger.error(f"Failed to refresh token: {e}") + raise ValueError(f"Token refresh failed: {e}") + + def validate_m2m_token( + self, + token: str + ) -> Dict[str, Any]: + """Validate a machine-to-machine token. + + Args: + token: The M2M access token to validate + + Returns: + Dictionary containing validation result + + Raises: + ValueError: If token validation fails + """ + return self.validate_token(token) + + def get_m2m_token( + self, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + scope: Optional[str] = None + ) -> Dict[str, Any]: + """Get machine-to-machine token using client credentials. + + This method is used for AI agent authentication using Azure AD service principals. + Each AI agent should have its own service principal (app registration) in Azure AD. + + Args: + client_id: Optional client ID (uses default if not provided) + client_secret: Optional client secret (uses default if not provided) + scope: Optional scope for the token (defaults to .default) + + Returns: + Dictionary containing token response: + - access_token: The M2M access token + - token_type: "Bearer" + - expires_in: Token expiration time in seconds + + Raises: + ValueError: If token generation fails + """ + try: + logger.debug("Requesting M2M token using client credentials") + + # Default scope for Entra ID M2M tokens + if not scope: + scope = f'api://{client_id or self.client_id}/.default' + + data = { + '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' + } + + response = requests.post(self.token_url, data=data, headers=headers, timeout=10) + response.raise_for_status() + + token_data = response.json() + logger.debug("M2M token generation successful") + + return token_data + + except requests.RequestException as e: + logger.error(f"Failed to get M2M token: {e}") + raise ValueError(f"M2M token generation failed: {e}") + + def get_provider_info(self) -> Dict[str, Any]: + """Get provider-specific information. + + Returns: + Dictionary containing provider configuration and endpoints + """ + return { + 'provider_type': 'entra', + 'tenant_id': self.tenant_id, + 'client_id': self.client_id, + 'endpoints': { + 'auth': self.auth_url, + 'token': self.token_url, + 'userinfo': self.userinfo_url, + 'jwks': self.jwks_url, + 'logout': self.logout_url + }, + 'issuers': { + 'v2': self.issuer_v2, + 'v1': self.issuer_v1 + } + } diff --git a/auth_server/providers/factory.py b/auth_server/providers/factory.py index 7fda460d..999e45e9 100644 --- a/auth_server/providers/factory.py +++ b/auth_server/providers/factory.py @@ -7,6 +7,7 @@ from .base import AuthProvider from .cognito import CognitoProvider from .keycloak import KeycloakProvider +from .entra import EntraIdProvider logging.basicConfig( level=logging.INFO, @@ -20,25 +21,27 @@ def get_auth_provider( provider_type: Optional[str] = None ) -> AuthProvider: """Factory function to get the appropriate auth provider. - + Args: - provider_type: Type of provider to create ('cognito' or 'keycloak'). + provider_type: Type of provider to create ('cognito', 'keycloak', or 'entra'). 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 """ provider_type = provider_type or os.environ.get('AUTH_PROVIDER', 'cognito') - + logger.info(f"Creating authentication provider: {provider_type}") - + if provider_type == 'keycloak': return _create_keycloak_provider() elif provider_type == 'cognito': return _create_cognito_provider() + elif provider_type == 'entra': + return _create_entra_provider() else: raise ValueError(f"Unknown auth provider: {provider_type}") @@ -121,6 +124,37 @@ 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') + + # Validate required configuration + missing_vars = [] + if not tenant_id: + missing_vars.append('ENTRA_TENANT_ID') + if not client_id: + missing_vars.append('ENTRA_CLIENT_ID') + if not client_secret: + missing_vars.append('ENTRA_CLIENT_SECRET') + + if missing_vars: + raise ValueError( + f"Missing required Entra ID configuration: {', '.join(missing_vars)}. " + "Please set these environment variables." + ) + + logger.info(f"Initializing Entra ID provider for tenant '{tenant_id}'") + + return EntraIdProvider( + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret + ) + + def _get_provider_health_info() -> dict: """Get health information for the current provider.""" try: diff --git a/auth_server/scopes.yml b/auth_server/scopes.yml index 4bfbdb5d..74f011f3 100644 --- a/auth_server/scopes.yml +++ b/auth_server/scopes.yml @@ -298,4 +298,4 @@ registry-admins: - all - action: delete_agent resources: - - all + - all \ No newline at end of file diff --git a/auth_server/server.py b/auth_server/server.py index dcc92c8c..99ceec11 100644 --- a/auth_server/server.py +++ b/auth_server/server.py @@ -1034,12 +1034,13 @@ async def validate_request(request: Request): logger.error(f"Error processing request payload for tool extraction: {e}") # Validate scope-based access if we have server/tool information - # For Keycloak, map groups to scopes; otherwise use scopes directly + # For providers that use groups (Keycloak, Entra ID, Cognito), map groups to scopes user_groups = validation_result.get('groups', []) - if user_groups and validation_result.get('method') == 'keycloak': - # Map Keycloak groups to scopes using the group mappings + auth_method = validation_result.get('method', '') + if user_groups and auth_method in ['keycloak', 'entra', 'cognito']: + # Map IdP groups to scopes using the group mappings user_scopes = map_groups_to_scopes(user_groups) - logger.info(f"Mapped Keycloak groups {user_groups} to scopes: {user_scopes}") + logger.info(f"Mapped {auth_method} groups {user_groups} to scopes: {user_scopes}") else: user_scopes = validation_result.get('scopes', []) if server_name: @@ -1729,6 +1730,39 @@ async def oauth2_callback( logger.info(f"Raw user info from {provider}: {user_info}") mapped_user = map_user_info(user_info, provider_config) logger.info(f"Mapped user info from userInfo: {mapped_user}") + 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") + + except Exception as e: + logger.warning(f"Entra ID token parsing failed: {e}, falling back to userInfo endpoint") + # Fallback to userInfo endpoint + user_info = await get_user_info(token_data["access_token"], provider_config) + logger.info(f"Raw user info from {provider}: {user_info}") + mapped_user = map_user_info(user_info, provider_config) + logger.info(f"Mapped user info from userInfo: {mapped_user}") else: # For other providers, use userInfo endpoint user_info = await get_user_info(token_data["access_token"], provider_config) diff --git a/credentials-provider/oauth/ingress_oauth.py b/credentials-provider/oauth/ingress_oauth.py index 31156696..c5bba315 100644 --- a/credentials-provider/oauth/ingress_oauth.py +++ b/credentials-provider/oauth/ingress_oauth.py @@ -3,11 +3,11 @@ Ingress OAuth Authentication Script This script handles OAuth authentication for ingress (inbound) connections to the MCP Gateway. -It supports both Cognito and Keycloak M2M (Machine-to-Machine) authentication based on AUTH_PROVIDER. +It supports Cognito, Keycloak, and Entra ID M2M (Machine-to-Machine) authentication based on AUTH_PROVIDER. The script: 1. Validates required INGRESS OAuth environment variables -2. Performs M2M authentication using client_credentials grant (Cognito or Keycloak) +2. Performs M2M authentication using client_credentials grant 3. Saves tokens to ingress.json in the OAuth tokens directory 4. Does not generate MCP configuration files (handled by oauth_creds.sh) @@ -24,6 +24,11 @@ - KEYCLOAK_M2M_CLIENT_ID: Keycloak M2M client ID - KEYCLOAK_M2M_CLIENT_SECRET: Keycloak M2M client secret +For AUTH_PROVIDER=entra: +- ENTRA_TENANT_ID: Azure AD Tenant ID (GUID) +- ENTRA_CLIENT_ID: App Registration Client ID (GUID) +- ENTRA_CLIENT_SECRET: App Registration Client Secret + Usage: python ingress_oauth.py python ingress_oauth.py --verbose @@ -81,6 +86,12 @@ def _validate_environment_variables() -> None: "KEYCLOAK_M2M_CLIENT_ID", "KEYCLOAK_M2M_CLIENT_SECRET" ] + elif auth_provider == "entra": + required_vars = [ + "ENTRA_TENANT_ID", + "ENTRA_CLIENT_ID", + "ENTRA_CLIENT_SECRET" + ] else: # cognito (default) required_vars = [ "INGRESS_OAUTH_USER_POOL_ID", @@ -194,6 +205,86 @@ def _perform_keycloak_m2m_authentication( raise +def _perform_entra_m2m_authentication( + tenant_id: str, + client_id: str, + client_secret: str +) -> Dict[str, Any]: + """Perform M2M (client credentials) OAuth 2.0 authentication with Microsoft Entra ID.""" + try: + # Generate token URL for Entra ID + token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + + # Prepare the token request + payload = { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "scope": f"api://{client_id}/.default" + } + + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json" + } + + logger.info(f"Requesting M2M token from {token_url}") + logger.debug(f"Using client_id: {client_id[:10]}..." if client_id else "No client_id") + + response = requests.post( + token_url, + data=payload, + headers=headers, + timeout=30 + ) + + if not response.ok: + logger.error(f"M2M token request failed with status {response.status_code}. Response: {response.text}") + raise ValueError(f"Token request failed: {response.text}") + + token_data = response.json() + + if "access_token" not in token_data: + logger.error(f"Access token not found in M2M response. Keys found: {list(token_data.keys())}") + raise ValueError("No access token in response") + + # Calculate expiry time + expires_at = None + if "expires_in" in token_data: + expires_at = time.time() + token_data["expires_in"] + else: + # Fallback: assume 3599 seconds (1 hour) validity if not specified + logger.warning("No expires_in in token response, assuming 3599 seconds validity") + expires_at = time.time() + 3599 + token_data["expires_in"] = 3599 + + # Prepare result + result = { + "access_token": token_data["access_token"], + "refresh_token": token_data.get("refresh_token"), # M2M typically doesn't have refresh tokens + "expires_at": expires_at, + "token_type": token_data.get("token_type", "Bearer"), + "provider": "entra_m2m", + "client_id": client_id, + "tenant_id": tenant_id + } + + logger.info("M2M token obtained successfully!") + + if expires_at: + expires_in = int(expires_at - time.time()) + logger.info(f"Token expires in: {expires_in} seconds") + + return result + + except requests.exceptions.RequestException as e: + logger.error(f"Network error during M2M token request: {e}") + raise + except Exception as e: + logger.error(f"Failed to obtain M2M token: {e}") + raise + + def _perform_m2m_authentication( client_id: str, client_secret: str, @@ -313,6 +404,11 @@ def _save_ingress_tokens(token_data: Dict[str, Any]) -> str: "realm": token_data["realm"], "usage_notes": "This token is for INGRESS authentication to the MCP Gateway (Keycloak M2M)" }) + elif provider == "entra_m2m": + save_data.update({ + "tenant_id": token_data["tenant_id"], + "usage_notes": "This token is for INGRESS authentication to the MCP Gateway (Entra ID M2M)" + }) else: # cognito_m2m save_data.update({ "user_pool_id": token_data["user_pool_id"], @@ -364,7 +460,7 @@ def _load_existing_tokens() -> Optional[Dict[str, Any]]: def main() -> int: """Main entry point.""" parser = argparse.ArgumentParser( - description="Ingress OAuth Authentication for MCP Gateway (Cognito or Keycloak M2M)", + description="Ingress OAuth Authentication for MCP Gateway (Cognito, Keycloak, or Entra ID M2M)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: @@ -384,6 +480,11 @@ def main() -> int: KEYCLOAK_REALM # Keycloak realm name KEYCLOAK_M2M_CLIENT_ID # Keycloak M2M client ID KEYCLOAK_M2M_CLIENT_SECRET # Keycloak M2M client secret + +For AUTH_PROVIDER=entra: + ENTRA_TENANT_ID # Azure AD Tenant ID (GUID) + ENTRA_CLIENT_ID # App Registration Client ID (GUID) + ENTRA_CLIENT_SECRET # App Registration Client Secret """ ) @@ -422,28 +523,42 @@ def main() -> int: client_secret = os.getenv("KEYCLOAK_M2M_CLIENT_SECRET") keycloak_url = os.getenv("KEYCLOAK_ADMIN_URL") or os.getenv("KEYCLOAK_EXTERNAL_URL") or os.getenv("KEYCLOAK_URL") realm = os.getenv("KEYCLOAK_REALM") - + logger.info(f"Keycloak URL: {keycloak_url}") logger.info(f"Realm: {realm}") logger.info(f"Client ID: {client_id[:10]}...") - + token_data = _perform_keycloak_m2m_authentication( client_id=client_id, client_secret=client_secret, keycloak_url=keycloak_url, realm=realm ) + elif auth_provider == "entra": + # Get Entra ID configuration from environment + tenant_id = os.getenv("ENTRA_TENANT_ID") + client_id = os.getenv("ENTRA_CLIENT_ID") + client_secret = os.getenv("ENTRA_CLIENT_SECRET") + + logger.info(f"Tenant ID: {tenant_id}") + logger.info(f"Client ID: {client_id[:10]}...") + + token_data = _perform_entra_m2m_authentication( + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret + ) else: # cognito (default) # Get Cognito configuration from environment client_id = os.getenv("INGRESS_OAUTH_CLIENT_ID") - client_secret = os.getenv("INGRESS_OAUTH_CLIENT_SECRET") + client_secret = os.getenv("INGRESS_OAUTH_CLIENT_SECRET") user_pool_id = os.getenv("INGRESS_OAUTH_USER_POOL_ID") region = os.getenv("AWS_REGION", "us-east-1") - + logger.info(f"User Pool ID: {user_pool_id}") logger.info(f"Client ID: {client_id[:10]}...") logger.info(f"Region: {region}") - + token_data = _perform_m2m_authentication( client_id=client_id, client_secret=client_secret, diff --git a/docker-compose.yml b/docker-compose.yml index 58992f42..aebf583b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -118,6 +118,11 @@ services: - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} - KEYCLOAK_M2M_CLIENT_ID=${KEYCLOAK_M2M_CLIENT_ID:-mcp-gateway-m2m} - KEYCLOAK_M2M_CLIENT_SECRET=${KEYCLOAK_M2M_CLIENT_SECRET} + # Entra ID configuration + - ENTRA_TENANT_ID=${ENTRA_TENANT_ID} + - ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID} + - ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET} + - ENTRA_ENABLED=${ENTRA_ENABLED:-false} ports: - "8888:8888" volumes: diff --git a/docs/entra-id-setup.md b/docs/entra-id-setup.md new file mode 100644 index 00000000..f6d2f0d0 --- /dev/null +++ b/docs/entra-id-setup.md @@ -0,0 +1,646 @@ +# Microsoft Entra ID 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) + +--- + +## 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: + +```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 +``` + +3. Update other required settings: + +```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 +``` + +--- + +## Group Configuration + +### Configure scopes.yml + +The `auth_server/scopes.yml` file maps Azure AD groups to MCP Gateway scopes and permissions. + +1. Open `auth_server/scopes.yml` + +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 + +``` + +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 + ``` + +2. You should see the MCP Gateway Registry login page + +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 + +### Issue: Missing email and groups claims + +**Symptoms:** +``` +Raw user info from entra: {'sub': '...', 'name': 'User Name', 'family_name': '...', 'given_name': '...'} +Mapped user info: {'username': None, 'email': None, 'groups': []} +``` + +**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 + +### Issue: Token validation fails with "Invalid issuer" + +**Symptoms:** +``` +Token validation failed: Invalid issuer: https://sts.windows.net/{tenant}/ +``` + +**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 +``` + +**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 + +**Symptoms:** +Groups claim contains `_claim_names` and `_claim_sources` instead of group IDs + +**Solution:** +This occurs when a user is a member of more than 200 groups. You need to: + +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 + +### Issue: Client secret expired + +**Symptoms:** +``` +AADSTS7000215: Invalid client secret provided +``` + +**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 + ``` + +### Issue: Cannot grant admin consent + +**Symptoms:** +You don't see the "Grant admin consent" button or get an error when clicking it + +**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) + +--- + +## Additional Resources + +- [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) + +--- + +## Production Deployment + +### Update Redirect URIs + +For production, update redirect URIs: +``` +https://your-domain.com/oauth2/callback/entra +``` + +### Environment Variables + +Update production `.env`: +```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 + +### Custom Claims + +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 + +### Group Filtering + +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** + +### Enterprise Applications + +For advanced management: +1. Go to **Enterprise applications** +2. Find your app registration +3. Configure: + - User assignment required + - Visibility settings + - Provisioning (if needed) + +--- + +## Adding New Users + +### Option 1: Add User to Existing Group (Recommended) + +**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** + +**Access will be immediate** - user can login and see servers/agents. + +### Option 2: Create New Group for User + +**If you need different permissions:** + +1. **Create new group in Azure:** + - **Group name**: `MCP Registry LOB3 Users` + - **Members**: Add the new user + +2. **Get the group Object ID** from the group overview page + +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 +``` + +4. **Restart auth server:** +```bash +cp auth_server/scopes.yml ~/mcp-gateway/auth_server/scopes.yml +docker-compose restart auth-server +``` + +--- + +## API Reference + +### Token Endpoint +``` +POST https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token +``` + +### Authorization Endpoint +``` +GET https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize +``` + +### 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 + +--- + +## 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) diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 016e9562..a51d0a16 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -24,14 +24,15 @@ const Login: React.FC = () => { const [searchParams] = useSearchParams(); useEffect(() => { + console.log('[Login] Component mounted, fetching OAuth providers...'); fetchOAuthProviders(); - + // Check for error parameter from URL (e.g., from OAuth callback) const urlError = searchParams.get('error'); if (urlError) { setError(decodeURIComponent(urlError)); } - + // Check if user preferences exist const savedRememberMe = localStorage.getItem('rememberMe') === 'true'; const savedUsername = localStorage.getItem('savedUsername'); @@ -41,13 +42,22 @@ const Login: React.FC = () => { } }, [searchParams]); + // Log when oauthProviders state changes + useEffect(() => { + console.log('[Login] oauthProviders state changed:', oauthProviders); + }, [oauthProviders]); + const fetchOAuthProviders = async () => { try { + console.log('[Login] Fetching OAuth providers from /api/auth/providers'); // Call the registry auth providers endpoint const response = await axios.get('/api/auth/providers'); + console.log('[Login] Response received:', response.data); + console.log('[Login] Providers:', response.data.providers); setOauthProviders(response.data.providers || []); + console.log('[Login] State updated with', response.data.providers?.length || 0, 'providers'); } catch (error) { - console.error('Failed to fetch OAuth providers:', error); + console.error('[Login] Failed to fetch OAuth providers:', error); // Don't show error for missing OAuth providers, just continue with basic auth } }; @@ -141,7 +151,7 @@ const Login: React.FC = () => { const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; const currentOrigin = window.location.origin; const redirectUri = encodeURIComponent(currentOrigin + '/'); - + if (isLocalhost) { window.location.href = `http://localhost:8888/oauth2/login/${provider}?redirect_uri=${redirectUri}`; } else { diff --git a/registry/api/server_routes.py b/registry/api/server_routes.py index 12d77df9..788eb14e 100644 --- a/registry/api/server_routes.py +++ b/registry/api/server_routes.py @@ -2052,8 +2052,8 @@ async def generate_user_token( "token_type": token_data.get("token_type", "Bearer"), "scope": token_data.get("scope", "") }, - "keycloak_url": "http://keycloak:8080", - "realm": "mcp-gateway", + "keycloak_url": getattr(settings, 'keycloak_url', None) or "http://keycloak:8080", + "realm": getattr(settings, 'keycloak_realm', None) or "mcp-gateway", "client_id": "user-generated", # Legacy fields for backward compatibility "token_data": token_data, diff --git a/registry/auth/dependencies.py b/registry/auth/dependencies.py index 56d2a5c5..62b30a3c 100644 --- a/registry/auth/dependencies.py +++ b/registry/auth/dependencies.py @@ -505,10 +505,10 @@ def nginx_proxied_auth( # Parse scopes from space-separated header scopes = x_scopes.split() if x_scopes else [] - # For Keycloak auth, map scopes to get groups + # Map scopes to get groups based on auth method groups = [] - if x_auth_method == 'keycloak': - # User authenticated via Keycloak JWT + if x_auth_method in ['keycloak', 'entra', 'cognito']: + # User authenticated via OAuth2 JWT (Keycloak, Entra ID, or Cognito) # Scopes already contain mapped permissions # Check if user has admin scopes if 'mcp-servers-unrestricted/read' in scopes and 'mcp-servers-unrestricted/execute' in scopes: @@ -538,7 +538,7 @@ def nginx_proxied_auth( 'groups': groups, 'scopes': scopes, 'auth_method': x_auth_method or 'keycloak', - 'provider': 'keycloak', + 'provider': x_auth_method or 'keycloak', # Use actual auth method as provider 'accessible_servers': accessible_servers, 'accessible_services': accessible_services, 'accessible_agents': accessible_agents,