From 064fd0672409ff7d67e649f99fdbdc15052da076 Mon Sep 17 00:00:00 2001 From: Nisha Deborah Philips Date: Mon, 3 Nov 2025 20:59:43 -0600 Subject: [PATCH 01/15] (wip): setting up entra-id --- .env.example | 32 +- auth_server/oauth2_providers.yml | 19 + auth_server/providers/entra.py | 470 ++++++++ auth_server/providers/factory.py | 46 +- auth_server/scopes.yml | 11 + auth_server/server.py | 42 +- docker-compose.yml | 5 + docs/ENTRA-ID-APP-CONFIGURATION.md | 161 +++ docs/entra-id-implementation-option1.md | 1469 +++++++++++++++++++++++ frontend/src/pages/Login.tsx | 18 +- 10 files changed, 2257 insertions(+), 16 deletions(-) create mode 100644 auth_server/providers/entra.py create mode 100644 docs/ENTRA-ID-APP-CONFIGURATION.md create mode 100644 docs/entra-id-implementation-option1.md diff --git a/.env.example b/.env.example index e85f2775..3f145fc3 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 # ============================================================================= 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..6d1e7673 --- /dev/null +++ b/auth_server/providers/entra.py @@ -0,0 +1,470 @@ +"""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 + return { + 'valid': True, + 'username': claims.get('preferred_username', claims.get('sub')), + 'email': claims.get('email'), + 'groups': claims.get('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 f0bdde12..ef3a1f02 100644 --- a/auth_server/scopes.yml +++ b/auth_server/scopes.yml @@ -31,6 +31,7 @@ UI-Scopes: toggle_service: - all group_mappings: + # Keycloak/Generic group mappings (by group name) mcp-registry-admin: - mcp-registry-admin - mcp-servers-unrestricted/read @@ -52,6 +53,16 @@ group_mappings: mcp-servers-restricted: - mcp-servers-restricted/read - mcp-servers-restricted/execute + # Entra ID group mappings (by Azure AD Group Object IDs) + # Admin group: Mcp-test-admin + "16c7e67e-e8ae-498c-ba2e-0593c0159e43": + - mcp-registry-admin + - mcp-servers-unrestricted/read + - mcp-servers-unrestricted/execute + # Users group: mcp-test-users + "62c07ac1-03d0-4924-90c7-a0255f23bd1d": + - mcp-registry-user + - mcp-servers-restricted/read mcp-servers-unrestricted/read: - server: auth_server methods: diff --git a/auth_server/server.py b/auth_server/server.py index 5599dfa7..ab7ff86a 100644 --- a/auth_server/server.py +++ b/auth_server/server.py @@ -1024,12 +1024,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 request_payload and server_name and tool_name: @@ -1719,6 +1720,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/docker-compose.yml b/docker-compose.yml index e844ba1d..5e886ba9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -115,6 +115,11 @@ services: - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} - KEYCLOAK_M2M_CLIENT_ID=${KEYCLOAK_M2M_CLIENT_ID:-mcp-gateway-m2m} - KEYCLOAK_M2M_CLIENT_SECRET=${KEYCLOAK_M2M_CLIENT_SECRET} + # 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-APP-CONFIGURATION.md b/docs/ENTRA-ID-APP-CONFIGURATION.md new file mode 100644 index 00000000..e135fd01 --- /dev/null +++ b/docs/ENTRA-ID-APP-CONFIGURATION.md @@ -0,0 +1,161 @@ +# Microsoft Entra ID App Registration Configuration + +## Issue: Missing Email and Groups Claims + +After implementing Entra ID authentication, you may find that the userinfo endpoint does not return `email` or `groups` claims. This is because Microsoft Entra ID requires explicit configuration to include these claims. + +## Current Symptoms + +From the auth server logs: +``` +Raw user info from entra: {'sub': '...', 'name': 'Debbie Philips', 'family_name': 'Philips', 'given_name': 'Debbie', 'picture': '...'} +Mapped user info: {'username': None, 'email': None, 'name': 'Debbie Philips', 'groups': []} +``` + +**Missing:** +- `email` claim +- `preferred_username` claim +- `groups` claim + +## Solution: Configure App Registration + +### Step 1: Add API Permissions for Groups + +1. Go to [Azure Portal](https://portal.azure.com) +2. Navigate to **Azure Active Directory** → **App registrations** +3. Select your app: `mcp-gateway-web` (Client ID: `150f50ad-d0ca-4d7a-bb4b-75fbaf31acc1`) +4. Click **API permissions** in the left menu +5. Click **Add a permission** +6. Select **Microsoft Graph** +7. Select **Delegated permissions** +8. Search for and add: + - `GroupMember.Read.All` - Read groups user belongs to + - `User.Read` - Should already be present + - `email` - Read user's email address + - `profile` - Read user's basic profile +9. Click **Add permissions** +10. Click **Grant admin consent for [Your Tenant]** - This is **REQUIRED** + +### Step 2: Configure Optional Claims + +1. In your app registration, 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 3: Configure Group Claims + +1. Still in **Token configuration** +2. Click **Add groups claim** +3. Select **Security groups** +4. Under "Customize token properties by type", select: + - **ID**: Check "Group ID" + - **Access**: Check "Group ID" +5. Click **Add** + +### Step 4: Verify Manifest (Optional) + +You can verify the configuration in the app manifest: + +1. Click **Manifest** in the left menu +2. Look for `optionalClaims`: + +```json +"optionalClaims": { + "idToken": [ + { + "name": "email", + "source": null, + "essential": false, + "additionalProperties": [] + }, + { + "name": "preferred_username", + "source": null, + "essential": false, + "additionalProperties": [] + }, + { + "name": "groups", + "source": null, + "essential": false, + "additionalProperties": [] + } + ], + "accessToken": [ + { + "name": "groups", + "source": null, + "essential": false, + "additionalProperties": [] + } + ] +} +``` + +3. Look for `groupMembershipClaims`: +```json +"groupMembershipClaims": "SecurityGroup" +``` + +### Step 5: Alternative - Use Microsoft Graph API + +If you cannot enable `GroupMember.Read.All` permission, you can modify the code to fetch groups via Microsoft Graph API: + +```python +# In auth_server/providers/entra.py, add a method: +def get_user_groups(self, access_token: str) -> List[str]: + """Fetch user's group memberships from Microsoft Graph.""" + try: + headers = {'Authorization': f'Bearer {access_token}'} + # Request group Object IDs + response = requests.get( + 'https://graph.microsoft.com/v1.0/me/memberOf?$select=id,displayName', + headers=headers, + timeout=10 + ) + response.raise_for_status() + + data = response.json() + # Return list of group Object IDs + return [group['id'] for group in data.get('value', [])] + except Exception as e: + logger.error(f"Failed to fetch user groups: {e}") + return [] +``` + +Then call this in the callback handler instead of relying on the groups claim. + +## Testing the Configuration + +After making these changes: + +1. Wait 5-10 minutes for Azure AD to propagate the changes +2. Clear your browser cookies for `localhost` +3. Try logging in again +4. Check the auth server logs for: +``` +Raw user info from entra: {'sub': '...', 'email': 'DebbiePhilips@AWS139.onmicrosoft.com', 'preferred_username': 'DebbiePhilips@AWS139.onmicrosoft.com', 'groups': ['16c7e67e-...', '62c07ac1-...'], ...} +``` + +## Expected Result + +After configuration, you should see: +- `email`: `DebbiePhilips@AWS139.onmicrosoft.com` +- `preferred_username`: `DebbiePhilips@AWS139.onmicrosoft.com` +- `groups`: `['16c7e67e-e8ae-498c-ba2e-0593c0159e43', '62c07ac1-03d0-4924-90c7-a0255f23bd1d']` + +The user will be mapped to scopes based on their group membership in `scopes.yml`: +- Admin group (`16c7e67e-...`): `mcp-registry-admin`, `mcp-servers-unrestricted/read`, `mcp-servers-unrestricted/execute` +- Users group (`62c07ac1-...`): `mcp-registry-user`, `mcp-servers-restricted/read` + +## References + +- [Microsoft Entra ID optional claims](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](https://learn.microsoft.com/en-us/graph/permissions-reference) diff --git a/docs/entra-id-implementation-option1.md b/docs/entra-id-implementation-option1.md new file mode 100644 index 00000000..b6bec32c --- /dev/null +++ b/docs/entra-id-implementation-option1.md @@ -0,0 +1,1469 @@ +# Option 1: Pure Configuration Approach - Add Entra ID Support + +## Core Concept + +**The Big Idea:** Your `oauth2_providers.yml` file already defines how OAuth providers work. Both Keycloak and Cognito are just OIDC providers with different URLs. Entra ID is the same - just another set of URLs! + +**What if** we could add Entra ID support by: +1. Adding Entra ID config to `oauth2_providers.yml` (like you have for Keycloak/Cognito) +2. Creating a minimal `EntraIdProvider` class that reuses existing logic +3. Adding an `elif` case in the factory + +No refactoring. No generic base class. Just add Entra ID as a third option. + +--- + +## How It Works + +### Current State + +Right now your `factory.py` does: + +```python +def get_auth_provider(provider_type: Optional[str] = None) -> AuthProvider: + provider_type = provider_type or os.environ.get('AUTH_PROVIDER', 'cognito') + + if provider_type == 'keycloak': + return _create_keycloak_provider() + elif provider_type == 'cognito': + return _create_cognito_provider() + else: + raise ValueError(f"Unknown auth provider: {provider_type}") +``` + +### Option 1 Changes + +**Step 1: Add Entra ID case to factory** + +```python +def get_auth_provider(provider_type: Optional[str] = None) -> AuthProvider: + provider_type = provider_type or os.environ.get('AUTH_PROVIDER', 'cognito') + + if provider_type == 'keycloak': + return _create_keycloak_provider() + elif provider_type == 'cognito': + return _create_cognito_provider() + elif provider_type == 'entra': # ← NEW + return _create_entra_provider() # ← NEW + else: + raise ValueError(f"Unknown auth provider: {provider_type}") +``` + +**Step 2: Add the helper function** + +```python +def _create_entra_provider() -> 'EntraIdProvider': # Note: return type annotation + """Create and configure Entra ID provider.""" + # Get environment variables + tenant_id = os.environ.get('ENTRA_TENANT_ID') + client_id = os.environ.get('ENTRA_CLIENT_ID') + client_secret = os.environ.get('ENTRA_CLIENT_SECRET') + + # 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}'") + + # Import here to avoid circular imports + from .entra import EntraIdProvider + + return EntraIdProvider( + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret + ) +``` + +That's literally all that changes in `factory.py` - add an elif and a helper function! + +--- + +## The EntraIdProvider Class + +**Key Insight:** Look at `CognitoProvider` and `KeycloakProvider`. They're ~90% identical: +- Same `get_jwks()` method +- Same JWT validation logic +- Same `exchange_code_for_token()` flow +- Same `refresh_token()` logic +- Same `get_m2m_token()` for client credentials + +**The only differences:** +1. **URLs**: Different endpoints (auth_url, token_url, etc.) +2. **Groups claim name**: `cognito:groups` vs `groups` vs Keycloak's `groups` +3. **Issuer format**: Different issuer URL patterns + +### Create EntraIdProvider by Copying CognitoProvider + +**File:** `auth_server/providers/entra.py` + +```python +"""Microsoft Entra ID 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 + +logger = logging.getLogger(__name__) + + +class EntraIdProvider(AuthProvider): + """Microsoft Entra ID (Azure AD) authentication provider. + + This is essentially CognitoProvider with different URLs and claim names. + """ + + 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 - EXACT SAME as Cognito/Keycloak + 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 - ONLY DIFFERENCE from Cognito is URLs + 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.issuer = f"{base_url}/v2.0" + + logger.debug(f"Initialized Entra ID provider for tenant '{tenant_id}'") + + # ======================================================================== + # COPY-PASTE from CognitoProvider with minimal changes + # ======================================================================== + + def validate_token(self, token: str, **kwargs: Any) -> Dict[str, Any]: + """Validate Entra ID JWT token. + + COPIED FROM: CognitoProvider.validate_token() (lines 71-137) + CHANGES: + - issuer = self.issuer (not Cognito-specific) + - groups claim = 'groups' (not 'cognito:groups') + - method = 'entra' (not 'cognito') + """ + try: + logger.debug("Validating Entra ID JWT token") + + # Get JWKS for validation + jwks = self.get_jwks() + + # Decode token header to get key ID + unverified_header = jwt.get_unverified_header(token) + kid = unverified_header.get('kid') + + if not kid: + raise ValueError("Token missing 'kid' in header") + + # Find matching key + signing_key = None + for key in jwks.get('keys', []): + if key.get('kid') == kid: + from jwt import PyJWK + signing_key = PyJWK(key).key + break + + if not signing_key: + raise ValueError(f"No matching key found for kid: {kid}") + + # Validate and decode token + claims = jwt.decode( + token, + signing_key, + algorithms=['RS256'], + issuer=self.issuer, # ← CHANGED: was Cognito issuer + audience=self.client_id, + 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 + return { + 'valid': True, + 'username': claims.get('preferred_username', claims.get('sub')), + 'email': claims.get('email'), + 'groups': claims.get('groups', []), # ← CHANGED: was 'cognito:groups' + 'scopes': claims.get('scope', '').split() if claims.get('scope') else [], + 'client_id': claims.get('azp', self.client_id), + 'method': 'entra', # ← CHANGED: was 'cognito' + '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. + + COPIED FROM: CognitoProvider.get_jwks() (lines 140-163) + CHANGES: None! Identical code, just different self.jwks_url + """ + 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. + + COPIED FROM: CognitoProvider.exchange_code_for_token() (lines 166-197) + CHANGES: None! Identical code, just different self.token_url + """ + 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. + + COPIED FROM: CognitoProvider.get_user_info() (lines 200-219) + CHANGES: None! Identical code, just different self.userinfo_url + """ + 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. + + COPIED FROM: CognitoProvider.get_auth_url() (lines 222-242) + CHANGES: Default scope includes 'User.Read' for Entra ID + """ + 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. + + COPIED FROM: CognitoProvider.get_logout_url() (lines 245-260) + CHANGES: Parameter name is 'post_logout_redirect_uri' for Entra ID + """ + logger.debug(f"Generating logout URL with redirect_uri: {redirect_uri}") + + params = { + 'client_id': self.client_id, + 'post_logout_redirect_uri': redirect_uri # ← CHANGED: was 'logout_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. + + COPIED FROM: CognitoProvider.refresh_token() (lines 263-292) + CHANGES: None! Identical code + """ + 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. + + COPIED FROM: CognitoProvider.validate_m2m_token() (lines 295-301) + CHANGES: None! Identical code + """ + 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. + + COPIED FROM: CognitoProvider.get_m2m_token() (lines 304-337) + CHANGES: None! Identical code + """ + try: + logger.debug("Requesting M2M token using client credentials") + + data = { + 'grant_type': 'client_credentials', + 'client_id': client_id or self.client_id, + 'client_secret': client_secret or self.client_secret + } + + if scope: + data['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. + + COPIED FROM: CognitoProvider.get_provider_info() (lines 340-355) + CHANGES: Provider type and keys renamed + """ + 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 + }, + 'issuer': self.issuer + } +``` + +**That's the entire class! ~280 lines, 90% copy-pasted from CognitoProvider.** + +--- + +## Environment Variables + +**File:** `.env.example` + +Add these lines: + +```bash +# ============================================================================ +# Microsoft Entra ID Configuration (if AUTH_PROVIDER=entra) +# ============================================================================ + +# Provider selection +AUTH_PROVIDER=entra # or cognito, keycloak + +# Required Entra ID settings +ENTRA_TENANT_ID=12345678-1234-1234-1234-123456789012 +ENTRA_CLIENT_ID=87654321-4321-4321-4321-210987654321 +ENTRA_CLIENT_SECRET=your_client_secret_here +``` + +--- + +## Configuration in oauth2_providers.yml (Optional) + +If you want web-based OAuth flow to show "Login with Microsoft" button: + +**File:** `auth_server/oauth2_providers.yml` + +Add this section: + +```yaml + 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" + scopes: ["openid", "email", "profile"] + response_type: "code" + grant_type: "authorization_code" + username_claim: "preferred_username" + email_claim: "email" + groups_claim: "groups" + enabled: "${ENTRA_ENABLED:-false}" +``` + +--- + +## How to Use It + +### Step 1: Azure Portal Setup + +1. Go to Azure Portal → Azure Active Directory → App registrations +2. Click "New registration" +3. Name: "MCP Gateway" +4. Redirect URI: `https://your-gateway.com/auth/callback` +5. Click "Register" +6. Copy Application (client) ID → This is `ENTRA_CLIENT_ID` +7. Copy Directory (tenant) ID → This is `ENTRA_TENANT_ID` +8. Go to "Certificates & secrets" → New client secret +9. Copy the secret value → This is `ENTRA_CLIENT_SECRET` + +### Step 2: Configure API Permissions (for Group Claims) + +1. In your App Registration, go to "API permissions" +2. Click "Add a permission" +3. Select "Microsoft Graph" → "Delegated permissions" +4. Add these permissions: + - `User.Read` (read basic user profile) + - `email` (read user's email) + - `openid` (OpenID Connect) + - `profile` (read user's basic profile) +5. **Optional (for group claims)**: Add `GroupMember.Read.All` or `Directory.Read.All` +6. Click "Grant admin consent" button (requires admin) + +### Step 3: Configure Token Configuration (for Groups in Token) + +By default, Azure AD doesn't include groups in the token. To enable: + +1. In your App Registration, go to "Token configuration" +2. Click "Add groups claim" +3. Select "Security groups" +4. Check "Emit groups as group IDs" under ID and Access tokens +5. Click "Add" + +**Note:** If users have 200+ groups, Azure AD will use the "groups overage" claim and you'll need to fetch groups via Microsoft Graph API (see Phase 2 enhancement below). + +### Step 4: Create Security Groups + +1. Go to Azure Active Directory → Groups +2. Click "New group" +3. Create groups matching your scopes: + - `mcp-servers-unrestricted` - Full access group + - `mcp-servers-restricted` - Limited access group +4. Copy each group's "Object ID" (GUID) +5. Add these to your `auth_server/scopes.yml`: + +```yaml +group_mappings: + # Use Azure AD Group Object IDs + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": # Object ID of mcp-servers-unrestricted + - mcp-registry-admin + - mcp-servers-unrestricted/read + - mcp-servers-unrestricted/execute + + "ffffffff-gggg-hhhh-iiii-jjjjjjjjjjjj": # Object ID of mcp-servers-restricted + - mcp-registry-user + - mcp-servers-restricted/read +``` + +### Step 5: Configure MCP Gateway + +Edit your `.env` file: + +```bash +AUTH_PROVIDER=entra +ENTRA_TENANT_ID= +ENTRA_CLIENT_ID= +ENTRA_CLIENT_SECRET= +``` + +### Step 6: Restart Auth Server + +```bash +docker-compose restart auth-server +``` + +### Step 7: Test + +Visit your gateway login page - you should see it redirects to Microsoft login! + +--- + +## Why This Works + +**The secret:** OIDC is OIDC. All providers (Keycloak, Cognito, Entra ID, Okta, Auth0, Google) implement the same protocol: + +1. **Authorization Code Flow:** + - Redirect to `auth_url` with client_id, redirect_uri, scope + - Get authorization code back + - Exchange code for token at `token_url` + +2. **Token Validation:** + - Fetch JWKS from `jwks_url` + - Verify JWT signature using JWKS + - Validate issuer, audience, expiration + +3. **Client Credentials (M2M):** + - POST to `token_url` with grant_type=client_credentials + - Get access token back + +**The only differences are URLs and claim names.** That's why you can copy-paste 90% of the code! + +--- + +## Summary of Changes + +| File | Changes | Lines | +|------|---------|-------| +| `auth_server/providers/entra.py` | **NEW FILE** - Copy CognitoProvider | ~280 | +| `auth_server/providers/factory.py` | Add `elif` + helper function | ~30 | +| `.env.example` | Add 3 env vars | ~5 | +| `auth_server/oauth2_providers.yml` | Add entra section (optional) | ~15 | + +**Total: ~330 lines of code, most copy-pasted** + +**Time estimate: 4-6 hours** (1 day) + +--- + +## What You Get + +✅ Users can login with Microsoft accounts +✅ AI agents can use Entra ID service principals +✅ Group-based permissions work (Azure AD security groups) +✅ All existing FGAC and scopes work unchanged +✅ No refactoring of existing code +✅ No breaking changes + +--- + +## Testing + +### Manual Testing + +1. **User Login Flow:** + ```bash + # Set AUTH_PROVIDER=entra in .env + # Restart auth-server + # Visit http://localhost:7860/login + # Should redirect to Microsoft login + # After login, should see MCP Gateway UI + ``` + +2. **M2M Token Generation:** + ```bash + # Create service principal in Azure AD + # Get client ID and secret + # Test token generation: + + curl -X POST https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token \ + -d "grant_type=client_credentials" \ + -d "client_id=${CLIENT_ID}" \ + -d "client_secret=${CLIENT_SECRET}" \ + -d "scope=api://${CLIENT_ID}/.default" + ``` + +3. **Token Validation:** + ```bash + # Use the generated token + # Make request to MCP Gateway with token + # Should validate successfully + ``` + +### Integration Testing Script + +Create `tests/test_entra_auth.sh`: + +```bash +#!/bin/bash +# Test Entra ID authentication + +set -e + +echo "Testing Entra ID Authentication" +echo "================================" + +# Check environment variables +if [ -z "$ENTRA_TENANT_ID" ]; then + echo "❌ ENTRA_TENANT_ID not set" + exit 1 +fi + +if [ -z "$ENTRA_CLIENT_ID" ]; then + echo "❌ ENTRA_CLIENT_ID not set" + exit 1 +fi + +if [ -z "$ENTRA_CLIENT_SECRET" ]; then + echo "❌ ENTRA_CLIENT_SECRET not set" + exit 1 +fi + +echo "✅ Environment variables configured" + +# Test M2M token generation +echo "" +echo "Testing M2M token generation..." + +RESPONSE=$(curl -s -X POST \ + "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token" \ + -d "grant_type=client_credentials" \ + -d "client_id=${ENTRA_CLIENT_ID}" \ + -d "client_secret=${ENTRA_CLIENT_SECRET}" \ + -d "scope=api://${ENTRA_CLIENT_ID}/.default") + +ACCESS_TOKEN=$(echo $RESPONSE | jq -r '.access_token') + +if [ "$ACCESS_TOKEN" != "null" ] && [ -n "$ACCESS_TOKEN" ]; then + echo "✅ M2M token generated successfully" +else + echo "❌ Failed to generate M2M token" + echo "Response: $RESPONSE" + exit 1 +fi + +# Test token validation +echo "" +echo "Testing token validation..." + +# Decode token (without verification, just to inspect) +TOKEN_PAYLOAD=$(echo $ACCESS_TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq .) + +echo "Token claims:" +echo "$TOKEN_PAYLOAD" | jq '{tid: .tid, aud: .aud, iss: .iss, exp: .exp}' + +echo "" +echo "✅ All tests passed!" +``` + +Make it executable: +```bash +chmod +x tests/test_entra_auth.sh +``` + +Run tests: +```bash +./tests/test_entra_auth.sh +``` + +--- + +## Phase 2 Enhancements (Optional) + +### Enhancement 1: Groups Overage Handling + +If users have 200+ Azure AD groups, add Microsoft Graph API support: + +```python +# In EntraIdProvider class + +async def get_user_info(self, access_token: str) -> Dict[str, Any]: + """Get user information from Entra ID. + + Enhanced to handle groups overage scenario. + """ + try: + user_info = await super().get_user_info(access_token) + + # Check for groups overage claim + if "_claim_names" in user_info and "groups" in user_info.get("_claim_names", {}): + logger.info("Groups overage detected, fetching from Microsoft Graph API") + user_info["groups"] = await self._fetch_groups_from_graph(access_token) + + return user_info + + except Exception as e: + logger.error(f"Failed to get user info: {e}") + raise ValueError(f"User info retrieval failed: {e}") + +async def _fetch_groups_from_graph(self, access_token: str) -> list: + """Fetch user groups from Microsoft Graph API.""" + import httpx + + url = "https://graph.microsoft.com/v1.0/me/memberOf" + headers = {"Authorization": f"Bearer {access_token}"} + + all_groups = [] + + async with httpx.AsyncClient() as client: + while url: + response = await client.get(url, headers=headers) + response.raise_for_status() + data = response.json() + + # Extract group Object IDs + groups = [ + item["id"] + for item in data.get("value", []) + if item.get("@odata.type") == "#microsoft.graph.group" + ] + all_groups.extend(groups) + + # Handle pagination + url = data.get("@odata.nextLink") + + return all_groups +``` + +**Additional API Permissions Required:** +- `GroupMember.Read.All` (Application permission) +- Or `Directory.Read.All` (Application permission) + +### Enhancement 2: MSAL Token Generation Helper + +Create `credentials-provider/entra/generate_tokens.py`: + +```python +"""Token generation for Entra ID service principals using MSAL.""" + +import os +from msal import ConfidentialClientApplication + +def generate_entra_token(tenant_id: str, client_id: str, client_secret: str): + """Generate M2M token for Entra ID service principal.""" + + authority = f"https://login.microsoftonline.com/{tenant_id}" + scopes = [f"api://{client_id}/.default"] + + app = ConfidentialClientApplication( + client_id=client_id, + client_credential=client_secret, + authority=authority + ) + + result = app.acquire_token_for_client(scopes=scopes) + + if "access_token" in result: + return result + else: + raise Exception(f"Token generation failed: {result.get('error_description')}") + +if __name__ == "__main__": + tenant_id = os.environ.get("ENTRA_TENANT_ID") + client_id = os.environ.get("ENTRA_CLIENT_ID") + client_secret = os.environ.get("ENTRA_CLIENT_SECRET") + + if not all([tenant_id, client_id, client_secret]): + print("❌ Missing environment variables") + exit(1) + + token = generate_entra_token(tenant_id, client_id, client_secret) + print(f"✅ Token generated successfully!") + print(f"Access token: {token['access_token'][:50]}...") + print(f"Expires in: {token['expires_in']} seconds") +``` + +Add dependency to `pyproject.toml`: +```toml +dependencies = [ + # ... existing dependencies + "msal>=1.24.0", +] +``` + +### Enhancement 3: Service Principal Setup Script + +Create `keycloak/setup/init-entra.sh`: + +```bash +#!/bin/bash +# Initialize Entra ID configuration for MCP Gateway + +set -e + +echo "🔧 Entra ID Setup for MCP Gateway" +echo "==================================" +echo "" + +# Check prerequisites +if ! command -v az &> /dev/null; then + echo "❌ Azure CLI not found. Please install: https://docs.microsoft.com/cli/azure/install-azure-cli" + exit 1 +fi + +# Login check +if ! az account show &> /dev/null; then + echo "📝 Please login to Azure:" + az login +fi + +# Get configuration +read -p "Enter your Tenant ID (or press Enter to use current): " TENANT_ID +if [ -z "$TENANT_ID" ]; then + TENANT_ID=$(az account show --query tenantId -o tsv) +fi + +echo "Using Tenant ID: $TENANT_ID" + +# Create app registration for MCP Gateway +echo "" +echo "📱 Creating app registration..." +APP_NAME="mcp-gateway-${USER}-$(date +%s)" + +# Create app registration +APP_ID=$(az ad app create \ + --display-name "$APP_NAME" \ + --sign-in-audience AzureADMyOrg \ + --query appId -o tsv) + +echo "✅ Created app registration: $APP_NAME" +echo " App ID: $APP_ID" + +# Create service principal +az ad sp create --id "$APP_ID" > /dev/null + +# Add required API permissions +echo "" +echo "🔐 Adding Microsoft Graph API permissions..." + +# User.Read (Delegated) +az ad app permission add \ + --id "$APP_ID" \ + --api 00000003-0000-0000-c000-000000000000 \ + --api-permissions e1fe6dd8-ba31-4d61-89e7-88639da4683d=Scope + +echo "⚠️ Admin consent required for permissions!" +echo " Please have your Azure AD admin run:" +echo " az ad app permission admin-consent --id $APP_ID" + +# Create client secret +echo "" +echo "🔑 Creating client secret..." +CLIENT_SECRET=$(az ad app credential reset \ + --id "$APP_ID" \ + --append \ + --query password -o tsv) + +echo "✅ Client secret created (save this securely!)" + +# Create security groups +echo "" +echo "👥 Creating security groups..." + +# Create groups +UNRESTRICTED_GROUP=$(az ad group create \ + --display-name "mcp-servers-unrestricted" \ + --mail-nickname "mcp-servers-unrestricted" \ + --query id -o tsv) + +RESTRICTED_GROUP=$(az ad group create \ + --display-name "mcp-servers-restricted" \ + --mail-nickname "mcp-servers-restricted" \ + --query id -o tsv) + +echo "✅ Created security groups:" +echo " mcp-servers-unrestricted: $UNRESTRICTED_GROUP" +echo " mcp-servers-restricted: $RESTRICTED_GROUP" + +# Save configuration +echo "" +echo "💾 Saving configuration..." + +cat > .env.entra << EOF +# Entra ID Configuration +# Generated: $(date) + +ENTRA_TENANT_ID=$TENANT_ID +ENTRA_CLIENT_ID=$APP_ID +ENTRA_CLIENT_SECRET=$CLIENT_SECRET + +# Group Object IDs (for scopes.yml) +# Add these to auth_server/scopes.yml group_mappings: +# "$UNRESTRICTED_GROUP": # mcp-servers-unrestricted +# - mcp-registry-admin +# - mcp-servers-unrestricted/read +# - mcp-servers-unrestricted/execute +# "$RESTRICTED_GROUP": # mcp-servers-restricted +# - mcp-registry-user +# - mcp-servers-restricted/read +EOF + +chmod 600 .env.entra + +echo "✅ Configuration saved to: .env.entra" +echo "" +echo "📋 Next steps:" +echo "1. Have Azure AD admin grant admin consent:" +echo " az ad app permission admin-consent --id $APP_ID" +echo "2. Copy .env.entra values to your main .env file" +echo "3. Add group Object IDs to auth_server/scopes.yml (see .env.entra)" +echo "4. Add users to security groups in Azure Portal" +echo "5. Restart auth-server: docker-compose restart auth-server" +echo "6. Test: ./tests/test_entra_auth.sh" +echo "" +echo "🎉 Entra ID setup complete!" +``` + +Make it executable: +```bash +chmod +x keycloak/setup/init-entra.sh +``` + +--- + +## Troubleshooting + +### Issue: "Invalid issuer" error + +**Cause:** Token issuer doesn't match expected issuer + +**Solution:** Check that issuer in token matches: +``` +https://login.microsoftonline.com/{TENANT_ID}/v2.0 +``` + +Verify with: +```bash +# Decode token (payload is 2nd part) +echo $TOKEN | cut -d. -f2 | base64 -d | jq .iss +``` + +### Issue: Groups not appearing in token + +**Cause:** Token configuration not set up + +**Solution:** +1. Go to Azure Portal → App Registration → Token configuration +2. Add groups claim +3. Select "Security groups" and "Emit groups as group IDs" + +### Issue: "Groups overage" claim appears + +**Cause:** User has 200+ groups + +**Solution:** Implement Phase 2 Enhancement 1 (Groups overage handling) + +### Issue: M2M token generation fails + +**Cause:** Service principal not configured properly + +**Solution:** +1. Verify app registration has service principal created +2. Check client secret hasn't expired +3. Verify tenant ID and client ID are correct + +--- + +## Documentation + +Create `docs/entra-id-setup.md` with Azure Portal setup guide. + +See the "How to Use It" section above for complete setup instructions. + +--- + +## Implementation Checklist + +- [ ] Create `auth_server/providers/entra.py` +- [ ] Update `auth_server/providers/factory.py` +- [ ] Update `auth_server/providers/__init__.py` (add import) +- [ ] Update `.env.example` +- [ ] Update `auth_server/oauth2_providers.yml` (optional) +- [ ] Create Azure AD app registration +- [ ] Configure API permissions +- [ ] Create security groups +- [ ] Test user login flow +- [ ] Test M2M token generation +- [ ] Create documentation +- [ ] Create setup script `init-entra.sh` +- [ ] Create test script `test_entra_auth.sh` + +--- + +## Comparison with Existing Providers + +| Feature | Keycloak | Cognito | Entra ID | +|---------|----------|---------|----------| +| OAuth2 | ✅ | ✅ | ✅ | +| OIDC | ✅ | ✅ | ✅ | +| JWT Validation | ✅ | ✅ | ✅ | +| M2M (Client Credentials) | ✅ | ✅ | ✅ | +| Token Refresh | ✅ | ✅ | ✅ | +| Groups Claim | ✅ `groups` | ✅ `cognito:groups` | ✅ `groups` | +| JWKS Caching | ✅ | ✅ | ✅ | +| UserInfo Endpoint | ✅ | ✅ | ✅ | +| Self-Hosted | ✅ | ❌ | ❌ | +| Cloud Service | ❌ | ✅ | ✅ | +| Enterprise Integration | ✅ | ❌ | ✅ | + +**Code Similarity:** +- Entra ID vs Cognito: 95% identical +- Entra ID vs Keycloak: 90% identical +- All three implement standard OIDC + +--- + +## Critical Code Review Findings + +### ✅ Groups-to-Scopes Mapping is Provider-Agnostic + +**Good News:** The groups-to-scopes mapping is **already generic** and will work for Entra ID without changes! + +**Evidence:** +1. **`auth_server/server.py:131-161`** - `map_groups_to_scopes()` function: + - Generic function that takes a list of group names + - Uses `scopes.yml` for mapping (not provider-specific) + - Works with Cognito, Keycloak, and will work with Entra ID + +2. **`auth_server/server.py:1027-1032`** - Keycloak-specific code: + ```python + if user_groups and validation_result.get('method') == 'keycloak': + # Map Keycloak groups to scopes using the group mappings + user_scopes = map_groups_to_scopes(user_groups) + ``` + - **Issue Found:** Hardcoded check for `method == 'keycloak'` + - **Impact:** Entra ID will need similar handling OR we refactor this + +3. **`registry/auth/dependencies.py:151-181`** - Registry has similar function: + - Function named `map_cognito_groups_to_scopes()` but it's generic + - **Issue Found:** Misleading name - should be `map_groups_to_scopes()` + - Actually works for any IdP groups + +### ⚠️ Issues Found + +#### Issue 1: Hardcoded Keycloak Logic in Auth Server + +**Location:** `auth_server/server.py:1027-1032` + +**Current Code:** +```python +# For Keycloak, map groups to scopes; otherwise use scopes directly +user_groups = validation_result.get('groups', []) +if user_groups and validation_result.get('method') == 'keycloak': + # Map Keycloak 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}") +else: + user_scopes = validation_result.get('scopes', []) +``` + +**Problem:** Only Keycloak gets group-to-scope mapping. Cognito and Entra ID won't work correctly. + +**Solution:** Change to: +```python +# Map groups to scopes for any provider that returns groups +user_groups = validation_result.get('groups', []) +auth_method = validation_result.get('method', '') + +# For providers that use groups (Keycloak, Entra ID, Cognito), map to scopes +if user_groups and auth_method in ['keycloak', 'entra', 'cognito']: + user_scopes = map_groups_to_scopes(user_groups) + logger.info(f"Mapped {auth_method} groups {user_groups} to scopes: {user_scopes}") +else: + # Fall back to scopes from token if no groups + user_scopes = validation_result.get('scopes', []) +``` + +#### Issue 2: Misleading Function Name in Registry + +**Location:** `registry/auth/dependencies.py:151` + +**Current:** `map_cognito_groups_to_scopes()` + +**Should be:** `map_groups_to_scopes()` (generic) + +**Impact:** Minor - function is generic but name suggests Cognito-only + +#### Issue 3: Cognito-Specific Group Claim Handling + +**Location:** Multiple places check for `cognito:groups` specifically + +**Files:** +- `auth_server/oauth2_providers.yml:34` - `groups_claim: "cognito:groups"` +- `auth_server/server.py:794` - `'groups': jwt_claims.get('cognito:groups', [])` +- `auth_server/server.py:1829` - Fallback logic: `["cognito:groups", "groups", "custom:groups"]` +- `auth_server/providers/cognito.py:122` - `claims.get('cognito:groups', [])` + +**Good News:** This is already handled correctly in the provider classes! +- Each provider extracts groups from its specific claim name +- Returns normalized result with `'groups'` key +- Central code receives generic `'groups'` list + +### ✅ Frontend is Provider-Agnostic + +**Finding:** Frontend has **minimal IdP-specific code** + +**Evidence:** +- `frontend/src/components/Sidebar.tsx:464` - Only reference is UI label "Keycloak Admin Tokens" +- No hardcoded provider logic in authentication flow +- OAuth flow works generically via session cookies + +**Conclusion:** Frontend will work with Entra ID without changes (maybe update label to "Admin Tokens") + +### ✅ Azure AD Groups Will Work + +**How it works:** +1. User authenticates with Entra ID → gets JWT with `groups` claim +2. `EntraIdProvider.validate_token()` extracts groups: `claims.get('groups', [])` +3. Returns normalized result: `{'groups': [...], 'method': 'entra'}` +4. Auth server checks `if auth_method in ['keycloak', 'entra', 'cognito']` (after fix) +5. Calls `map_groups_to_scopes(groups)` → looks up in `scopes.yml` +6. Groups map to scopes like any other provider + +**Example:** +```yaml +# scopes.yml - Works for ALL providers! +group_mappings: + # Keycloak groups + mcp-servers-unrestricted: + - mcp-servers-unrestricted/read + - mcp-servers-unrestricted/execute + + # Entra ID groups (use Azure AD Group Object IDs or names) + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": # Entra ID Group Object ID + - mcp-servers-unrestricted/read + - mcp-servers-unrestricted/execute +``` + +### 🔍 Keycloak References Audit + +**Total Files with "keycloak":** 67 files + +**Categories:** +1. **Provider Implementation** (5 files) - Core provider code + - `auth_server/providers/keycloak.py` + - `auth_server/providers/factory.py` + - `auth_server/oauth2_providers.yml` + +2. **Setup Scripts** (10 files) - Keycloak-specific setup + - `keycloak/setup/*.sh` - Admin scripts for Keycloak configuration + - `keycloak/import/realm-config.json` - Keycloak realm configuration + +3. **Documentation** (15 files) - References in docs + - Most are examples showing Keycloak as one option + - No blocking issues + +4. **Configuration Examples** (12 files) - .env examples, docker-compose + - Template files showing Keycloak configuration + - Will need similar Entra ID examples + +5. **Credentials/Token Generation** (5 files) - Token generation helpers + - `credentials-provider/keycloak/generate_tokens.py` + - Will need similar `credentials-provider/entra/generate_tokens.py` + +6. **Tests** (3 files) - Test scripts + - `test-keycloak-mcp.sh` + - Optional: Create `test-entra-mcp.sh` + +7. **Registry Utils** (1 file) - Keycloak admin integration + - `registry/utils/keycloak_manager.py` - Admin functions for Keycloak + - Not needed for Entra ID (Azure Portal used instead) + +**Conclusion:** No blocking Keycloak dependencies. All references are: +- Provider-specific implementations (parallel to Cognito) +- Optional admin utilities +- Documentation examples + +--- + +## Can Entra ID be Used as the IdP for OAuth Web Login? + +### ✅ YES - Entra ID Works as Full OAuth Provider + +**Evidence:** + +1. **OAuth2 Configuration Already Generic:** + - `auth_server/oauth2_providers.yml` defines providers + - Each provider has: `auth_url`, `token_url`, `user_info_url`, etc. + - Entra ID fits this pattern perfectly + +2. **Web Login Flow is Provider-Agnostic:** + - User clicks "Login" → redirected to IdP's `auth_url` + - User authenticates with IdP + - IdP redirects back with authorization code + - Server exchanges code for token at `token_url` + - Server gets user info from `user_info_url` + - Server creates session cookie + +3. **Frontend is Provider-Agnostic:** + - Frontend only knows about session cookies + - Doesn't care if user authenticated via Keycloak, Cognito, or Entra ID + - No frontend code changes needed + +### What Needs to Change? + +#### File 1: `auth_server/server.py` + +**Change Line 1027-1032:** + +**Before:** +```python +if user_groups and validation_result.get('method') == 'keycloak': + user_scopes = map_groups_to_scopes(user_groups) +``` + +**After:** +```python +# Map groups to scopes for any IdP that provides groups +if user_groups and validation_result.get('method') in ['keycloak', 'entra', 'cognito']: + user_scopes = map_groups_to_scopes(user_groups) + logger.info(f"Mapped {validation_result.get('method')} groups to scopes") +``` + +#### File 2: `registry/auth/dependencies.py` + +**Optional Refactor (Line 151):** + +**Before:** +```python +def map_cognito_groups_to_scopes(groups: List[str]) -> List[str]: + """ + Map Cognito groups to MCP scopes using the scopes.yml configuration. +``` + +**After (optional, for clarity):** +```python +def map_groups_to_scopes(groups: List[str]) -> List[str]: + """ + Map IdP groups to MCP scopes using the scopes.yml configuration. + Works for Cognito, Keycloak, Entra ID, and any OIDC provider. +``` + +**Then update callers at lines 392, 402.** + +--- + +## Final Recommendations + +### ✅ Option 1 Implementation is CONFIRMED VIABLE + +**Summary of Changes Needed:** + +| Component | Changes Required | Effort | +|-----------|-----------------|--------| +| **Auth Server Provider** | Create `auth_server/providers/entra.py` (copy Cognito) | 2-3 hours | +| **Factory** | Add `elif provider_type == 'entra'` case | 30 minutes | +| **Group Mapping Logic** | Change line 1027-1032 to include 'entra' | 15 minutes | +| **Configuration** | Add Entra ID to `.env.example`, `oauth2_providers.yml` | 30 minutes | +| **Testing** | Manual testing + create test script | 1-2 hours | +| **Documentation** | Azure Portal setup guide | 1-2 hours | +| **TOTAL** | **6-9 hours (1-2 days)** | | + +### Code Changes Summary + +#### 1. New Files (2 files) +- `auth_server/providers/entra.py` (~280 lines) - **90% copy from Cognito** +- `credentials-provider/entra/generate_tokens.py` (~100 lines) - Optional helper + +#### 2. Modified Files (3 files) +- `auth_server/providers/factory.py` - Add ~30 lines +- `auth_server/server.py` - Change 1 line (line 1029) +- `.env.example` - Add 3 env vars + +#### 3. Configuration Files (1 file) +- `auth_server/oauth2_providers.yml` - Add Entra ID section (~15 lines) + +### Groups-to-Scopes Mapping - No Changes Needed! + +**✅ Current `scopes.yml` works as-is for Entra ID** + +**Example Usage:** +```yaml +# scopes.yml +group_mappings: + # Works with Keycloak group names + mcp-servers-unrestricted: + - mcp-servers-unrestricted/read + - mcp-servers-unrestricted/execute + + # Works with Entra ID Group Object IDs + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": + - mcp-servers-unrestricted/read + - mcp-servers-unrestricted/execute + + # Works with Cognito group names + cognito-admins: + - mcp-registry-admin +``` + +**Key Insight:** The mapping is a simple dict lookup. Keys can be: +- Keycloak group names (strings) +- Entra ID Group Object IDs (GUIDs as strings) +- Cognito group names (strings) + +### Entra ID as Primary IdP - Full Support + +**✅ Can replace Keycloak entirely** + +**What works:** +- Web login (OAuth 2.0 Authorization Code Flow) +- User authentication with Microsoft accounts +- Group-based access control +- Session management +- All existing UI functionality +- API authentication with JWT tokens +- M2M authentication with service principals + +**What doesn't require changes:** +- Frontend (already provider-agnostic) +- Registry UI (works via session cookies) +- Scopes configuration (already generic) +- Group-to-scope mapping (already generic) + +**One-line summary:** +> Set `AUTH_PROVIDER=entra`, add Entra ID credentials, restart auth-server, and it works! + +### Risk Assessment + +**🟢 LOW RISK** - Minimal code changes, isolated to provider layer + +**Why Low Risk:** +1. **Copy-paste approach** - Proven pattern from Cognito +2. **Provider isolation** - Changes don't affect existing providers +3. **Generic infrastructure** - Groups/scopes mapping already works +4. **No frontend changes** - UI is provider-agnostic +5. **Backward compatible** - Existing Keycloak/Cognito continue working + +**Testing Strategy:** +1. Test Entra ID in isolation (new .env config) +2. Verify existing providers still work (Keycloak, Cognito) +3. Test group-to-scope mapping with Azure AD groups +4. Test web login flow +5. Test M2M authentication with service principals + +### Checklist for Implementation + +- [ ] Create `auth_server/providers/entra.py` (copy from cognito.py) +- [ ] Update `auth_server/providers/factory.py` (add entra case) +- [ ] Fix `auth_server/server.py` line 1029 (add 'entra' to list) +- [ ] Add Entra ID to `auth_server/oauth2_providers.yml` +- [ ] Add Entra ID env vars to `.env.example` +- [ ] Create Azure AD app registration +- [ ] Create Azure AD security groups +- [ ] Map groups in `scopes.yml` (using Object IDs) +- [ ] Test web login flow +- [ ] Test M2M token generation +- [ ] Test group-based permissions +- [ ] Create documentation (`docs/entra-id-setup.md`) +- [ ] Optional: Rename `map_cognito_groups_to_scopes()` for clarity +- [ ] Optional: Create `test-entra-mcp.sh` test script +- [ ] Optional: Create setup script `keycloak/setup/init-entra.sh` + +--- + +## Conclusion + +By copying `CognitoProvider` and changing URLs/claim names, you can add Entra ID support in **less than a day** with minimal code changes and **zero refactoring** of existing providers. + +This approach leverages the fact that OIDC is a standard protocol - all providers work the same way, they just have different endpoints and claim names. + +**Critical Review Confirms:** +✅ Groups-to-scopes mapping is already generic +✅ No provider-specific coupling in core logic (except one line to fix) +✅ Frontend is provider-agnostic +✅ Entra ID can be full IdP replacement for Keycloak +✅ All existing functionality continues to work +✅ Low risk, high reward implementation + +**Estimated Total Effort: 6-9 hours (1-2 days)** diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index f2546030..15c822c4 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -25,14 +25,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'); @@ -42,13 +43,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 } }; @@ -142,7 +152,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 { From b4c35d36ce680b5a91fc8387c0b5e971f49bf4cf Mon Sep 17 00:00:00 2001 From: Nisha Deborah Philips Date: Mon, 3 Nov 2025 21:25:04 -0600 Subject: [PATCH 02/15] fixing entra id generate token workflow --- registry/api/server_routes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/registry/api/server_routes.py b/registry/api/server_routes.py index 40d67ce7..85ce162d 100644 --- a/registry/api/server_routes.py +++ b/registry/api/server_routes.py @@ -2050,8 +2050,8 @@ async def generate_user_token( "token_type": token_data.get("token_type", "Bearer"), "scope": token_data.get("scope", "") }, - "keycloak_url": settings.keycloak_url or "http://keycloak:8080", - "realm": settings.keycloak_realm or "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, From 2bf028e6678aa4d5658968ffabc6fc9b78192cfb Mon Sep 17 00:00:00 2001 From: Nisha Deborah Philips Date: Tue, 4 Nov 2025 18:56:03 -0600 Subject: [PATCH 03/15] m2m auth --- auth_server/providers/entra.py | 10 +- docker/nginx_rev_proxy_http_only.conf | 31 + docs/ENTRA-ID-APP-CONFIGURATION.md | 161 --- docs/ENTRA-ID-SETUP-GUIDE.md | 551 +++++++++ docs/entra-id-implementation-option1.md | 1469 ----------------------- registry/api/server_routes.py | 4 +- registry/auth/dependencies.py | 8 +- 7 files changed, 597 insertions(+), 1637 deletions(-) delete mode 100644 docs/ENTRA-ID-APP-CONFIGURATION.md create mode 100644 docs/ENTRA-ID-SETUP-GUIDE.md delete mode 100644 docs/entra-id-implementation-option1.md diff --git a/auth_server/providers/entra.py b/auth_server/providers/entra.py index 6d1e7673..543ff2cc 100644 --- a/auth_server/providers/entra.py +++ b/auth_server/providers/entra.py @@ -142,11 +142,19 @@ def validate_token( 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': claims.get('groups', []), + 'groups': groups, 'scopes': claims.get('scope', '').split() if claims.get('scope') else [], 'client_id': claims.get('azp', self.client_id), 'method': 'entra', diff --git a/docker/nginx_rev_proxy_http_only.conf b/docker/nginx_rev_proxy_http_only.conf index 15f2d44e..633fb2ff 100644 --- a/docker/nginx_rev_proxy_http_only.conf +++ b/docker/nginx_rev_proxy_http_only.conf @@ -14,6 +14,37 @@ server { # Add this to trigger the named location for 403 errors error_page 403 = @forbidden_error; + # API endpoints with authentication + location /api/ { + # Authenticate request via auth server (validates JWT Bearer tokens and session cookies) + auth_request /validate; + + # Capture auth server response headers + auth_request_set $auth_user $upstream_http_x_user; + auth_request_set $auth_username $upstream_http_x_username; + auth_request_set $auth_client_id $upstream_http_x_client_id; + auth_request_set $auth_scopes $upstream_http_x_scopes; + auth_request_set $auth_method $upstream_http_x_auth_method; + + # Proxy to FastAPI application + proxy_pass http://127.0.0.1:7860/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Forward authentication headers to backend + proxy_set_header X-User $auth_user; + proxy_set_header X-Username $auth_username; + proxy_set_header X-Client-Id-Auth $auth_client_id; + proxy_set_header X-Scopes $auth_scopes; + proxy_set_header X-Auth-Method $auth_method; + + # Pass through original Authorization header + proxy_set_header Authorization $http_authorization; + } + # Route for Cost Explorer service location / { proxy_pass http://127.0.0.1:7860/; diff --git a/docs/ENTRA-ID-APP-CONFIGURATION.md b/docs/ENTRA-ID-APP-CONFIGURATION.md deleted file mode 100644 index e135fd01..00000000 --- a/docs/ENTRA-ID-APP-CONFIGURATION.md +++ /dev/null @@ -1,161 +0,0 @@ -# Microsoft Entra ID App Registration Configuration - -## Issue: Missing Email and Groups Claims - -After implementing Entra ID authentication, you may find that the userinfo endpoint does not return `email` or `groups` claims. This is because Microsoft Entra ID requires explicit configuration to include these claims. - -## Current Symptoms - -From the auth server logs: -``` -Raw user info from entra: {'sub': '...', 'name': 'Debbie Philips', 'family_name': 'Philips', 'given_name': 'Debbie', 'picture': '...'} -Mapped user info: {'username': None, 'email': None, 'name': 'Debbie Philips', 'groups': []} -``` - -**Missing:** -- `email` claim -- `preferred_username` claim -- `groups` claim - -## Solution: Configure App Registration - -### Step 1: Add API Permissions for Groups - -1. Go to [Azure Portal](https://portal.azure.com) -2. Navigate to **Azure Active Directory** → **App registrations** -3. Select your app: `mcp-gateway-web` (Client ID: `150f50ad-d0ca-4d7a-bb4b-75fbaf31acc1`) -4. Click **API permissions** in the left menu -5. Click **Add a permission** -6. Select **Microsoft Graph** -7. Select **Delegated permissions** -8. Search for and add: - - `GroupMember.Read.All` - Read groups user belongs to - - `User.Read` - Should already be present - - `email` - Read user's email address - - `profile` - Read user's basic profile -9. Click **Add permissions** -10. Click **Grant admin consent for [Your Tenant]** - This is **REQUIRED** - -### Step 2: Configure Optional Claims - -1. In your app registration, 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 3: Configure Group Claims - -1. Still in **Token configuration** -2. Click **Add groups claim** -3. Select **Security groups** -4. Under "Customize token properties by type", select: - - **ID**: Check "Group ID" - - **Access**: Check "Group ID" -5. Click **Add** - -### Step 4: Verify Manifest (Optional) - -You can verify the configuration in the app manifest: - -1. Click **Manifest** in the left menu -2. Look for `optionalClaims`: - -```json -"optionalClaims": { - "idToken": [ - { - "name": "email", - "source": null, - "essential": false, - "additionalProperties": [] - }, - { - "name": "preferred_username", - "source": null, - "essential": false, - "additionalProperties": [] - }, - { - "name": "groups", - "source": null, - "essential": false, - "additionalProperties": [] - } - ], - "accessToken": [ - { - "name": "groups", - "source": null, - "essential": false, - "additionalProperties": [] - } - ] -} -``` - -3. Look for `groupMembershipClaims`: -```json -"groupMembershipClaims": "SecurityGroup" -``` - -### Step 5: Alternative - Use Microsoft Graph API - -If you cannot enable `GroupMember.Read.All` permission, you can modify the code to fetch groups via Microsoft Graph API: - -```python -# In auth_server/providers/entra.py, add a method: -def get_user_groups(self, access_token: str) -> List[str]: - """Fetch user's group memberships from Microsoft Graph.""" - try: - headers = {'Authorization': f'Bearer {access_token}'} - # Request group Object IDs - response = requests.get( - 'https://graph.microsoft.com/v1.0/me/memberOf?$select=id,displayName', - headers=headers, - timeout=10 - ) - response.raise_for_status() - - data = response.json() - # Return list of group Object IDs - return [group['id'] for group in data.get('value', [])] - except Exception as e: - logger.error(f"Failed to fetch user groups: {e}") - return [] -``` - -Then call this in the callback handler instead of relying on the groups claim. - -## Testing the Configuration - -After making these changes: - -1. Wait 5-10 minutes for Azure AD to propagate the changes -2. Clear your browser cookies for `localhost` -3. Try logging in again -4. Check the auth server logs for: -``` -Raw user info from entra: {'sub': '...', 'email': 'DebbiePhilips@AWS139.onmicrosoft.com', 'preferred_username': 'DebbiePhilips@AWS139.onmicrosoft.com', 'groups': ['16c7e67e-...', '62c07ac1-...'], ...} -``` - -## Expected Result - -After configuration, you should see: -- `email`: `DebbiePhilips@AWS139.onmicrosoft.com` -- `preferred_username`: `DebbiePhilips@AWS139.onmicrosoft.com` -- `groups`: `['16c7e67e-e8ae-498c-ba2e-0593c0159e43', '62c07ac1-03d0-4924-90c7-a0255f23bd1d']` - -The user will be mapped to scopes based on their group membership in `scopes.yml`: -- Admin group (`16c7e67e-...`): `mcp-registry-admin`, `mcp-servers-unrestricted/read`, `mcp-servers-unrestricted/execute` -- Users group (`62c07ac1-...`): `mcp-registry-user`, `mcp-servers-restricted/read` - -## References - -- [Microsoft Entra ID optional claims](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](https://learn.microsoft.com/en-us/graph/permissions-reference) diff --git a/docs/ENTRA-ID-SETUP-GUIDE.md b/docs/ENTRA-ID-SETUP-GUIDE.md new file mode 100644 index 00000000..36712bf1 --- /dev/null +++ b/docs/ENTRA-ID-SETUP-GUIDE.md @@ -0,0 +1,551 @@ +# 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: + # Keycloak/Generic group mappings (by group name) + mcp-registry-admin: + - mcp-registry-admin + - mcp-servers-unrestricted/read + - mcp-servers-unrestricted/execute + + mcp-registry-user: + - mcp-registry-user + - mcp-servers-restricted/read + + # Entra ID group mappings (by Azure AD Group Object IDs) + # Admin group: Mcp-test-admin + "16c7e67e-e8ae-498c-ba2e-0593c0159e43": + - mcp-registry-admin + - mcp-servers-unrestricted/read + - mcp-servers-unrestricted/execute + + # Users group: mcp-test-users + "62c07ac1-03d0-4924-90c7-a0255f23bd1d": + - mcp-registry-user + - mcp-servers-restricted/read +``` + +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) + +--- + +## 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/docs/entra-id-implementation-option1.md b/docs/entra-id-implementation-option1.md deleted file mode 100644 index b6bec32c..00000000 --- a/docs/entra-id-implementation-option1.md +++ /dev/null @@ -1,1469 +0,0 @@ -# Option 1: Pure Configuration Approach - Add Entra ID Support - -## Core Concept - -**The Big Idea:** Your `oauth2_providers.yml` file already defines how OAuth providers work. Both Keycloak and Cognito are just OIDC providers with different URLs. Entra ID is the same - just another set of URLs! - -**What if** we could add Entra ID support by: -1. Adding Entra ID config to `oauth2_providers.yml` (like you have for Keycloak/Cognito) -2. Creating a minimal `EntraIdProvider` class that reuses existing logic -3. Adding an `elif` case in the factory - -No refactoring. No generic base class. Just add Entra ID as a third option. - ---- - -## How It Works - -### Current State - -Right now your `factory.py` does: - -```python -def get_auth_provider(provider_type: Optional[str] = None) -> AuthProvider: - provider_type = provider_type or os.environ.get('AUTH_PROVIDER', 'cognito') - - if provider_type == 'keycloak': - return _create_keycloak_provider() - elif provider_type == 'cognito': - return _create_cognito_provider() - else: - raise ValueError(f"Unknown auth provider: {provider_type}") -``` - -### Option 1 Changes - -**Step 1: Add Entra ID case to factory** - -```python -def get_auth_provider(provider_type: Optional[str] = None) -> AuthProvider: - provider_type = provider_type or os.environ.get('AUTH_PROVIDER', 'cognito') - - if provider_type == 'keycloak': - return _create_keycloak_provider() - elif provider_type == 'cognito': - return _create_cognito_provider() - elif provider_type == 'entra': # ← NEW - return _create_entra_provider() # ← NEW - else: - raise ValueError(f"Unknown auth provider: {provider_type}") -``` - -**Step 2: Add the helper function** - -```python -def _create_entra_provider() -> 'EntraIdProvider': # Note: return type annotation - """Create and configure Entra ID provider.""" - # Get environment variables - tenant_id = os.environ.get('ENTRA_TENANT_ID') - client_id = os.environ.get('ENTRA_CLIENT_ID') - client_secret = os.environ.get('ENTRA_CLIENT_SECRET') - - # 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}'") - - # Import here to avoid circular imports - from .entra import EntraIdProvider - - return EntraIdProvider( - tenant_id=tenant_id, - client_id=client_id, - client_secret=client_secret - ) -``` - -That's literally all that changes in `factory.py` - add an elif and a helper function! - ---- - -## The EntraIdProvider Class - -**Key Insight:** Look at `CognitoProvider` and `KeycloakProvider`. They're ~90% identical: -- Same `get_jwks()` method -- Same JWT validation logic -- Same `exchange_code_for_token()` flow -- Same `refresh_token()` logic -- Same `get_m2m_token()` for client credentials - -**The only differences:** -1. **URLs**: Different endpoints (auth_url, token_url, etc.) -2. **Groups claim name**: `cognito:groups` vs `groups` vs Keycloak's `groups` -3. **Issuer format**: Different issuer URL patterns - -### Create EntraIdProvider by Copying CognitoProvider - -**File:** `auth_server/providers/entra.py` - -```python -"""Microsoft Entra ID 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 - -logger = logging.getLogger(__name__) - - -class EntraIdProvider(AuthProvider): - """Microsoft Entra ID (Azure AD) authentication provider. - - This is essentially CognitoProvider with different URLs and claim names. - """ - - 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 - EXACT SAME as Cognito/Keycloak - 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 - ONLY DIFFERENCE from Cognito is URLs - 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.issuer = f"{base_url}/v2.0" - - logger.debug(f"Initialized Entra ID provider for tenant '{tenant_id}'") - - # ======================================================================== - # COPY-PASTE from CognitoProvider with minimal changes - # ======================================================================== - - def validate_token(self, token: str, **kwargs: Any) -> Dict[str, Any]: - """Validate Entra ID JWT token. - - COPIED FROM: CognitoProvider.validate_token() (lines 71-137) - CHANGES: - - issuer = self.issuer (not Cognito-specific) - - groups claim = 'groups' (not 'cognito:groups') - - method = 'entra' (not 'cognito') - """ - try: - logger.debug("Validating Entra ID JWT token") - - # Get JWKS for validation - jwks = self.get_jwks() - - # Decode token header to get key ID - unverified_header = jwt.get_unverified_header(token) - kid = unverified_header.get('kid') - - if not kid: - raise ValueError("Token missing 'kid' in header") - - # Find matching key - signing_key = None - for key in jwks.get('keys', []): - if key.get('kid') == kid: - from jwt import PyJWK - signing_key = PyJWK(key).key - break - - if not signing_key: - raise ValueError(f"No matching key found for kid: {kid}") - - # Validate and decode token - claims = jwt.decode( - token, - signing_key, - algorithms=['RS256'], - issuer=self.issuer, # ← CHANGED: was Cognito issuer - audience=self.client_id, - 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 - return { - 'valid': True, - 'username': claims.get('preferred_username', claims.get('sub')), - 'email': claims.get('email'), - 'groups': claims.get('groups', []), # ← CHANGED: was 'cognito:groups' - 'scopes': claims.get('scope', '').split() if claims.get('scope') else [], - 'client_id': claims.get('azp', self.client_id), - 'method': 'entra', # ← CHANGED: was 'cognito' - '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. - - COPIED FROM: CognitoProvider.get_jwks() (lines 140-163) - CHANGES: None! Identical code, just different self.jwks_url - """ - 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. - - COPIED FROM: CognitoProvider.exchange_code_for_token() (lines 166-197) - CHANGES: None! Identical code, just different self.token_url - """ - 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. - - COPIED FROM: CognitoProvider.get_user_info() (lines 200-219) - CHANGES: None! Identical code, just different self.userinfo_url - """ - 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. - - COPIED FROM: CognitoProvider.get_auth_url() (lines 222-242) - CHANGES: Default scope includes 'User.Read' for Entra ID - """ - 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. - - COPIED FROM: CognitoProvider.get_logout_url() (lines 245-260) - CHANGES: Parameter name is 'post_logout_redirect_uri' for Entra ID - """ - logger.debug(f"Generating logout URL with redirect_uri: {redirect_uri}") - - params = { - 'client_id': self.client_id, - 'post_logout_redirect_uri': redirect_uri # ← CHANGED: was 'logout_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. - - COPIED FROM: CognitoProvider.refresh_token() (lines 263-292) - CHANGES: None! Identical code - """ - 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. - - COPIED FROM: CognitoProvider.validate_m2m_token() (lines 295-301) - CHANGES: None! Identical code - """ - 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. - - COPIED FROM: CognitoProvider.get_m2m_token() (lines 304-337) - CHANGES: None! Identical code - """ - try: - logger.debug("Requesting M2M token using client credentials") - - data = { - 'grant_type': 'client_credentials', - 'client_id': client_id or self.client_id, - 'client_secret': client_secret or self.client_secret - } - - if scope: - data['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. - - COPIED FROM: CognitoProvider.get_provider_info() (lines 340-355) - CHANGES: Provider type and keys renamed - """ - 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 - }, - 'issuer': self.issuer - } -``` - -**That's the entire class! ~280 lines, 90% copy-pasted from CognitoProvider.** - ---- - -## Environment Variables - -**File:** `.env.example` - -Add these lines: - -```bash -# ============================================================================ -# Microsoft Entra ID Configuration (if AUTH_PROVIDER=entra) -# ============================================================================ - -# Provider selection -AUTH_PROVIDER=entra # or cognito, keycloak - -# Required Entra ID settings -ENTRA_TENANT_ID=12345678-1234-1234-1234-123456789012 -ENTRA_CLIENT_ID=87654321-4321-4321-4321-210987654321 -ENTRA_CLIENT_SECRET=your_client_secret_here -``` - ---- - -## Configuration in oauth2_providers.yml (Optional) - -If you want web-based OAuth flow to show "Login with Microsoft" button: - -**File:** `auth_server/oauth2_providers.yml` - -Add this section: - -```yaml - 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" - scopes: ["openid", "email", "profile"] - response_type: "code" - grant_type: "authorization_code" - username_claim: "preferred_username" - email_claim: "email" - groups_claim: "groups" - enabled: "${ENTRA_ENABLED:-false}" -``` - ---- - -## How to Use It - -### Step 1: Azure Portal Setup - -1. Go to Azure Portal → Azure Active Directory → App registrations -2. Click "New registration" -3. Name: "MCP Gateway" -4. Redirect URI: `https://your-gateway.com/auth/callback` -5. Click "Register" -6. Copy Application (client) ID → This is `ENTRA_CLIENT_ID` -7. Copy Directory (tenant) ID → This is `ENTRA_TENANT_ID` -8. Go to "Certificates & secrets" → New client secret -9. Copy the secret value → This is `ENTRA_CLIENT_SECRET` - -### Step 2: Configure API Permissions (for Group Claims) - -1. In your App Registration, go to "API permissions" -2. Click "Add a permission" -3. Select "Microsoft Graph" → "Delegated permissions" -4. Add these permissions: - - `User.Read` (read basic user profile) - - `email` (read user's email) - - `openid` (OpenID Connect) - - `profile` (read user's basic profile) -5. **Optional (for group claims)**: Add `GroupMember.Read.All` or `Directory.Read.All` -6. Click "Grant admin consent" button (requires admin) - -### Step 3: Configure Token Configuration (for Groups in Token) - -By default, Azure AD doesn't include groups in the token. To enable: - -1. In your App Registration, go to "Token configuration" -2. Click "Add groups claim" -3. Select "Security groups" -4. Check "Emit groups as group IDs" under ID and Access tokens -5. Click "Add" - -**Note:** If users have 200+ groups, Azure AD will use the "groups overage" claim and you'll need to fetch groups via Microsoft Graph API (see Phase 2 enhancement below). - -### Step 4: Create Security Groups - -1. Go to Azure Active Directory → Groups -2. Click "New group" -3. Create groups matching your scopes: - - `mcp-servers-unrestricted` - Full access group - - `mcp-servers-restricted` - Limited access group -4. Copy each group's "Object ID" (GUID) -5. Add these to your `auth_server/scopes.yml`: - -```yaml -group_mappings: - # Use Azure AD Group Object IDs - "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": # Object ID of mcp-servers-unrestricted - - mcp-registry-admin - - mcp-servers-unrestricted/read - - mcp-servers-unrestricted/execute - - "ffffffff-gggg-hhhh-iiii-jjjjjjjjjjjj": # Object ID of mcp-servers-restricted - - mcp-registry-user - - mcp-servers-restricted/read -``` - -### Step 5: Configure MCP Gateway - -Edit your `.env` file: - -```bash -AUTH_PROVIDER=entra -ENTRA_TENANT_ID= -ENTRA_CLIENT_ID= -ENTRA_CLIENT_SECRET= -``` - -### Step 6: Restart Auth Server - -```bash -docker-compose restart auth-server -``` - -### Step 7: Test - -Visit your gateway login page - you should see it redirects to Microsoft login! - ---- - -## Why This Works - -**The secret:** OIDC is OIDC. All providers (Keycloak, Cognito, Entra ID, Okta, Auth0, Google) implement the same protocol: - -1. **Authorization Code Flow:** - - Redirect to `auth_url` with client_id, redirect_uri, scope - - Get authorization code back - - Exchange code for token at `token_url` - -2. **Token Validation:** - - Fetch JWKS from `jwks_url` - - Verify JWT signature using JWKS - - Validate issuer, audience, expiration - -3. **Client Credentials (M2M):** - - POST to `token_url` with grant_type=client_credentials - - Get access token back - -**The only differences are URLs and claim names.** That's why you can copy-paste 90% of the code! - ---- - -## Summary of Changes - -| File | Changes | Lines | -|------|---------|-------| -| `auth_server/providers/entra.py` | **NEW FILE** - Copy CognitoProvider | ~280 | -| `auth_server/providers/factory.py` | Add `elif` + helper function | ~30 | -| `.env.example` | Add 3 env vars | ~5 | -| `auth_server/oauth2_providers.yml` | Add entra section (optional) | ~15 | - -**Total: ~330 lines of code, most copy-pasted** - -**Time estimate: 4-6 hours** (1 day) - ---- - -## What You Get - -✅ Users can login with Microsoft accounts -✅ AI agents can use Entra ID service principals -✅ Group-based permissions work (Azure AD security groups) -✅ All existing FGAC and scopes work unchanged -✅ No refactoring of existing code -✅ No breaking changes - ---- - -## Testing - -### Manual Testing - -1. **User Login Flow:** - ```bash - # Set AUTH_PROVIDER=entra in .env - # Restart auth-server - # Visit http://localhost:7860/login - # Should redirect to Microsoft login - # After login, should see MCP Gateway UI - ``` - -2. **M2M Token Generation:** - ```bash - # Create service principal in Azure AD - # Get client ID and secret - # Test token generation: - - curl -X POST https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token \ - -d "grant_type=client_credentials" \ - -d "client_id=${CLIENT_ID}" \ - -d "client_secret=${CLIENT_SECRET}" \ - -d "scope=api://${CLIENT_ID}/.default" - ``` - -3. **Token Validation:** - ```bash - # Use the generated token - # Make request to MCP Gateway with token - # Should validate successfully - ``` - -### Integration Testing Script - -Create `tests/test_entra_auth.sh`: - -```bash -#!/bin/bash -# Test Entra ID authentication - -set -e - -echo "Testing Entra ID Authentication" -echo "================================" - -# Check environment variables -if [ -z "$ENTRA_TENANT_ID" ]; then - echo "❌ ENTRA_TENANT_ID not set" - exit 1 -fi - -if [ -z "$ENTRA_CLIENT_ID" ]; then - echo "❌ ENTRA_CLIENT_ID not set" - exit 1 -fi - -if [ -z "$ENTRA_CLIENT_SECRET" ]; then - echo "❌ ENTRA_CLIENT_SECRET not set" - exit 1 -fi - -echo "✅ Environment variables configured" - -# Test M2M token generation -echo "" -echo "Testing M2M token generation..." - -RESPONSE=$(curl -s -X POST \ - "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token" \ - -d "grant_type=client_credentials" \ - -d "client_id=${ENTRA_CLIENT_ID}" \ - -d "client_secret=${ENTRA_CLIENT_SECRET}" \ - -d "scope=api://${ENTRA_CLIENT_ID}/.default") - -ACCESS_TOKEN=$(echo $RESPONSE | jq -r '.access_token') - -if [ "$ACCESS_TOKEN" != "null" ] && [ -n "$ACCESS_TOKEN" ]; then - echo "✅ M2M token generated successfully" -else - echo "❌ Failed to generate M2M token" - echo "Response: $RESPONSE" - exit 1 -fi - -# Test token validation -echo "" -echo "Testing token validation..." - -# Decode token (without verification, just to inspect) -TOKEN_PAYLOAD=$(echo $ACCESS_TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq .) - -echo "Token claims:" -echo "$TOKEN_PAYLOAD" | jq '{tid: .tid, aud: .aud, iss: .iss, exp: .exp}' - -echo "" -echo "✅ All tests passed!" -``` - -Make it executable: -```bash -chmod +x tests/test_entra_auth.sh -``` - -Run tests: -```bash -./tests/test_entra_auth.sh -``` - ---- - -## Phase 2 Enhancements (Optional) - -### Enhancement 1: Groups Overage Handling - -If users have 200+ Azure AD groups, add Microsoft Graph API support: - -```python -# In EntraIdProvider class - -async def get_user_info(self, access_token: str) -> Dict[str, Any]: - """Get user information from Entra ID. - - Enhanced to handle groups overage scenario. - """ - try: - user_info = await super().get_user_info(access_token) - - # Check for groups overage claim - if "_claim_names" in user_info and "groups" in user_info.get("_claim_names", {}): - logger.info("Groups overage detected, fetching from Microsoft Graph API") - user_info["groups"] = await self._fetch_groups_from_graph(access_token) - - return user_info - - except Exception as e: - logger.error(f"Failed to get user info: {e}") - raise ValueError(f"User info retrieval failed: {e}") - -async def _fetch_groups_from_graph(self, access_token: str) -> list: - """Fetch user groups from Microsoft Graph API.""" - import httpx - - url = "https://graph.microsoft.com/v1.0/me/memberOf" - headers = {"Authorization": f"Bearer {access_token}"} - - all_groups = [] - - async with httpx.AsyncClient() as client: - while url: - response = await client.get(url, headers=headers) - response.raise_for_status() - data = response.json() - - # Extract group Object IDs - groups = [ - item["id"] - for item in data.get("value", []) - if item.get("@odata.type") == "#microsoft.graph.group" - ] - all_groups.extend(groups) - - # Handle pagination - url = data.get("@odata.nextLink") - - return all_groups -``` - -**Additional API Permissions Required:** -- `GroupMember.Read.All` (Application permission) -- Or `Directory.Read.All` (Application permission) - -### Enhancement 2: MSAL Token Generation Helper - -Create `credentials-provider/entra/generate_tokens.py`: - -```python -"""Token generation for Entra ID service principals using MSAL.""" - -import os -from msal import ConfidentialClientApplication - -def generate_entra_token(tenant_id: str, client_id: str, client_secret: str): - """Generate M2M token for Entra ID service principal.""" - - authority = f"https://login.microsoftonline.com/{tenant_id}" - scopes = [f"api://{client_id}/.default"] - - app = ConfidentialClientApplication( - client_id=client_id, - client_credential=client_secret, - authority=authority - ) - - result = app.acquire_token_for_client(scopes=scopes) - - if "access_token" in result: - return result - else: - raise Exception(f"Token generation failed: {result.get('error_description')}") - -if __name__ == "__main__": - tenant_id = os.environ.get("ENTRA_TENANT_ID") - client_id = os.environ.get("ENTRA_CLIENT_ID") - client_secret = os.environ.get("ENTRA_CLIENT_SECRET") - - if not all([tenant_id, client_id, client_secret]): - print("❌ Missing environment variables") - exit(1) - - token = generate_entra_token(tenant_id, client_id, client_secret) - print(f"✅ Token generated successfully!") - print(f"Access token: {token['access_token'][:50]}...") - print(f"Expires in: {token['expires_in']} seconds") -``` - -Add dependency to `pyproject.toml`: -```toml -dependencies = [ - # ... existing dependencies - "msal>=1.24.0", -] -``` - -### Enhancement 3: Service Principal Setup Script - -Create `keycloak/setup/init-entra.sh`: - -```bash -#!/bin/bash -# Initialize Entra ID configuration for MCP Gateway - -set -e - -echo "🔧 Entra ID Setup for MCP Gateway" -echo "==================================" -echo "" - -# Check prerequisites -if ! command -v az &> /dev/null; then - echo "❌ Azure CLI not found. Please install: https://docs.microsoft.com/cli/azure/install-azure-cli" - exit 1 -fi - -# Login check -if ! az account show &> /dev/null; then - echo "📝 Please login to Azure:" - az login -fi - -# Get configuration -read -p "Enter your Tenant ID (or press Enter to use current): " TENANT_ID -if [ -z "$TENANT_ID" ]; then - TENANT_ID=$(az account show --query tenantId -o tsv) -fi - -echo "Using Tenant ID: $TENANT_ID" - -# Create app registration for MCP Gateway -echo "" -echo "📱 Creating app registration..." -APP_NAME="mcp-gateway-${USER}-$(date +%s)" - -# Create app registration -APP_ID=$(az ad app create \ - --display-name "$APP_NAME" \ - --sign-in-audience AzureADMyOrg \ - --query appId -o tsv) - -echo "✅ Created app registration: $APP_NAME" -echo " App ID: $APP_ID" - -# Create service principal -az ad sp create --id "$APP_ID" > /dev/null - -# Add required API permissions -echo "" -echo "🔐 Adding Microsoft Graph API permissions..." - -# User.Read (Delegated) -az ad app permission add \ - --id "$APP_ID" \ - --api 00000003-0000-0000-c000-000000000000 \ - --api-permissions e1fe6dd8-ba31-4d61-89e7-88639da4683d=Scope - -echo "⚠️ Admin consent required for permissions!" -echo " Please have your Azure AD admin run:" -echo " az ad app permission admin-consent --id $APP_ID" - -# Create client secret -echo "" -echo "🔑 Creating client secret..." -CLIENT_SECRET=$(az ad app credential reset \ - --id "$APP_ID" \ - --append \ - --query password -o tsv) - -echo "✅ Client secret created (save this securely!)" - -# Create security groups -echo "" -echo "👥 Creating security groups..." - -# Create groups -UNRESTRICTED_GROUP=$(az ad group create \ - --display-name "mcp-servers-unrestricted" \ - --mail-nickname "mcp-servers-unrestricted" \ - --query id -o tsv) - -RESTRICTED_GROUP=$(az ad group create \ - --display-name "mcp-servers-restricted" \ - --mail-nickname "mcp-servers-restricted" \ - --query id -o tsv) - -echo "✅ Created security groups:" -echo " mcp-servers-unrestricted: $UNRESTRICTED_GROUP" -echo " mcp-servers-restricted: $RESTRICTED_GROUP" - -# Save configuration -echo "" -echo "💾 Saving configuration..." - -cat > .env.entra << EOF -# Entra ID Configuration -# Generated: $(date) - -ENTRA_TENANT_ID=$TENANT_ID -ENTRA_CLIENT_ID=$APP_ID -ENTRA_CLIENT_SECRET=$CLIENT_SECRET - -# Group Object IDs (for scopes.yml) -# Add these to auth_server/scopes.yml group_mappings: -# "$UNRESTRICTED_GROUP": # mcp-servers-unrestricted -# - mcp-registry-admin -# - mcp-servers-unrestricted/read -# - mcp-servers-unrestricted/execute -# "$RESTRICTED_GROUP": # mcp-servers-restricted -# - mcp-registry-user -# - mcp-servers-restricted/read -EOF - -chmod 600 .env.entra - -echo "✅ Configuration saved to: .env.entra" -echo "" -echo "📋 Next steps:" -echo "1. Have Azure AD admin grant admin consent:" -echo " az ad app permission admin-consent --id $APP_ID" -echo "2. Copy .env.entra values to your main .env file" -echo "3. Add group Object IDs to auth_server/scopes.yml (see .env.entra)" -echo "4. Add users to security groups in Azure Portal" -echo "5. Restart auth-server: docker-compose restart auth-server" -echo "6. Test: ./tests/test_entra_auth.sh" -echo "" -echo "🎉 Entra ID setup complete!" -``` - -Make it executable: -```bash -chmod +x keycloak/setup/init-entra.sh -``` - ---- - -## Troubleshooting - -### Issue: "Invalid issuer" error - -**Cause:** Token issuer doesn't match expected issuer - -**Solution:** Check that issuer in token matches: -``` -https://login.microsoftonline.com/{TENANT_ID}/v2.0 -``` - -Verify with: -```bash -# Decode token (payload is 2nd part) -echo $TOKEN | cut -d. -f2 | base64 -d | jq .iss -``` - -### Issue: Groups not appearing in token - -**Cause:** Token configuration not set up - -**Solution:** -1. Go to Azure Portal → App Registration → Token configuration -2. Add groups claim -3. Select "Security groups" and "Emit groups as group IDs" - -### Issue: "Groups overage" claim appears - -**Cause:** User has 200+ groups - -**Solution:** Implement Phase 2 Enhancement 1 (Groups overage handling) - -### Issue: M2M token generation fails - -**Cause:** Service principal not configured properly - -**Solution:** -1. Verify app registration has service principal created -2. Check client secret hasn't expired -3. Verify tenant ID and client ID are correct - ---- - -## Documentation - -Create `docs/entra-id-setup.md` with Azure Portal setup guide. - -See the "How to Use It" section above for complete setup instructions. - ---- - -## Implementation Checklist - -- [ ] Create `auth_server/providers/entra.py` -- [ ] Update `auth_server/providers/factory.py` -- [ ] Update `auth_server/providers/__init__.py` (add import) -- [ ] Update `.env.example` -- [ ] Update `auth_server/oauth2_providers.yml` (optional) -- [ ] Create Azure AD app registration -- [ ] Configure API permissions -- [ ] Create security groups -- [ ] Test user login flow -- [ ] Test M2M token generation -- [ ] Create documentation -- [ ] Create setup script `init-entra.sh` -- [ ] Create test script `test_entra_auth.sh` - ---- - -## Comparison with Existing Providers - -| Feature | Keycloak | Cognito | Entra ID | -|---------|----------|---------|----------| -| OAuth2 | ✅ | ✅ | ✅ | -| OIDC | ✅ | ✅ | ✅ | -| JWT Validation | ✅ | ✅ | ✅ | -| M2M (Client Credentials) | ✅ | ✅ | ✅ | -| Token Refresh | ✅ | ✅ | ✅ | -| Groups Claim | ✅ `groups` | ✅ `cognito:groups` | ✅ `groups` | -| JWKS Caching | ✅ | ✅ | ✅ | -| UserInfo Endpoint | ✅ | ✅ | ✅ | -| Self-Hosted | ✅ | ❌ | ❌ | -| Cloud Service | ❌ | ✅ | ✅ | -| Enterprise Integration | ✅ | ❌ | ✅ | - -**Code Similarity:** -- Entra ID vs Cognito: 95% identical -- Entra ID vs Keycloak: 90% identical -- All three implement standard OIDC - ---- - -## Critical Code Review Findings - -### ✅ Groups-to-Scopes Mapping is Provider-Agnostic - -**Good News:** The groups-to-scopes mapping is **already generic** and will work for Entra ID without changes! - -**Evidence:** -1. **`auth_server/server.py:131-161`** - `map_groups_to_scopes()` function: - - Generic function that takes a list of group names - - Uses `scopes.yml` for mapping (not provider-specific) - - Works with Cognito, Keycloak, and will work with Entra ID - -2. **`auth_server/server.py:1027-1032`** - Keycloak-specific code: - ```python - if user_groups and validation_result.get('method') == 'keycloak': - # Map Keycloak groups to scopes using the group mappings - user_scopes = map_groups_to_scopes(user_groups) - ``` - - **Issue Found:** Hardcoded check for `method == 'keycloak'` - - **Impact:** Entra ID will need similar handling OR we refactor this - -3. **`registry/auth/dependencies.py:151-181`** - Registry has similar function: - - Function named `map_cognito_groups_to_scopes()` but it's generic - - **Issue Found:** Misleading name - should be `map_groups_to_scopes()` - - Actually works for any IdP groups - -### ⚠️ Issues Found - -#### Issue 1: Hardcoded Keycloak Logic in Auth Server - -**Location:** `auth_server/server.py:1027-1032` - -**Current Code:** -```python -# For Keycloak, map groups to scopes; otherwise use scopes directly -user_groups = validation_result.get('groups', []) -if user_groups and validation_result.get('method') == 'keycloak': - # Map Keycloak 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}") -else: - user_scopes = validation_result.get('scopes', []) -``` - -**Problem:** Only Keycloak gets group-to-scope mapping. Cognito and Entra ID won't work correctly. - -**Solution:** Change to: -```python -# Map groups to scopes for any provider that returns groups -user_groups = validation_result.get('groups', []) -auth_method = validation_result.get('method', '') - -# For providers that use groups (Keycloak, Entra ID, Cognito), map to scopes -if user_groups and auth_method in ['keycloak', 'entra', 'cognito']: - user_scopes = map_groups_to_scopes(user_groups) - logger.info(f"Mapped {auth_method} groups {user_groups} to scopes: {user_scopes}") -else: - # Fall back to scopes from token if no groups - user_scopes = validation_result.get('scopes', []) -``` - -#### Issue 2: Misleading Function Name in Registry - -**Location:** `registry/auth/dependencies.py:151` - -**Current:** `map_cognito_groups_to_scopes()` - -**Should be:** `map_groups_to_scopes()` (generic) - -**Impact:** Minor - function is generic but name suggests Cognito-only - -#### Issue 3: Cognito-Specific Group Claim Handling - -**Location:** Multiple places check for `cognito:groups` specifically - -**Files:** -- `auth_server/oauth2_providers.yml:34` - `groups_claim: "cognito:groups"` -- `auth_server/server.py:794` - `'groups': jwt_claims.get('cognito:groups', [])` -- `auth_server/server.py:1829` - Fallback logic: `["cognito:groups", "groups", "custom:groups"]` -- `auth_server/providers/cognito.py:122` - `claims.get('cognito:groups', [])` - -**Good News:** This is already handled correctly in the provider classes! -- Each provider extracts groups from its specific claim name -- Returns normalized result with `'groups'` key -- Central code receives generic `'groups'` list - -### ✅ Frontend is Provider-Agnostic - -**Finding:** Frontend has **minimal IdP-specific code** - -**Evidence:** -- `frontend/src/components/Sidebar.tsx:464` - Only reference is UI label "Keycloak Admin Tokens" -- No hardcoded provider logic in authentication flow -- OAuth flow works generically via session cookies - -**Conclusion:** Frontend will work with Entra ID without changes (maybe update label to "Admin Tokens") - -### ✅ Azure AD Groups Will Work - -**How it works:** -1. User authenticates with Entra ID → gets JWT with `groups` claim -2. `EntraIdProvider.validate_token()` extracts groups: `claims.get('groups', [])` -3. Returns normalized result: `{'groups': [...], 'method': 'entra'}` -4. Auth server checks `if auth_method in ['keycloak', 'entra', 'cognito']` (after fix) -5. Calls `map_groups_to_scopes(groups)` → looks up in `scopes.yml` -6. Groups map to scopes like any other provider - -**Example:** -```yaml -# scopes.yml - Works for ALL providers! -group_mappings: - # Keycloak groups - mcp-servers-unrestricted: - - mcp-servers-unrestricted/read - - mcp-servers-unrestricted/execute - - # Entra ID groups (use Azure AD Group Object IDs or names) - "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": # Entra ID Group Object ID - - mcp-servers-unrestricted/read - - mcp-servers-unrestricted/execute -``` - -### 🔍 Keycloak References Audit - -**Total Files with "keycloak":** 67 files - -**Categories:** -1. **Provider Implementation** (5 files) - Core provider code - - `auth_server/providers/keycloak.py` - - `auth_server/providers/factory.py` - - `auth_server/oauth2_providers.yml` - -2. **Setup Scripts** (10 files) - Keycloak-specific setup - - `keycloak/setup/*.sh` - Admin scripts for Keycloak configuration - - `keycloak/import/realm-config.json` - Keycloak realm configuration - -3. **Documentation** (15 files) - References in docs - - Most are examples showing Keycloak as one option - - No blocking issues - -4. **Configuration Examples** (12 files) - .env examples, docker-compose - - Template files showing Keycloak configuration - - Will need similar Entra ID examples - -5. **Credentials/Token Generation** (5 files) - Token generation helpers - - `credentials-provider/keycloak/generate_tokens.py` - - Will need similar `credentials-provider/entra/generate_tokens.py` - -6. **Tests** (3 files) - Test scripts - - `test-keycloak-mcp.sh` - - Optional: Create `test-entra-mcp.sh` - -7. **Registry Utils** (1 file) - Keycloak admin integration - - `registry/utils/keycloak_manager.py` - Admin functions for Keycloak - - Not needed for Entra ID (Azure Portal used instead) - -**Conclusion:** No blocking Keycloak dependencies. All references are: -- Provider-specific implementations (parallel to Cognito) -- Optional admin utilities -- Documentation examples - ---- - -## Can Entra ID be Used as the IdP for OAuth Web Login? - -### ✅ YES - Entra ID Works as Full OAuth Provider - -**Evidence:** - -1. **OAuth2 Configuration Already Generic:** - - `auth_server/oauth2_providers.yml` defines providers - - Each provider has: `auth_url`, `token_url`, `user_info_url`, etc. - - Entra ID fits this pattern perfectly - -2. **Web Login Flow is Provider-Agnostic:** - - User clicks "Login" → redirected to IdP's `auth_url` - - User authenticates with IdP - - IdP redirects back with authorization code - - Server exchanges code for token at `token_url` - - Server gets user info from `user_info_url` - - Server creates session cookie - -3. **Frontend is Provider-Agnostic:** - - Frontend only knows about session cookies - - Doesn't care if user authenticated via Keycloak, Cognito, or Entra ID - - No frontend code changes needed - -### What Needs to Change? - -#### File 1: `auth_server/server.py` - -**Change Line 1027-1032:** - -**Before:** -```python -if user_groups and validation_result.get('method') == 'keycloak': - user_scopes = map_groups_to_scopes(user_groups) -``` - -**After:** -```python -# Map groups to scopes for any IdP that provides groups -if user_groups and validation_result.get('method') in ['keycloak', 'entra', 'cognito']: - user_scopes = map_groups_to_scopes(user_groups) - logger.info(f"Mapped {validation_result.get('method')} groups to scopes") -``` - -#### File 2: `registry/auth/dependencies.py` - -**Optional Refactor (Line 151):** - -**Before:** -```python -def map_cognito_groups_to_scopes(groups: List[str]) -> List[str]: - """ - Map Cognito groups to MCP scopes using the scopes.yml configuration. -``` - -**After (optional, for clarity):** -```python -def map_groups_to_scopes(groups: List[str]) -> List[str]: - """ - Map IdP groups to MCP scopes using the scopes.yml configuration. - Works for Cognito, Keycloak, Entra ID, and any OIDC provider. -``` - -**Then update callers at lines 392, 402.** - ---- - -## Final Recommendations - -### ✅ Option 1 Implementation is CONFIRMED VIABLE - -**Summary of Changes Needed:** - -| Component | Changes Required | Effort | -|-----------|-----------------|--------| -| **Auth Server Provider** | Create `auth_server/providers/entra.py` (copy Cognito) | 2-3 hours | -| **Factory** | Add `elif provider_type == 'entra'` case | 30 minutes | -| **Group Mapping Logic** | Change line 1027-1032 to include 'entra' | 15 minutes | -| **Configuration** | Add Entra ID to `.env.example`, `oauth2_providers.yml` | 30 minutes | -| **Testing** | Manual testing + create test script | 1-2 hours | -| **Documentation** | Azure Portal setup guide | 1-2 hours | -| **TOTAL** | **6-9 hours (1-2 days)** | | - -### Code Changes Summary - -#### 1. New Files (2 files) -- `auth_server/providers/entra.py` (~280 lines) - **90% copy from Cognito** -- `credentials-provider/entra/generate_tokens.py` (~100 lines) - Optional helper - -#### 2. Modified Files (3 files) -- `auth_server/providers/factory.py` - Add ~30 lines -- `auth_server/server.py` - Change 1 line (line 1029) -- `.env.example` - Add 3 env vars - -#### 3. Configuration Files (1 file) -- `auth_server/oauth2_providers.yml` - Add Entra ID section (~15 lines) - -### Groups-to-Scopes Mapping - No Changes Needed! - -**✅ Current `scopes.yml` works as-is for Entra ID** - -**Example Usage:** -```yaml -# scopes.yml -group_mappings: - # Works with Keycloak group names - mcp-servers-unrestricted: - - mcp-servers-unrestricted/read - - mcp-servers-unrestricted/execute - - # Works with Entra ID Group Object IDs - "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": - - mcp-servers-unrestricted/read - - mcp-servers-unrestricted/execute - - # Works with Cognito group names - cognito-admins: - - mcp-registry-admin -``` - -**Key Insight:** The mapping is a simple dict lookup. Keys can be: -- Keycloak group names (strings) -- Entra ID Group Object IDs (GUIDs as strings) -- Cognito group names (strings) - -### Entra ID as Primary IdP - Full Support - -**✅ Can replace Keycloak entirely** - -**What works:** -- Web login (OAuth 2.0 Authorization Code Flow) -- User authentication with Microsoft accounts -- Group-based access control -- Session management -- All existing UI functionality -- API authentication with JWT tokens -- M2M authentication with service principals - -**What doesn't require changes:** -- Frontend (already provider-agnostic) -- Registry UI (works via session cookies) -- Scopes configuration (already generic) -- Group-to-scope mapping (already generic) - -**One-line summary:** -> Set `AUTH_PROVIDER=entra`, add Entra ID credentials, restart auth-server, and it works! - -### Risk Assessment - -**🟢 LOW RISK** - Minimal code changes, isolated to provider layer - -**Why Low Risk:** -1. **Copy-paste approach** - Proven pattern from Cognito -2. **Provider isolation** - Changes don't affect existing providers -3. **Generic infrastructure** - Groups/scopes mapping already works -4. **No frontend changes** - UI is provider-agnostic -5. **Backward compatible** - Existing Keycloak/Cognito continue working - -**Testing Strategy:** -1. Test Entra ID in isolation (new .env config) -2. Verify existing providers still work (Keycloak, Cognito) -3. Test group-to-scope mapping with Azure AD groups -4. Test web login flow -5. Test M2M authentication with service principals - -### Checklist for Implementation - -- [ ] Create `auth_server/providers/entra.py` (copy from cognito.py) -- [ ] Update `auth_server/providers/factory.py` (add entra case) -- [ ] Fix `auth_server/server.py` line 1029 (add 'entra' to list) -- [ ] Add Entra ID to `auth_server/oauth2_providers.yml` -- [ ] Add Entra ID env vars to `.env.example` -- [ ] Create Azure AD app registration -- [ ] Create Azure AD security groups -- [ ] Map groups in `scopes.yml` (using Object IDs) -- [ ] Test web login flow -- [ ] Test M2M token generation -- [ ] Test group-based permissions -- [ ] Create documentation (`docs/entra-id-setup.md`) -- [ ] Optional: Rename `map_cognito_groups_to_scopes()` for clarity -- [ ] Optional: Create `test-entra-mcp.sh` test script -- [ ] Optional: Create setup script `keycloak/setup/init-entra.sh` - ---- - -## Conclusion - -By copying `CognitoProvider` and changing URLs/claim names, you can add Entra ID support in **less than a day** with minimal code changes and **zero refactoring** of existing providers. - -This approach leverages the fact that OIDC is a standard protocol - all providers work the same way, they just have different endpoints and claim names. - -**Critical Review Confirms:** -✅ Groups-to-scopes mapping is already generic -✅ No provider-specific coupling in core logic (except one line to fix) -✅ Frontend is provider-agnostic -✅ Entra ID can be full IdP replacement for Keycloak -✅ All existing functionality continues to work -✅ Low risk, high reward implementation - -**Estimated Total Effort: 6-9 hours (1-2 days)** diff --git a/registry/api/server_routes.py b/registry/api/server_routes.py index 85ce162d..569b4488 100644 --- a/registry/api/server_routes.py +++ b/registry/api/server_routes.py @@ -10,7 +10,7 @@ import httpx from ..core.config import settings -from ..auth.dependencies import web_auth, api_auth, enhanced_auth +from ..auth.dependencies import web_auth, api_auth, enhanced_auth, nginx_proxied_auth from ..services.server_service import server_service logger = logging.getLogger(__name__) @@ -119,7 +119,7 @@ def can_perform_action(permission: str, service_name: str) -> bool: @router.get("/servers") async def get_servers_json( query: str | None = None, - user_context: Annotated[dict, Depends(enhanced_auth)] = None, + user_context: Annotated[dict, Depends(nginx_proxied_auth)] = None, ): """Get servers data as JSON for React frontend (reuses root route logic).""" service_data = [] diff --git a/registry/auth/dependencies.py b/registry/auth/dependencies.py index 1327f68b..9963d6e1 100644 --- a/registry/auth/dependencies.py +++ b/registry/auth/dependencies.py @@ -461,10 +461,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: @@ -491,7 +491,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, 'ui_permissions': ui_permissions, From 915dc314427bc37db608c4665faa7ef2bc17a1a4 Mon Sep 17 00:00:00 2001 From: Nisha Deborah Philips Date: Wed, 5 Nov 2025 15:52:37 -0500 Subject: [PATCH 04/15] added token gen --- credentials-provider/oauth/ingress_oauth.py | 131 ++++++++++++++++++-- 1 file changed, 123 insertions(+), 8 deletions(-) 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, From b151478367b6f6f3b5839e55f5a9cc40352cc81d Mon Sep 17 00:00:00 2001 From: Nisha Deborah Philips Date: Wed, 5 Nov 2025 16:47:13 -0500 Subject: [PATCH 05/15] commented out the github env variable --- .env.example | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 3f145fc3..41b85197 100644 --- a/.env.example +++ b/.env.example @@ -195,12 +195,12 @@ DOCKERHUB_TOKEN=your_dockerhub_access_token # The GITHUB_TOKEN is automatically provided in GitHub Actions # For local builds, generate a Personal Access Token with packages:write scope # Get this from https://github.com/settings/tokens -GITHUB_USERNAME=your_github_username -GITHUB_TOKEN=your_github_personal_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 # ============================================================================= # ADDITIONAL CONFIGURATION From 497aef6232b73e4bb097013f4d723b5b406d8c88 Mon Sep 17 00:00:00 2001 From: Nisha Deborah Philips Date: Thu, 6 Nov 2025 11:32:42 -0500 Subject: [PATCH 06/15] one server only --- auth_server/scopes.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/auth_server/scopes.yml b/auth_server/scopes.yml index ef3a1f02..8aba3baa 100644 --- a/auth_server/scopes.yml +++ b/auth_server/scopes.yml @@ -61,8 +61,7 @@ group_mappings: - mcp-servers-unrestricted/execute # Users group: mcp-test-users "62c07ac1-03d0-4924-90c7-a0255f23bd1d": - - mcp-registry-user - - mcp-servers-restricted/read + - mcp-servers-currenttime-only/read mcp-servers-unrestricted/read: - server: auth_server methods: @@ -544,3 +543,15 @@ mcp-servers-restricted/execute: - temporal_anomaly_detector - user_profile_analyzer - synthetic_data_generator +mcp-servers-currenttime-only/read: +- server: currenttime + methods: + - initialize + - notifications/initialized + - ping + - tools/list + - tools/call + - resources/list + - resources/templates/list + tools: + - current_time_by_timezone \ No newline at end of file From 885d8d547d325b559b334adb884c02ae515ffcd7 Mon Sep 17 00:00:00 2001 From: Nisha Deborah Philips Date: Tue, 11 Nov 2025 10:35:57 -0600 Subject: [PATCH 07/15] fixing merge conflicts overwrting --- docker/nginx_rev_proxy_http_and_https.conf | 69 +++++++++++++++++++++- registry/api/server_routes.py | 4 +- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/docker/nginx_rev_proxy_http_and_https.conf b/docker/nginx_rev_proxy_http_and_https.conf index ae13a4e1..24aab9d3 100644 --- a/docker/nginx_rev_proxy_http_and_https.conf +++ b/docker/nginx_rev_proxy_http_and_https.conf @@ -14,7 +14,74 @@ server { # Add this to trigger the named location for 403 errors error_page 403 = @forbidden_error; - # Route for Cost Explorer service + # Public auth endpoints - no authentication required (priority prefix match) + location ^~ /api/auth { + proxy_pass http://127.0.0.1:7860; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass_request_headers on; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # Public health endpoint - no authentication required (priority prefix match) + location ^~ /api/health { + proxy_pass http://127.0.0.1:7860; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass_request_headers on; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # Protected API endpoints - require authentication + location /api/ { + # Authenticate request via auth server (validates JWT Bearer tokens) + auth_request /validate; + + # Capture auth server response headers + auth_request_set $auth_user $upstream_http_x_user; + auth_request_set $auth_username $upstream_http_x_username; + auth_request_set $auth_client_id $upstream_http_x_client_id; + auth_request_set $auth_scopes $upstream_http_x_scopes; + auth_request_set $auth_method $upstream_http_x_auth_method; + + # Proxy to FastAPI service + proxy_pass http://127.0.0.1:7860/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Forward validated auth context to FastAPI + proxy_set_header X-User $auth_user; + proxy_set_header X-Username $auth_username; + proxy_set_header X-Client-Id $auth_client_id; + proxy_set_header X-Scopes $auth_scopes; + proxy_set_header X-Auth-Method $auth_method; + + # Pass through original Authorization header + proxy_set_header Authorization $http_authorization; + + # Pass all request headers + proxy_pass_request_headers on; + + # Timeouts + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # Route for Cost Explorer service (catch-all for unauthenticated routes) location / { proxy_pass http://127.0.0.1:7860/; proxy_http_version 1.1; diff --git a/registry/api/server_routes.py b/registry/api/server_routes.py index a95c0aec..788eb14e 100644 --- a/registry/api/server_routes.py +++ b/registry/api/server_routes.py @@ -10,7 +10,7 @@ import httpx from ..core.config import settings -from ..auth.dependencies import web_auth, api_auth, enhanced_auth, nginx_proxied_auth +from ..auth.dependencies import web_auth, api_auth, enhanced_auth from ..services.server_service import server_service logger = logging.getLogger(__name__) @@ -119,7 +119,7 @@ def can_perform_action(permission: str, service_name: str) -> bool: @router.get("/servers") async def get_servers_json( query: str | None = None, - user_context: Annotated[dict, Depends(nginx_proxied_auth)] = None, + user_context: Annotated[dict, Depends(enhanced_auth)] = None, ): """Get servers data as JSON for React frontend (reuses root route logic).""" service_data = [] From 3136d1aa09ffbd53165b149eaca8cf4dc10a5ec7 Mon Sep 17 00:00:00 2001 From: Nisha Deborah Philips Date: Tue, 11 Nov 2025 10:38:26 -0600 Subject: [PATCH 08/15] resolving merge conflicts overwrite --- docker/nginx_rev_proxy_http_and_https.conf | 69 +--------------------- docker/nginx_rev_proxy_http_only.conf | 69 +++++++++++++++++++++- 2 files changed, 69 insertions(+), 69 deletions(-) diff --git a/docker/nginx_rev_proxy_http_and_https.conf b/docker/nginx_rev_proxy_http_and_https.conf index 24aab9d3..ae13a4e1 100644 --- a/docker/nginx_rev_proxy_http_and_https.conf +++ b/docker/nginx_rev_proxy_http_and_https.conf @@ -14,74 +14,7 @@ server { # Add this to trigger the named location for 403 errors error_page 403 = @forbidden_error; - # Public auth endpoints - no authentication required (priority prefix match) - location ^~ /api/auth { - proxy_pass http://127.0.0.1:7860; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_pass_request_headers on; - proxy_connect_timeout 10s; - proxy_send_timeout 30s; - proxy_read_timeout 30s; - } - - # Public health endpoint - no authentication required (priority prefix match) - location ^~ /api/health { - proxy_pass http://127.0.0.1:7860; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_pass_request_headers on; - proxy_connect_timeout 10s; - proxy_send_timeout 30s; - proxy_read_timeout 30s; - } - - # Protected API endpoints - require authentication - location /api/ { - # Authenticate request via auth server (validates JWT Bearer tokens) - auth_request /validate; - - # Capture auth server response headers - auth_request_set $auth_user $upstream_http_x_user; - auth_request_set $auth_username $upstream_http_x_username; - auth_request_set $auth_client_id $upstream_http_x_client_id; - auth_request_set $auth_scopes $upstream_http_x_scopes; - auth_request_set $auth_method $upstream_http_x_auth_method; - - # Proxy to FastAPI service - proxy_pass http://127.0.0.1:7860/api/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # Forward validated auth context to FastAPI - proxy_set_header X-User $auth_user; - proxy_set_header X-Username $auth_username; - proxy_set_header X-Client-Id $auth_client_id; - proxy_set_header X-Scopes $auth_scopes; - proxy_set_header X-Auth-Method $auth_method; - - # Pass through original Authorization header - proxy_set_header Authorization $http_authorization; - - # Pass all request headers - proxy_pass_request_headers on; - - # Timeouts - proxy_connect_timeout 10s; - proxy_send_timeout 30s; - proxy_read_timeout 30s; - } - - # Route for Cost Explorer service (catch-all for unauthenticated routes) + # Route for Cost Explorer service location / { proxy_pass http://127.0.0.1:7860/; proxy_http_version 1.1; diff --git a/docker/nginx_rev_proxy_http_only.conf b/docker/nginx_rev_proxy_http_only.conf index 45b3f0e0..68d27367 100644 --- a/docker/nginx_rev_proxy_http_only.conf +++ b/docker/nginx_rev_proxy_http_only.conf @@ -14,7 +14,74 @@ server { # Add this to trigger the named location for 403 errors error_page 403 = @forbidden_error; - # Route for Cost Explorer service + # Public auth endpoints - no authentication required (priority prefix match) + location ^~ /api/auth { + proxy_pass http://127.0.0.1:7860; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass_request_headers on; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # Public health endpoint - no authentication required (priority prefix match) + location ^~ /api/health { + proxy_pass http://127.0.0.1:7860; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass_request_headers on; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # Protected API endpoints - require authentication + location /api/ { + # Authenticate request via auth server (validates JWT Bearer tokens) + auth_request /validate; + + # Capture auth server response headers + auth_request_set $auth_user $upstream_http_x_user; + auth_request_set $auth_username $upstream_http_x_username; + auth_request_set $auth_client_id $upstream_http_x_client_id; + auth_request_set $auth_scopes $upstream_http_x_scopes; + auth_request_set $auth_method $upstream_http_x_auth_method; + + # Proxy to FastAPI service + proxy_pass http://127.0.0.1:7860/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Forward validated auth context to FastAPI + proxy_set_header X-User $auth_user; + proxy_set_header X-Username $auth_username; + proxy_set_header X-Client-Id $auth_client_id; + proxy_set_header X-Scopes $auth_scopes; + proxy_set_header X-Auth-Method $auth_method; + + # Pass through original Authorization header + proxy_set_header Authorization $http_authorization; + + # Pass all request headers + proxy_pass_request_headers on; + + # Timeouts + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # Route for Cost Explorer service (catch-all for unauthenticated routes) location / { proxy_pass http://127.0.0.1:7860/; proxy_http_version 1.1; From 6083f386f2cb7ec8d924a0e89b8b1d380e7a747f Mon Sep 17 00:00:00 2001 From: Nisha Deborah Philips Date: Tue, 11 Nov 2025 10:50:57 -0600 Subject: [PATCH 09/15] removed from scopes.yml --- auth_server/scopes.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/auth_server/scopes.yml b/auth_server/scopes.yml index 9e756afe..54d1084e 100644 --- a/auth_server/scopes.yml +++ b/auth_server/scopes.yml @@ -128,17 +128,6 @@ group_mappings: registry-users-lob2: - registry-users-lob2 -# ==================== MCP SERVER SCOPES ==================== -# Unrestricted read access: Wildcard access to all servers with all methods and tools - # Entra ID group mappings (by Azure AD Group Object IDs) - # Admin group: Mcp-test-admin - "16c7e67e-e8ae-498c-ba2e-0593c0159e43": - - mcp-registry-admin - - mcp-servers-unrestricted/read - - mcp-servers-unrestricted/execute - # Users group: mcp-test-users - "62c07ac1-03d0-4924-90c7-a0255f23bd1d": - - mcp-servers-currenttime-only/read mcp-servers-unrestricted/read: - server: '*' methods: From 1a221cd22171bfe0449915f7ffb5f3b8c63efa4d Mon Sep 17 00:00:00 2001 From: Nisha Deborah Philips Date: Tue, 11 Nov 2025 17:57:23 -0600 Subject: [PATCH 10/15] updated readme --- README.md | 1 + ...RA-ID-SETUP-GUIDE.md => entra-id-setup.md} | 131 +++++++++++++++--- 2 files changed, 114 insertions(+), 18 deletions(-) rename docs/{ENTRA-ID-SETUP-GUIDE.md => entra-id-setup.md} (88%) diff --git a/README.md b/README.md index 9bd59b5f..33f14991 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. diff --git a/docs/ENTRA-ID-SETUP-GUIDE.md b/docs/entra-id-setup.md similarity index 88% rename from docs/ENTRA-ID-SETUP-GUIDE.md rename to docs/entra-id-setup.md index 36712bf1..f6d2f0d0 100644 --- a/docs/ENTRA-ID-SETUP-GUIDE.md +++ b/docs/entra-id-setup.md @@ -245,27 +245,12 @@ The `auth_server/scopes.yml` file maps Azure AD groups to MCP Gateway scopes and ```yaml group_mappings: - # Keycloak/Generic group mappings (by group name) - mcp-registry-admin: - - mcp-registry-admin - - mcp-servers-unrestricted/read - - mcp-servers-unrestricted/execute - - mcp-registry-user: - - mcp-registry-user - - mcp-servers-restricted/read - # Entra ID group mappings (by Azure AD Group Object IDs) - # Admin group: Mcp-test-admin - "16c7e67e-e8ae-498c-ba2e-0593c0159e43": + # Admin group + "object_id": - mcp-registry-admin - - mcp-servers-unrestricted/read - - mcp-servers-unrestricted/execute + - registry-admins - # Users group: mcp-test-users - "62c07ac1-03d0-4924-90c7-a0255f23bd1d": - - mcp-registry-user - - mcp-servers-restricted/read ``` 3. Replace the group Object IDs with your actual group IDs from Azure Portal @@ -501,6 +486,116 @@ You don't see the "Grant admin consent" button or get an error when clicking it --- +## 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** From 9a9febe7e491604db8fa5b1a4f520a2a8dad7d07 Mon Sep 17 00:00:00 2001 From: Nisha Deborah Philips Date: Tue, 11 Nov 2025 17:58:24 -0600 Subject: [PATCH 11/15] added reverted changes to scopes.yml --- auth_server/scopes.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/auth_server/scopes.yml b/auth_server/scopes.yml index 54d1084e..80065df9 100644 --- a/auth_server/scopes.yml +++ b/auth_server/scopes.yml @@ -127,7 +127,9 @@ group_mappings: - registry-users-lob1 registry-users-lob2: - registry-users-lob2 - + +# ==================== MCP SERVER SCOPES ==================== +# Unrestricted read access: Wildcard access to all servers with all methods and tools mcp-servers-unrestricted/read: - server: '*' methods: From ec275c9d5778533d9facff4b2c314a0aaaad899b Mon Sep 17 00:00:00 2001 From: Nisha Deborah Philips Date: Tue, 11 Nov 2025 17:59:18 -0600 Subject: [PATCH 12/15] added reverted changes to scopes.yml --- cli/examples/my_agent.json | 138 +++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 cli/examples/my_agent.json diff --git a/cli/examples/my_agent.json b/cli/examples/my_agent.json new file mode 100644 index 00000000..5ef20a82 --- /dev/null +++ b/cli/examples/my_agent.json @@ -0,0 +1,138 @@ +{ + "protocol_version": "1.0", + "name": "Code Reviewer Agent", + "description": "Comprehensive code review agent that analyzes code quality, identifies bugs, suggests improvements, and provides detailed feedback on code structure and best practices", + "url": "https://example.com/agents/code-reviewer", + "version": "2.1.0", + "provider": "Example Corp", + "path": "/code-reviewer", + "tags": "code-review,quality-analysis,testing,best-practices", + "is_enabled": true, + "num_stars": 42, + "license": "MIT", + "visibility": "public", + "trust_level": "verified", + "streaming": true, + "security_schemes": { + "bearer_auth": { + "type": "http", + "scheme": "bearer", + "bearer_format": "JWT" + } + }, + "security": [ + { + "bearer_auth": [] + } + ], + "skills": [ + { + "id": "analyze_code_quality", + "name": "Analyze Code Quality", + "description": "Analyze code for quality metrics including complexity, duplication, and maintainability", + "parameters": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "The code to analyze" + }, + "language": { + "type": "string", + "enum": [ + "python", + "javascript", + "java", + "go", + "rust" + ], + "description": "Programming language" + } + }, + "required": [ + "code", + "language" + ] + }, + "tags": [ + "analysis", + "metrics" + ] + }, + { + "id": "detect_bugs", + "name": "Detect Bugs", + "description": "Identify potential bugs and issues in the code", + "parameters": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "The code to analyze for bugs" + }, + "severity": { + "type": "string", + "enum": [ + "critical", + "high", + "medium", + "low" + ], + "description": "Minimum severity level to report" + } + }, + "required": [ + "code" + ] + }, + "tags": [ + "bug-detection", + "validation" + ] + }, + { + "id": "suggest_improvements", + "name": "Suggest Improvements", + "description": "Provide suggestions for code improvements and refactoring", + "parameters": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "The code to improve" + }, + "focus_areas": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "performance", + "readability", + "testability", + "security" + ] + }, + "description": "Areas to focus improvements on" + } + }, + "required": [ + "code" + ] + }, + "tags": [ + "improvement", + "refactoring" + ] + } + ], + "metadata": { + "max_code_size_mb": 10, + "supported_formats": [ + "json", + "xml", + "yaml" + ], + "response_time_ms": 2000, + "availability": "99.9%" + } +} From 7a59fdb6422f6193e1cbe11c9987e1008c671a7e Mon Sep 17 00:00:00 2001 From: Nisha Deborah Philips Date: Tue, 11 Nov 2025 18:00:31 -0600 Subject: [PATCH 13/15] added reverted changes to scopes.yml - 1 --- auth_server/scopes.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/auth_server/scopes.yml b/auth_server/scopes.yml index 80065df9..29c505d8 100644 --- a/auth_server/scopes.yml +++ b/auth_server/scopes.yml @@ -116,7 +116,6 @@ UI-Scopes: # ==================== GROUP MAPPINGS ==================== # Maps Keycloak groups to internal scope group names group_mappings: - # Keycloak/Generic group mappings (by group name) mcp-registry-admin: - mcp-registry-admin - mcp-servers-unrestricted/read @@ -127,7 +126,6 @@ group_mappings: - registry-users-lob1 registry-users-lob2: - registry-users-lob2 - # ==================== MCP SERVER SCOPES ==================== # Unrestricted read access: Wildcard access to all servers with all methods and tools mcp-servers-unrestricted/read: From 00a48dda3faf593803781a1d149735ba7b098987 Mon Sep 17 00:00:00 2001 From: Nisha Deborah Philips Date: Tue, 11 Nov 2025 18:01:57 -0600 Subject: [PATCH 14/15] committed my_agent.json by mistake --- cli/examples/my_agent.json | 138 ------------------------------------- 1 file changed, 138 deletions(-) delete mode 100644 cli/examples/my_agent.json diff --git a/cli/examples/my_agent.json b/cli/examples/my_agent.json deleted file mode 100644 index 5ef20a82..00000000 --- a/cli/examples/my_agent.json +++ /dev/null @@ -1,138 +0,0 @@ -{ - "protocol_version": "1.0", - "name": "Code Reviewer Agent", - "description": "Comprehensive code review agent that analyzes code quality, identifies bugs, suggests improvements, and provides detailed feedback on code structure and best practices", - "url": "https://example.com/agents/code-reviewer", - "version": "2.1.0", - "provider": "Example Corp", - "path": "/code-reviewer", - "tags": "code-review,quality-analysis,testing,best-practices", - "is_enabled": true, - "num_stars": 42, - "license": "MIT", - "visibility": "public", - "trust_level": "verified", - "streaming": true, - "security_schemes": { - "bearer_auth": { - "type": "http", - "scheme": "bearer", - "bearer_format": "JWT" - } - }, - "security": [ - { - "bearer_auth": [] - } - ], - "skills": [ - { - "id": "analyze_code_quality", - "name": "Analyze Code Quality", - "description": "Analyze code for quality metrics including complexity, duplication, and maintainability", - "parameters": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "The code to analyze" - }, - "language": { - "type": "string", - "enum": [ - "python", - "javascript", - "java", - "go", - "rust" - ], - "description": "Programming language" - } - }, - "required": [ - "code", - "language" - ] - }, - "tags": [ - "analysis", - "metrics" - ] - }, - { - "id": "detect_bugs", - "name": "Detect Bugs", - "description": "Identify potential bugs and issues in the code", - "parameters": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "The code to analyze for bugs" - }, - "severity": { - "type": "string", - "enum": [ - "critical", - "high", - "medium", - "low" - ], - "description": "Minimum severity level to report" - } - }, - "required": [ - "code" - ] - }, - "tags": [ - "bug-detection", - "validation" - ] - }, - { - "id": "suggest_improvements", - "name": "Suggest Improvements", - "description": "Provide suggestions for code improvements and refactoring", - "parameters": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "The code to improve" - }, - "focus_areas": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "performance", - "readability", - "testability", - "security" - ] - }, - "description": "Areas to focus improvements on" - } - }, - "required": [ - "code" - ] - }, - "tags": [ - "improvement", - "refactoring" - ] - } - ], - "metadata": { - "max_code_size_mb": 10, - "supported_formats": [ - "json", - "xml", - "yaml" - ], - "response_time_ms": 2000, - "availability": "99.9%" - } -} From a1f5c9c3a32b7bb1b5b6548366baa47e5496a97b Mon Sep 17 00:00:00 2001 From: Nisha Deborah Philips Date: Tue, 11 Nov 2025 18:04:27 -0600 Subject: [PATCH 15/15] marking 128 as complete in readme --- README.md | 2 +- auth_server/scopes.yml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 33f14991..af018d5a 100644 --- a/README.md +++ b/README.md @@ -552,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/scopes.yml b/auth_server/scopes.yml index 29c505d8..74f011f3 100644 --- a/auth_server/scopes.yml +++ b/auth_server/scopes.yml @@ -126,6 +126,7 @@ group_mappings: - registry-users-lob1 registry-users-lob2: - registry-users-lob2 + # ==================== MCP SERVER SCOPES ==================== # Unrestricted read access: Wildcard access to all servers with all methods and tools mcp-servers-unrestricted/read: @@ -297,4 +298,4 @@ registry-admins: - all - action: delete_agent resources: - - all + - all \ No newline at end of file