diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index f6220aa71..e239210dc 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -5774,6 +5774,8 @@ async def admin_add_gateway(request: Request, db: Session = Depends(get_db), use oauth_redirect_uri = str(form.get("oauth_redirect_uri", "")) oauth_client_id = str(form.get("oauth_client_id", "")) oauth_client_secret = str(form.get("oauth_client_secret", "")) + oauth_username = str(form.get("oauth_username", "")) + oauth_password = str(form.get("oauth_password", "")) oauth_scopes_str = str(form.get("oauth_scopes", "")) # If any OAuth field is provided, assemble oauth_config @@ -5797,6 +5799,12 @@ async def admin_add_gateway(request: Request, db: Session = Depends(get_db), use encryption = get_oauth_encryption(settings.auth_encryption_secret) oauth_config["client_secret"] = encryption.encrypt_secret(oauth_client_secret) + # Add username and password for password grant type + if oauth_username: + oauth_config["username"] = oauth_username + if oauth_password: + oauth_config["password"] = oauth_password + # Parse scopes (comma or space separated) if oauth_scopes_str: scopes = [s.strip() for s in oauth_scopes_str.replace(",", " ").split() if s.strip()] @@ -6077,6 +6085,8 @@ async def admin_edit_gateway( oauth_redirect_uri = str(form.get("oauth_redirect_uri", "")) oauth_client_id = str(form.get("oauth_client_id", "")) oauth_client_secret = str(form.get("oauth_client_secret", "")) + oauth_username = str(form.get("oauth_username", "")) + oauth_password = str(form.get("oauth_password", "")) oauth_scopes_str = str(form.get("oauth_scopes", "")) # If any OAuth field is provided, assemble oauth_config @@ -6100,6 +6110,12 @@ async def admin_edit_gateway( encryption = get_oauth_encryption(settings.auth_encryption_secret) oauth_config["client_secret"] = encryption.encrypt_secret(oauth_client_secret) + # Add username and password for password grant type + if oauth_username: + oauth_config["username"] = oauth_username + if oauth_password: + oauth_config["password"] = oauth_password + # Parse scopes (comma or space separated) if oauth_scopes_str: scopes = [s.strip() for s in oauth_scopes_str.replace(",", " ").split() if s.strip()] diff --git a/mcpgateway/services/oauth_manager.py b/mcpgateway/services/oauth_manager.py index a7cd7a9d4..0678dad93 100644 --- a/mcpgateway/services/oauth_manager.py +++ b/mcpgateway/services/oauth_manager.py @@ -182,6 +182,8 @@ async def get_access_token(self, credentials: Dict[str, Any]) -> str: if grant_type == "client_credentials": return await self._client_credentials_flow(credentials) + if grant_type == "password": + return await self._password_flow(credentials) if grant_type == "authorization_code": # For authorization code flow in gateway initialization, we need to handle this differently # Since this is called during gateway setup, we'll try to use client credentials as fallback @@ -282,6 +284,105 @@ async def _client_credentials_flow(self, credentials: Dict[str, Any]) -> str: # This should never be reached due to the exception above, but needed for type safety raise OAuthError("Failed to obtain access token after all retry attempts") + async def _password_flow(self, credentials: Dict[str, Any]) -> str: + """Resource Owner Password Credentials flow (RFC 6749 Section 4.3). + + This flow is used when the application can directly handle the user's credentials, + such as with trusted first-party applications or legacy integrations like Keycloak. + + Args: + credentials: OAuth configuration with client_id, optional client_secret, token_url, username, password + + Returns: + Access token string + + Raises: + OAuthError: If token acquisition fails after all retries + """ + client_id = credentials.get("client_id") + client_secret = credentials.get("client_secret") + token_url = credentials["token_url"] + username = credentials.get("username") + password = credentials.get("password") + scopes = credentials.get("scopes", []) + + if not username or not password: + raise OAuthError("Username and password are required for password grant type") + + # Decrypt client secret if it's encrypted and present + if client_secret and len(client_secret) > 50: # Simple heuristic: encrypted secrets are longer + try: + settings = get_settings() + encryption = get_oauth_encryption(settings.auth_encryption_secret) + decrypted_secret = encryption.decrypt_secret(client_secret) + if decrypted_secret: + client_secret = decrypted_secret + logger.debug("Successfully decrypted client secret") + else: + logger.warning("Failed to decrypt client secret, using encrypted version") + except Exception as e: + logger.warning(f"Failed to decrypt client secret: {e}, using encrypted version") + + # Prepare token request data + token_data = { + "grant_type": "password", + "username": username, + "password": password, + } + + # Add client_id (required by most providers including Keycloak) + if client_id: + token_data["client_id"] = client_id + + # Add client_secret if present (some providers require it, others don't) + if client_secret: + token_data["client_secret"] = client_secret + + if scopes: + token_data["scope"] = " ".join(scopes) if isinstance(scopes, list) else scopes + + # Fetch token with retries + for attempt in range(self.max_retries): + try: + async with aiohttp.ClientSession() as session: + async with session.post(token_url, data=token_data, timeout=aiohttp.ClientTimeout(total=self.request_timeout)) as response: + response.raise_for_status() + + # Handle both JSON and form-encoded responses + content_type = response.headers.get("content-type", "") + if "application/x-www-form-urlencoded" in content_type: + # Parse form-encoded response + text_response = await response.text() + token_response = {} + for pair in text_response.split("&"): + if "=" in pair: + key, value = pair.split("=", 1) + token_response[key] = value + else: + # Try JSON response + try: + token_response = await response.json() + except Exception as e: + logger.warning(f"Failed to parse JSON response: {e}") + # Fallback to text parsing + text_response = await response.text() + token_response = {"raw_response": text_response} + + if "access_token" not in token_response: + raise OAuthError(f"No access_token in response: {token_response}") + + logger.info("Successfully obtained access token via password grant") + return token_response["access_token"] + + except aiohttp.ClientError as e: + logger.warning(f"Token request attempt {attempt + 1} failed: {str(e)}") + if attempt == self.max_retries - 1: + raise OAuthError(f"Failed to obtain access token after {self.max_retries} attempts: {str(e)}") + await asyncio.sleep(2**attempt) # Exponential backoff + + # This should never be reached due to the exception above, but needed for type safety + raise OAuthError("Failed to obtain access token after all retry attempts") + async def get_authorization_url(self, credentials: Dict[str, Any]) -> Dict[str, str]: """Get authorization URL for user delegation flow. diff --git a/mcpgateway/static/admin.js b/mcpgateway/static/admin.js index a089e50d2..6a5812b53 100644 --- a/mcpgateway/static/admin.js +++ b/mcpgateway/static/admin.js @@ -9306,6 +9306,8 @@ function handleAuthTypeChange() { function handleOAuthGrantTypeChange() { const grantType = this.value; const authCodeFields = safeGetElement("oauth-auth-code-fields-gw"); + const usernameField = safeGetElement("oauth-username-field-gw"); + const passwordField = safeGetElement("oauth-password-field-gw"); if (authCodeFields) { if (grantType === "authorization_code") { @@ -9333,11 +9335,48 @@ function handleOAuthGrantTypeChange() { }); } } + + // Handle password grant type fields + if (usernameField && passwordField) { + if (grantType === "password") { + usernameField.style.display = "block"; + passwordField.style.display = "block"; + + // Make username and password required for password grant + const usernameInput = safeGetElement("oauth-username-gw"); + const passwordInput = safeGetElement("oauth-password-gw"); + if (usernameInput) { + usernameInput.required = true; + } + if (passwordInput) { + passwordInput.required = true; + } + + console.log( + "Password grant flow selected - username and password are now required", + ); + } else { + usernameField.style.display = "none"; + passwordField.style.display = "none"; + + // Remove required validation for hidden fields + const usernameInput = safeGetElement("oauth-username-gw"); + const passwordInput = safeGetElement("oauth-password-gw"); + if (usernameInput) { + usernameInput.required = false; + } + if (passwordInput) { + passwordInput.required = false; + } + } + } } function handleEditOAuthGrantTypeChange() { const grantType = this.value; const authCodeFields = safeGetElement("oauth-auth-code-fields-gw-edit"); + const usernameField = safeGetElement("oauth-username-field-edit"); + const passwordField = safeGetElement("oauth-password-field-edit"); if (authCodeFields) { if (grantType === "authorization_code") { @@ -9365,6 +9404,41 @@ function handleEditOAuthGrantTypeChange() { }); } } + + // Handle password grant type fields + if (usernameField && passwordField) { + if (grantType === "password") { + usernameField.style.display = "block"; + passwordField.style.display = "block"; + + // Make username and password required for password grant + const usernameInput = safeGetElement("oauth-username-gw-edit"); + const passwordInput = safeGetElement("oauth-password-gw-edit"); + if (usernameInput) { + usernameInput.required = true; + } + if (passwordInput) { + passwordInput.required = true; + } + + console.log( + "Password grant flow selected - username and password are now required", + ); + } else { + usernameField.style.display = "none"; + passwordField.style.display = "none"; + + // Remove required validation for hidden fields + const usernameInput = safeGetElement("oauth-username-gw-edit"); + const passwordInput = safeGetElement("oauth-password-gw-edit"); + if (usernameInput) { + usernameInput.required = false; + } + if (passwordInput) { + passwordInput.required = false; + } + } + } } function setupSchemaModeHandlers() { diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html index 2e42f1703..5c889ce02 100644 --- a/mcpgateway/templates/admin.html +++ b/mcpgateway/templates/admin.html @@ -4564,6 +4564,9 @@