diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index b296cdfa3f..386cbe9e8d 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -12136,6 +12136,11 @@ async def admin_add_gateway(request: Request, db: Session = Depends(get_db), use if scopes: oauth_config["scopes"] = scopes + # Token endpoint auth method (RFC 6749 Section 2.3) + oauth_token_endpoint_auth_method = str(form.get("oauth_token_endpoint_auth_method", "")) + if oauth_token_endpoint_auth_method: + oauth_config["token_endpoint_auth_method"] = oauth_token_endpoint_auth_method + LOGGER.info(f"✅ Assembled OAuth config from UI form fields: grant_type={oauth_grant_type}, issuer={oauth_issuer}") LOGGER.info(f"DEBUG: Complete oauth_config = {oauth_config}") @@ -12408,6 +12413,11 @@ async def admin_edit_gateway( if scopes: oauth_config["scopes"] = scopes + # Token endpoint auth method (RFC 6749 Section 2.3) + oauth_token_endpoint_auth_method = str(form.get("oauth_token_endpoint_auth_method", "")) + if oauth_token_endpoint_auth_method: + oauth_config["token_endpoint_auth_method"] = oauth_token_endpoint_auth_method + LOGGER.info(f"✅ Assembled OAuth config from UI form fields (edit): grant_type={oauth_grant_type}, issuer={oauth_issuer}") user_email = get_user_email(user) diff --git a/mcpgateway/admin_ui/gateways.js b/mcpgateway/admin_ui/gateways.js index c8028dcf6e..6c42aaa168 100644 --- a/mcpgateway/admin_ui/gateways.js +++ b/mcpgateway/admin_ui/gateways.js @@ -400,6 +400,9 @@ export const editGateway = async function (gatewayId) { const oauthRedirectUriField = safeGetElement("oauth-redirect-uri-gw-edit"); const oauthIssuerField = safeGetElement("oauth-issuer-gw-edit"); const oauthScopesField = safeGetElement("oauth-scopes-gw-edit"); + const oauthTokenEndpointAuthMethodField = safeGetElement( + "oauth-token-endpoint-auth-method-gw-edit" + ); const oauthAuthCodeFields = safeGetElement( "oauth-auth-code-fields-gw-edit" ); @@ -526,6 +529,13 @@ export const editGateway = async function (gatewayId) { ? config.scopes.join(" ") : ""; } + if ( + oauthTokenEndpointAuthMethodField && + config.token_endpoint_auth_method + ) { + oauthTokenEndpointAuthMethodField.value = + config.token_endpoint_auth_method; + } } break; case "query_param": diff --git a/mcpgateway/services/oauth_manager.py b/mcpgateway/services/oauth_manager.py index 84dca18b7c..2ff68e0467 100644 --- a/mcpgateway/services/oauth_manager.py +++ b/mcpgateway/services/oauth_manager.py @@ -241,7 +241,7 @@ async def _prepare_runtime_credentials(credentials: Dict[str, Any], flow_name: s logger.warning("Failed to prepare runtime OAuth credentials for %s flow: %s", flow_name, exc) return credentials - async def _post_token_request(self, url: str, data: Any, ca_certificate: Optional[str] = None, client_cert: Optional[str] = None, client_key: Optional[str] = None) -> httpx.Response: + async def _post_token_request(self, url: str, data: Any, ca_certificate: Optional[str] = None, client_cert: Optional[str] = None, client_key: Optional[str] = None, headers: Optional[dict] = None) -> httpx.Response: """POST to a token endpoint, using a custom SSL context when CA certs are provided. When ``ca_certificate`` is supplied, an isolated ``httpx.AsyncClient`` @@ -256,6 +256,7 @@ async def _post_token_request(self, url: str, data: Any, ca_certificate: Optiona ca_certificate: Optional PEM-encoded CA certificate. client_cert: Optional client certificate for mTLS. client_key: Optional client private key for mTLS. + headers: Optional extra headers (e.g. Authorization: Basic for client_secret_basic). Returns: The HTTP response from the token endpoint. @@ -263,9 +264,9 @@ async def _post_token_request(self, url: str, data: Any, ca_certificate: Optiona if ca_certificate: ssl_context = get_cached_ssl_context(ca_certificate, client_cert=client_cert, client_key=client_key) async with httpx.AsyncClient(verify=ssl_context) as client: - return await client.post(url, data=data, timeout=self.request_timeout) + return await client.post(url, data=data, headers=headers, timeout=self.request_timeout) client = await self._get_client() - return await client.post(url, data=data, timeout=self.request_timeout) + return await client.post(url, data=data, headers=headers, timeout=self.request_timeout) # Keys whose values must never be echoed in error messages or logs. _SENSITIVE_TOKEN_KEYS = frozenset({"access_token", "refresh_token", "id_token", "client_secret", "password"}) @@ -1408,17 +1409,31 @@ async def _exchange_code_for_tokens( token_url = runtime_credentials["token_url"] redirect_uri = runtime_credentials["redirect_uri"] + # Determine token endpoint authentication method (RFC 6749 Section 2.3) + # - "client_secret_post" (default): client_id and client_secret in POST body + # - "client_secret_basic": credentials in Authorization: Basic header + token_auth_method = credentials.get("token_endpoint_auth_method", "client_secret_post") + use_basic_auth = token_auth_method == "client_secret_basic" and client_secret + + # Build HTTP Basic Auth header if required by the provider + auth_header = None + if use_basic_auth: + basic_credentials = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() + auth_header = {"Authorization": f"Basic {basic_credentials}"} + # Prepare token exchange data token_data = { "grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri, - "client_id": client_id, } - # Only include client_secret if present (public clients don't have secrets) - if client_secret: - token_data["client_secret"] = client_secret + # Include client credentials in POST body only when not using Basic auth + if not use_basic_auth: + token_data["client_id"] = client_id + # Only include client_secret if present (public clients don't have secrets) + if client_secret: + token_data["client_secret"] = client_secret # Add PKCE code_verifier if present (RFC 7636) if code_verifier: @@ -1442,7 +1457,7 @@ async def _exchange_code_for_tokens( # Exchange code for token with retries for attempt in range(self.max_retries): try: - response = await self._post_token_request(token_url, token_data, ca_certificate=ca_certificate, client_cert=client_cert, client_key=client_key) + response = await self._post_token_request(token_url, token_data, ca_certificate=ca_certificate, client_cert=client_cert, client_key=client_key, headers=auth_header) response.raise_for_status() token_response = self._parse_token_response(response) @@ -1502,16 +1517,28 @@ async def refresh_token( if not client_id: raise OAuthError("No client_id configured for OAuth provider") + # Determine token endpoint authentication method (RFC 6749 Section 2.3) + token_auth_method = credentials.get("token_endpoint_auth_method", "client_secret_post") + use_basic_auth = token_auth_method == "client_secret_basic" and client_secret + + # Build HTTP Basic Auth header if required by the provider + auth_header = None + if use_basic_auth: + basic_credentials = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() + auth_header = {"Authorization": f"Basic {basic_credentials}"} + # Prepare token refresh request token_data = { "grant_type": "refresh_token", "refresh_token": refresh_token, - "client_id": client_id, } - # Add client_secret if available (some providers require it) - if client_secret: - token_data["client_secret"] = client_secret + # Include client credentials in POST body only when not using Basic auth + if not use_basic_auth: + token_data["client_id"] = client_id + # Add client_secret if available (some providers require it) + if client_secret: + token_data["client_secret"] = client_secret # Add resource parameter for JWT access token (RFC 8707) # Must be included in refresh requests to maintain JWT token type @@ -1531,7 +1558,7 @@ async def refresh_token( # Attempt token refresh with retries for attempt in range(self.max_retries): try: - response = await self._post_token_request(token_url, token_data, ca_certificate=ca_certificate, client_cert=client_cert, client_key=client_key) + response = await self._post_token_request(token_url, token_data, ca_certificate=ca_certificate, client_cert=client_cert, client_key=client_key, headers=auth_header) if response.status_code == 200: token_response = self._parse_token_response(response) diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html index 08fdc66641..8f9bb0f10c 100644 --- a/mcpgateway/templates/admin.html +++ b/mcpgateway/templates/admin.html @@ -5956,6 +5956,25 @@
+ How client credentials are sent to the token endpoint (RFC 6749 Section 2.3) +
++ How client credentials are sent to the token endpoint (RFC 6749 Section 2.3) +
+