Coverage for mcpgateway / routers / oauth_router.py: 100%
319 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 00:56 +0100
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 00:56 +0100
1# -*- coding: utf-8 -*-
2"""Location: ./mcpgateway/routers/oauth_router.py
3Copyright 2025
4SPDX-License-Identifier: Apache-2.0
5Authors: Mihai Criveti
7OAuth Router for ContextForge.
9This module handles OAuth 2.0 Authorization Code flow endpoints including:
10- Initiating OAuth flows
11- Handling OAuth callbacks
12- Token management
13"""
15# Standard
16from html import escape
17import logging
18from typing import Annotated, Any, Dict
19from urllib.parse import urlparse, urlunparse
21# Third-Party
22from fastapi import APIRouter, Depends, HTTPException, Query, Request
23from fastapi.responses import HTMLResponse, RedirectResponse
24from sqlalchemy import select
25from sqlalchemy.orm import Session
27# First-Party
28from mcpgateway.auth import normalize_token_teams
29from mcpgateway.common.validators import SecurityValidator
30from mcpgateway.config import settings
31from mcpgateway.db import Gateway, get_db
32from mcpgateway.middleware.rbac import get_current_user_with_permissions, require_permission
33from mcpgateway.middleware.token_scoping import token_scoping_middleware
34from mcpgateway.schemas import EmailUserResponse
35from mcpgateway.services.dcr_service import DcrError, DcrService
36from mcpgateway.services.encryption_service import protect_oauth_config_for_storage
37from mcpgateway.services.oauth_manager import OAuthError, OAuthManager
38from mcpgateway.services.token_storage_service import TokenStorageService
39from mcpgateway.utils.log_sanitizer import sanitize_for_log
41logger = logging.getLogger(__name__)
44def _normalize_resource_url(url: str | None, *, preserve_query: bool = False) -> str | None:
45 """Normalize URL for use as RFC 8707 resource parameter.
47 Per RFC 8707 Section 2:
48 - resource MUST be an absolute URI (scheme required; supports both URLs and URNs)
49 - resource MUST NOT include a fragment component
50 - resource SHOULD NOT include a query component (but allowed when necessary)
52 Args:
53 url: The resource URL to normalize
54 preserve_query: If True, preserve query component (for explicitly configured resources).
55 If False, strip query (for auto-derived resources per RFC 8707 SHOULD NOT).
57 Returns:
58 Normalized URL suitable for RFC 8707 resource parameter, or None if invalid
59 """
60 if not url:
61 return None
62 parsed = urlparse(url)
63 # RFC 8707: resource MUST be an absolute URI (requires scheme)
64 # Support both hierarchical URIs (https://...) and URNs (urn:example:app)
65 if not parsed.scheme:
66 logger.warning(f"Invalid resource URL (must be absolute URI with scheme): {url}")
67 return None
68 # Remove fragment (MUST NOT per RFC 8707)
69 # Query: strip for auto-derived (SHOULD NOT), preserve for explicit config (allowed when necessary)
70 query = parsed.query if preserve_query else ""
71 normalized = urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, query, ""))
72 return normalized
75oauth_router = APIRouter(prefix="/oauth", tags=["oauth"])
78def _require_admin_user(current_user: EmailUserResponse) -> None:
79 """Require admin context for DCR management endpoints.
81 Args:
82 current_user: Authenticated user context from RBAC dependency.
84 Raises:
85 HTTPException: If requester is not an admin user.
86 """
87 is_admin = current_user.is_admin if hasattr(current_user, "is_admin") else current_user.get("is_admin", False)
88 if not is_admin:
89 raise HTTPException(status_code=403, detail="Admin permissions required")
92def _resolve_token_teams_for_scope_check(request: Request, current_user: EmailUserResponse) -> list[str] | None:
93 """Resolve token teams for scoped ownership checks using normalized token semantics.
95 Args:
96 request: Incoming request with token scoping state.
97 current_user: Authenticated user context.
99 Returns:
100 ``None`` for unrestricted admin scope, or a normalized team list for scoped access.
101 """
102 is_admin = False
103 if hasattr(current_user, "is_admin"):
104 is_admin = bool(getattr(current_user, "is_admin", False))
105 elif isinstance(current_user, dict):
106 is_admin = bool(current_user.get("is_admin", False) or current_user.get("user", {}).get("is_admin", False))
108 _not_set = object()
109 token_teams = getattr(request.state, "token_teams", _not_set)
110 if token_teams is _not_set or not (token_teams is None or isinstance(token_teams, list)):
111 cached = getattr(request.state, "_jwt_verified_payload", None)
112 if cached and isinstance(cached, tuple) and len(cached) == 2:
113 _, payload = cached
114 if payload:
115 token_teams = normalize_token_teams(payload)
116 is_admin = bool(payload.get("is_admin", False) or payload.get("user", {}).get("is_admin", False))
117 # Fail closed when request.state contains an unexpected token_teams value.
118 if token_teams is not _not_set and not (token_teams is None or isinstance(token_teams, list)):
119 token_teams = _not_set
121 if token_teams is _not_set:
122 token_teams = None if is_admin else []
124 # Empty-team scoped tokens are public-only and must never receive admin bypass.
125 if isinstance(token_teams, list) and len(token_teams) == 0:
126 is_admin = False
128 if is_admin and token_teams is None:
129 return None
130 return token_teams
133def _extract_user_email(current_user: EmailUserResponse | dict) -> str | None:
134 """Extract requester email from typed or dict user contexts.
136 Args:
137 current_user: Authenticated user context.
139 Returns:
140 Lowercased email when available, otherwise ``None``.
141 """
142 if hasattr(current_user, "email"):
143 email = getattr(current_user, "email", None)
144 if isinstance(email, str) and email.strip():
145 return email.strip().lower()
146 if isinstance(current_user, dict):
147 email = current_user.get("email") or current_user.get("user", {}).get("email")
148 if isinstance(email, str) and email.strip():
149 return email.strip().lower()
150 return None
153def _extract_is_admin(current_user: EmailUserResponse | dict) -> bool:
154 """Extract admin flag from typed or dict user contexts.
156 Args:
157 current_user: Authenticated user context.
159 Returns:
160 ``True`` when the user context indicates admin privileges.
161 """
162 if hasattr(current_user, "is_admin"):
163 return bool(getattr(current_user, "is_admin", False))
164 if isinstance(current_user, dict):
165 return bool(current_user.get("is_admin", False) or current_user.get("user", {}).get("is_admin", False))
166 return False
169async def _enforce_gateway_access(
170 gateway_id: str,
171 gateway: Gateway,
172 current_user: EmailUserResponse,
173 db: Session,
174 request: Request | None = None,
175) -> None:
176 """Enforce gateway visibility and ownership checks for OAuth endpoints.
178 Args:
179 gateway_id: Gateway identifier used for scoped ownership checks.
180 gateway: Gateway record being accessed.
181 current_user: Authenticated requester context.
182 db: Active database session.
183 request: Optional request carrying token-scoping context.
185 Raises:
186 HTTPException: If authentication is missing or access is not permitted.
187 """
188 requester_email = _extract_user_email(current_user)
189 if not requester_email:
190 raise HTTPException(status_code=401, detail="User authentication required")
192 requester_is_admin = _extract_is_admin(current_user)
194 if request is not None:
195 token_teams = _resolve_token_teams_for_scope_check(request, current_user)
196 if token_teams is None:
197 if requester_is_admin:
198 return
199 token_teams = []
201 if not token_scoping_middleware._check_resource_team_ownership(
202 f"/gateways/{gateway_id}",
203 token_teams,
204 db=db,
205 _user_email=requester_email,
206 ):
207 raise HTTPException(status_code=403, detail="You don't have access to this gateway")
209 if requester_is_admin:
210 return
212 visibility = str(getattr(gateway, "visibility", "team") or "team").lower()
213 gateway_owner = getattr(gateway, "owner_email", None)
214 gateway_team_id = getattr(gateway, "team_id", None)
216 if visibility == "public":
217 return
219 if visibility == "team":
220 if not gateway_team_id:
221 raise HTTPException(status_code=403, detail="You don't have access to this gateway")
222 # First-Party
223 from mcpgateway.services.email_auth_service import EmailAuthService
225 auth_service = EmailAuthService(db)
226 user = await auth_service.get_user_by_email(requester_email)
227 if not user or not user.is_team_member(gateway_team_id):
228 raise HTTPException(status_code=403, detail="You don't have access to this gateway")
229 return
231 if visibility in {"private", "user"}:
232 if gateway_owner and gateway_owner.strip().lower() == requester_email:
233 return
234 raise HTTPException(status_code=403, detail="You don't have access to this gateway")
236 if gateway_owner and gateway_owner.strip().lower() == requester_email:
237 return
238 if gateway_team_id:
239 # First-Party
240 from mcpgateway.services.email_auth_service import EmailAuthService
242 auth_service = EmailAuthService(db)
243 user = await auth_service.get_user_by_email(requester_email)
244 if user and user.is_team_member(gateway_team_id):
245 return
247 raise HTTPException(status_code=403, detail="You don't have access to this gateway")
250@oauth_router.get("/authorize/{gateway_id}")
251async def initiate_oauth_flow(
252 gateway_id: str, request: Request, current_user: EmailUserResponse = Depends(get_current_user_with_permissions), db: Session = Depends(get_db)
253) -> RedirectResponse: # noqa: ARG001
254 """Initiates the OAuth 2.0 Authorization Code flow for a specified gateway.
256 This endpoint retrieves the OAuth configuration for the given gateway, validates that
257 the gateway supports the Authorization Code flow, and redirects the user to the OAuth
258 provider's authorization URL to begin the OAuth process.
260 **Phase 1.4: DCR Integration**
261 If the gateway has an issuer but no client_id, and DCR is enabled, this endpoint will
262 automatically register the gateway as an OAuth client with the Authorization Server
263 using Dynamic Client Registration (RFC 7591).
265 Args:
266 gateway_id: The unique identifier of the gateway to authorize.
267 request: The FastAPI request object.
268 current_user: The authenticated user initiating the OAuth flow.
269 db: The database session dependency.
271 Returns:
272 A redirect response to the OAuth provider's authorization URL.
274 Raises:
275 HTTPException: If the gateway is not found, not configured for OAuth, or not using
276 the Authorization Code flow. If an unexpected error occurs during the initiation process.
278 Examples:
279 >>> import asyncio
280 >>> asyncio.iscoroutinefunction(initiate_oauth_flow)
281 True
282 """
283 try:
284 # Get gateway configuration
285 gateway = db.execute(select(Gateway).where(Gateway.id == gateway_id)).scalar_one_or_none()
287 if not gateway:
288 raise HTTPException(status_code=404, detail="Gateway not found")
290 await _enforce_gateway_access(gateway_id, gateway, current_user, db, request=request)
292 if not gateway.oauth_config:
293 raise HTTPException(status_code=400, detail="Gateway is not configured for OAuth")
295 if gateway.oauth_config.get("grant_type") != "authorization_code":
296 raise HTTPException(status_code=400, detail="Gateway is not configured for Authorization Code flow")
298 oauth_config = gateway.oauth_config.copy() # Work with a copy to avoid mutating the original
300 # RFC 8707: Set resource parameter for JWT access tokens
301 # Respect pre-configured resource (e.g., for providers requiring pre-registered resources)
302 # Only derive from gateway.url if not explicitly configured
303 if oauth_config.get("resource"):
304 # Normalize existing resource - preserve query for explicit config (RFC 8707 allows when necessary)
305 existing = oauth_config["resource"]
306 if isinstance(existing, list):
307 original_count = len(existing)
308 normalized = [_normalize_resource_url(r, preserve_query=True) for r in existing]
309 oauth_config["resource"] = [r for r in normalized if r]
310 if not oauth_config["resource"] and original_count > 0:
311 logger.warning(f"All {original_count} configured resource values were invalid and removed")
312 else:
313 oauth_config["resource"] = _normalize_resource_url(existing, preserve_query=True)
314 else:
315 # Default to gateway.url as the resource (strip query per RFC 8707 SHOULD NOT)
316 oauth_config["resource"] = _normalize_resource_url(gateway.url)
318 # Phase 1.4: Auto-trigger DCR if credentials are missing
319 # Check if gateway has issuer but no client_id (DCR scenario)
320 issuer = oauth_config.get("issuer")
321 client_id = oauth_config.get("client_id")
323 if issuer and not client_id:
324 if settings.dcr_enabled and settings.dcr_auto_register_on_missing_credentials:
325 logger.info(f"Gateway {SecurityValidator.sanitize_log_message(gateway_id)} has issuer but no client_id. Attempting DCR...")
327 try:
328 # Initialize DCR service
329 dcr_service = DcrService()
331 # Check if client is already registered in database
332 registered_client = await dcr_service.get_or_register_client(
333 gateway_id=gateway_id,
334 gateway_name=gateway.name,
335 issuer=issuer,
336 redirect_uri=oauth_config.get("redirect_uri"),
337 scopes=oauth_config.get("scopes", settings.dcr_default_scopes),
338 db=db,
339 )
341 logger.info(f"✅ DCR successful for gateway {SecurityValidator.sanitize_log_message(gateway_id)}: client_id={SecurityValidator.sanitize_log_message(registered_client.client_id)}")
343 # Decrypt the client secret for use in OAuth flow (if present - public clients may not have secrets)
344 decrypted_secret = None
345 if registered_client.client_secret_encrypted:
346 # First-Party
347 from mcpgateway.services.encryption_service import get_encryption_service
349 encryption = get_encryption_service(settings.auth_encryption_secret)
350 decrypted_secret = await encryption.decrypt_secret_async(registered_client.client_secret_encrypted)
352 # Update oauth_config with registered credentials
353 oauth_config["client_id"] = registered_client.client_id
354 if decrypted_secret:
355 oauth_config["client_secret"] = decrypted_secret
357 # Discover AS metadata to get authorization/token endpoints if not already set
358 # Note: OAuthManager expects 'authorization_url' and 'token_url', not 'authorization_endpoint'/'token_endpoint'
359 if not oauth_config.get("authorization_url") or not oauth_config.get("token_url"):
360 metadata = await dcr_service.discover_as_metadata(issuer)
361 oauth_config["authorization_url"] = metadata.get("authorization_endpoint")
362 oauth_config["token_url"] = metadata.get("token_endpoint")
363 logger.info(f"Discovered OAuth endpoints for {issuer}")
365 # Update gateway's oauth_config and auth_type in database for future use.
366 # Protect sensitive fields before persistence to keep service-layer behavior consistent.
367 gateway.oauth_config = await protect_oauth_config_for_storage(oauth_config, existing_oauth_config=gateway.oauth_config)
368 gateway.auth_type = "oauth" # Ensure auth_type is set for OAuth-protected servers
369 db.commit()
371 logger.info(f"Updated gateway {SecurityValidator.sanitize_log_message(gateway_id)} with DCR credentials and auth_type=oauth")
373 except DcrError as dcr_err:
374 logger.error(f"DCR failed for gateway {SecurityValidator.sanitize_log_message(gateway_id)}: {dcr_err}")
375 raise HTTPException(
376 status_code=500,
377 detail=f"Dynamic Client Registration failed: {str(dcr_err)}. Please configure client_id and client_secret manually or check your OAuth server supports RFC 7591.",
378 )
379 except Exception as dcr_ex:
380 logger.error(f"Unexpected error during DCR for gateway {SecurityValidator.sanitize_log_message(gateway_id)}: {dcr_ex}")
381 raise HTTPException(status_code=500, detail=f"Failed to register OAuth client: {str(dcr_ex)}")
382 else:
383 # DCR is disabled or auto-register is off
384 logger.warning(f"Gateway {SecurityValidator.sanitize_log_message(gateway_id)} has issuer but no client_id, and DCR auto-registration is disabled")
385 raise HTTPException(
386 status_code=400,
387 detail="Gateway OAuth configuration is incomplete. Please provide client_id and client_secret, or enable DCR (Dynamic Client Registration) by setting MCPGATEWAY_DCR_ENABLED=true and MCPGATEWAY_DCR_AUTO_REGISTER_ON_MISSING_CREDENTIALS=true",
388 )
390 # Validate required fields for OAuth flow
391 if not oauth_config.get("client_id"):
392 raise HTTPException(status_code=400, detail="OAuth configuration missing client_id")
394 # Initiate OAuth flow with user context (now includes PKCE from existing implementation)
395 requester_email = _extract_user_email(current_user)
396 oauth_manager = OAuthManager(token_storage=TokenStorageService(db))
397 auth_data = await oauth_manager.initiate_authorization_code_flow(gateway_id, oauth_config, app_user_email=requester_email)
399 logger.info(f"Initiated OAuth flow for gateway {SecurityValidator.sanitize_log_message(gateway_id)} by user {SecurityValidator.sanitize_log_message(requester_email)}")
401 # Redirect user to OAuth provider
402 return RedirectResponse(url=auth_data["authorization_url"])
404 except HTTPException:
405 raise
406 except Exception as e:
407 logger.error(f"Failed to initiate OAuth flow: {str(e)}")
408 raise HTTPException(status_code=500, detail=f"Failed to initiate OAuth flow: {str(e)}")
411@oauth_router.get("/callback")
412async def oauth_callback(
413 code: Annotated[str | None, Query(description="Authorization code from OAuth provider")] = None,
414 state: Annotated[str, Query(description="State parameter for CSRF protection")] = ...,
415 error: Annotated[str | None, Query(description="OAuth provider error code")] = None,
416 error_description: Annotated[str | None, Query(description="OAuth provider error description")] = None,
417 # Remove the gateway_id parameter requirement
418 request: Request = None,
419 db: Session = Depends(get_db),
420) -> HTMLResponse:
421 """Handle the OAuth callback and complete the authorization process.
423 This endpoint is called by the OAuth provider after the user authorizes access.
424 It receives the authorization code and state parameters, verifies the state,
425 retrieves the corresponding gateway configuration, and exchanges the code for an access token.
427 Args:
428 code (str): The authorization code returned by the OAuth provider.
429 state (str): The state parameter for CSRF protection, which encodes the gateway ID.
430 error (str): OAuth provider error code from error callback (RFC 6749 Section 4.1.2.1).
431 error_description (str): OAuth provider error description.
432 request (Request): The incoming HTTP request object.
433 db (Session): The database session dependency.
435 Returns:
436 HTMLResponse: An HTML response indicating the result of the OAuth authorization process.
438 Raises:
439 ValueError: Raised internally when state parameter is missing gateway_id (caught and handled).
441 Examples:
442 >>> import asyncio
443 >>> asyncio.iscoroutinefunction(oauth_callback)
444 True
445 """
447 try:
448 # Get root path for URL construction
449 root_path = request.scope.get("root_path", "") if request else ""
450 safe_root_path = escape(str(root_path), quote=True)
452 # RFC 6749 Section 4.1.2.1: provider may return error instead of code
453 if error:
454 error_text = escape(error)
455 description_text = escape(error_description or "OAuth provider returned an authorization error.")
456 # Sanitize untrusted query parameters before logging to prevent log injection
457 logger.warning(f"OAuth provider returned error callback: error={sanitize_for_log(error)}, description={sanitize_for_log(error_description)}")
458 return HTMLResponse(
459 content=f"""
460 <!DOCTYPE html>
461 <html>
462 <head><title>OAuth Authorization Failed</title></head>
463 <body>
464 <h1>❌ OAuth Authorization Failed</h1>
465 <p><strong>Error:</strong> {error_text}</p>
466 <p><strong>Description:</strong> {description_text}</p>
467 <a href="{safe_root_path}/admin#gateways">Return to Admin Panel</a>
468 </body>
469 </html>
470 """,
471 status_code=400,
472 )
474 if not code:
475 logger.warning("OAuth callback missing authorization code")
476 return HTMLResponse(
477 content=f"""
478 <!DOCTYPE html>
479 <html>
480 <head><title>OAuth Authorization Failed</title></head>
481 <body>
482 <h1>❌ OAuth Authorization Failed</h1>
483 <p>Error: Missing authorization code in callback response.</p>
484 <a href="{safe_root_path}/admin#gateways">Return to Admin Panel</a>
485 </body>
486 </html>
487 """,
488 status_code=400,
489 )
491 def _invalid_state_response() -> HTMLResponse:
492 """Return an HTML error page for invalid or missing OAuth state.
494 Returns:
495 HTMLResponse: A 400 error page describing the invalid state.
496 """
497 return HTMLResponse(
498 content=f"""
499 <!DOCTYPE html>
500 <html>
501 <head><title>OAuth Authorization Failed</title></head>
502 <body>
503 <h1>❌ OAuth Authorization Failed</h1>
504 <p>Error: Invalid OAuth state parameter.</p>
505 <a href="{safe_root_path}/admin#gateways">Return to Admin Panel</a>
506 </body>
507 </html>
508 """,
509 status_code=400,
510 )
512 oauth_manager = OAuthManager(token_storage=TokenStorageService(db))
513 gateway_id = await oauth_manager.resolve_gateway_id_from_state(state, allow_legacy_fallback=False)
514 if not gateway_id:
515 logger.warning("OAuth callback received invalid or unknown state token")
516 return _invalid_state_response()
518 # Get gateway configuration
519 gateway = db.execute(select(Gateway).where(Gateway.id == gateway_id)).scalar_one_or_none()
521 if not gateway:
522 logger.warning("OAuth callback state resolved to unknown gateway id")
523 return _invalid_state_response()
525 if not gateway.oauth_config:
526 logger.warning("OAuth callback state resolved to gateway without OAuth configuration")
527 return _invalid_state_response()
529 # Complete OAuth flow
531 # RFC 8707: Add resource parameter for JWT access tokens
532 # Must be set here in callback, not just in /authorize, because complete_authorization_code_flow
533 # needs it for the token exchange request
534 # Respect pre-configured resource; only derive from gateway.url if not explicitly configured
535 oauth_config_with_resource = gateway.oauth_config.copy()
536 if oauth_config_with_resource.get("resource"):
537 # Preserve query for explicit config (RFC 8707 allows when necessary)
538 existing = oauth_config_with_resource["resource"]
539 if isinstance(existing, list):
540 original_count = len(existing)
541 normalized = [_normalize_resource_url(r, preserve_query=True) for r in existing]
542 oauth_config_with_resource["resource"] = [r for r in normalized if r]
543 if not oauth_config_with_resource["resource"] and original_count > 0:
544 logger.warning(f"All {original_count} configured resource values were invalid and removed")
545 else:
546 oauth_config_with_resource["resource"] = _normalize_resource_url(existing, preserve_query=True)
547 else:
548 # Strip query for auto-derived (RFC 8707 SHOULD NOT)
549 oauth_config_with_resource["resource"] = _normalize_resource_url(gateway.url)
551 result = await oauth_manager.complete_authorization_code_flow(gateway_id, code, state, oauth_config_with_resource)
553 logger.info(f"Completed OAuth flow for gateway {SecurityValidator.sanitize_log_message(gateway_id)}, user {SecurityValidator.sanitize_log_message(str(result.get('user_id')))}")
555 # Return success page with option to return to admin
556 return HTMLResponse(content=f"""
557 <!DOCTYPE html>
558 <html>
559 <head>
560 <title>OAuth Authorization Successful</title>
561 <style>
562 body {{ font-family: Arial, sans-serif; margin: 40px; }}
563 .success {{ color: #059669; }}
564 .error {{ color: #dc2626; }}
565 .info {{ color: #2563eb; }}
566 .button {{
567 display: inline-block;
568 padding: 10px 20px;
569 background-color: #3b82f6;
570 color: white;
571 text-decoration: none;
572 border-radius: 5px;
573 margin-top: 20px;
574 }}
575 .button:hover {{ background-color: #2563eb; }}
576 </style>
577 </head>
578 <body>
579 <h1 class="success">✅ OAuth Authorization Successful</h1>
580 <div class="info">
581 <p><strong>Gateway:</strong> {escape(str(gateway.name))}</p>
582 <p><strong>User ID:</strong> {escape(str(result.get("user_id", "Unknown")))}</p>
583 <p><strong>Expires:</strong> {escape(str(result.get("expires_at", "Unknown")))}</p>
584 <p><strong>Status:</strong> Authorization completed successfully</p>
585 </div>
587 <div style="margin: 30px 0;">
588 <h3>Next Steps:</h3>
589 <p>Now that OAuth authorization is complete, you can fetch tools from the MCP server:</p>
590 <button onclick="fetchTools()" class="button" style="background-color: #059669;">
591 🔧 Fetch Tools from MCP Server
592 </button>
593 <div id="fetch-status" style="margin-top: 15px;"></div>
594 </div>
596 <a href="{safe_root_path}/admin#gateways" class="button">Return to Admin Panel</a>
598 <script>
599 async function fetchTools() {{
600 const button = event.target;
601 const statusDiv = document.getElementById('fetch-status');
603 button.disabled = true;
604 button.textContent = '⏳ Fetching Tools...';
605 statusDiv.innerHTML = '<p style="color: #2563eb;">Fetching tools from MCP server...</p>';
607 try {{
608 const response = await fetch('{safe_root_path}/oauth/fetch-tools/{escape(str(gateway_id), quote=True)}', {{
609 method: 'POST',
610 credentials: 'include',
611 headers: {{ 'Accept': 'text/html' }}
612 }});
614 const result = await response.json();
616 if (response.ok) {{
617 statusDiv.innerHTML = `
618 <div style="color: #059669; padding: 15px; background-color: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 5px;">
619 <h4>✅ Tools Fetched Successfully!</h4>
620 <p>${{result.message}}</p>
621 </div>
622 `;
623 button.textContent = '✅ Tools Fetched';
624 button.style.backgroundColor = '#059669';
625 }} else {{
626 throw new Error(result.detail || 'Failed to fetch tools');
627 }}
628 }} catch (error) {{
629 statusDiv.innerHTML = `
630 <div style="color: #dc2626; padding: 15px; background-color: #fef2f2; border: 1px solid #fecaca; border-radius: 5px;">
631 <h4>❌ Failed to Fetch Tools</h4>
632 <p><strong>Error:</strong> ${{error.message}}</p>
633 <p>You can still return to the admin panel and try again later.</p>
634 </div>
635 `;
636 button.textContent = '❌ Retry Fetch Tools';
637 button.style.backgroundColor = '#dc2626';
638 button.disabled = false;
639 }}
640 }}
641 </script>
642 </body>
643 </html>
644 """)
646 except OAuthError as e:
647 logger.error(f"OAuth callback failed: {str(e)}")
648 return HTMLResponse(
649 content=f"""
650 <!DOCTYPE html>
651 <html>
652 <head>
653 <title>OAuth Authorization Failed</title>
654 <style>
655 body {{ font-family: Arial, sans-serif; margin: 40px; }}
656 .error {{ color: #dc2626; }}
657 .button {{
658 display: inline-block;
659 padding: 10px 20px;
660 background-color: #3b82f6;
661 color: white;
662 text-decoration: none;
663 border-radius: 5px;
664 margin-top: 20px;
665 }}
666 .button:hover {{ background-color: #2563eb; }}
667 </style>
668 </head>
669 <body>
670 <h1 class="error">❌ OAuth Authorization Failed</h1>
671 <p><strong>Error:</strong> {escape(str(e))}</p>
672 <p>Please check your OAuth configuration and try again.</p>
673 <a href="{safe_root_path}/admin#gateways" class="button">Return to Admin Panel</a>
674 </body>
675 </html>
676 """,
677 status_code=400,
678 )
680 except Exception as e:
681 logger.error(f"Unexpected error in OAuth callback: {str(e)}")
682 return HTMLResponse(
683 content=f"""
684 <!DOCTYPE html>
685 <html>
686 <head>
687 <title>OAuth Authorization Failed</title>
688 <style>
689 body {{ font-family: Arial, sans-serif; margin: 40px; }}
690 .error {{ color: #dc2626; }}
691 .button {{
692 display: inline-block;
693 padding: 10px 20px;
694 background-color: #3b82f6;
695 color: white;
696 text-decoration: none;
697 border-radius: 5px;
698 margin-top: 20px;
699 }}
700 .button:hover {{ background-color: #2563eb; }}
701 </style>
702 </head>
703 <body>
704 <h1 class="error">❌ OAuth Authorization Failed</h1>
705 <p><strong>Unexpected Error:</strong> {escape(str(e))}</p>
706 <p>Please contact your administrator for assistance.</p>
707 <a href="{safe_root_path}/admin#gateways" class="button">Return to Admin Panel</a>
708 </body>
709 </html>
710 """,
711 status_code=500,
712 )
715@oauth_router.get("/status/{gateway_id}")
716async def get_oauth_status(
717 gateway_id: str,
718 request: Request,
719 current_user: dict = Depends(get_current_user_with_permissions),
720 db: Session = Depends(get_db),
721) -> dict:
722 """Get OAuth status for a gateway.
724 Requires authentication and authorization to prevent information disclosure
725 about gateway OAuth configuration (client IDs, scopes, etc.).
727 Args:
728 gateway_id: ID of the gateway
729 current_user: Authenticated user (enforces authentication)
730 db: Database session
731 request: Request with token-scoping context.
733 Returns:
734 OAuth status information
736 Raises:
737 HTTPException: If not authenticated, not authorized, gateway not found, or error
738 """
739 try:
740 # Get gateway configuration
741 gateway = db.execute(select(Gateway).where(Gateway.id == gateway_id)).scalar_one_or_none()
743 if not gateway:
744 raise HTTPException(status_code=404, detail="Gateway not found")
746 await _enforce_gateway_access(gateway_id, gateway, current_user, db, request=request)
748 if not gateway.oauth_config:
749 return {"oauth_enabled": False, "message": "Gateway is not configured for OAuth"}
751 # Get OAuth configuration info
752 oauth_config = gateway.oauth_config
753 grant_type = oauth_config.get("grant_type")
755 if grant_type == "authorization_code":
756 # For now, return basic info - in a real implementation you might want to
757 # show authorized users, token status, etc.
758 return {
759 "oauth_enabled": True,
760 "grant_type": grant_type,
761 "client_id": oauth_config.get("client_id"),
762 "scopes": oauth_config.get("scopes", []),
763 "authorization_url": oauth_config.get("authorization_url"),
764 "redirect_uri": oauth_config.get("redirect_uri"),
765 "message": "Gateway configured for Authorization Code flow",
766 }
767 else:
768 return {
769 "oauth_enabled": True,
770 "grant_type": grant_type,
771 "client_id": oauth_config.get("client_id"),
772 "scopes": oauth_config.get("scopes", []),
773 "message": f"Gateway configured for {grant_type} flow",
774 }
776 except HTTPException:
777 raise
778 except Exception as e:
779 logger.error(f"Failed to get OAuth status: {str(e)}")
780 raise HTTPException(status_code=500, detail=f"Failed to get OAuth status: {str(e)}")
783@oauth_router.post("/fetch-tools/{gateway_id}")
784@require_permission("gateways.update")
785async def fetch_tools_after_oauth(
786 gateway_id: str,
787 request: Request,
788 current_user: EmailUserResponse = Depends(get_current_user_with_permissions),
789 db: Session = Depends(get_db),
790) -> Dict[str, Any]:
791 """Fetch tools from MCP server after OAuth completion for Authorization Code flow.
793 Args:
794 gateway_id: ID of the gateway to fetch tools for
795 request: Incoming request used for token scope context
796 current_user: The authenticated user fetching tools
797 db: Database session
799 Returns:
800 Dict containing success status and message with number of tools fetched
802 Raises:
803 HTTPException: If fetching tools fails
804 """
805 try:
806 gateway = db.execute(select(Gateway).where(Gateway.id == gateway_id)).scalar_one_or_none()
807 if not gateway:
808 raise HTTPException(status_code=404, detail=f"Gateway not found: {gateway_id}")
810 requester_email = current_user.get("email") if isinstance(current_user, dict) else getattr(current_user, "email", None)
811 await _enforce_gateway_access(gateway_id, gateway, current_user, db, request=request)
813 # First-Party
814 from mcpgateway.services.gateway_service import GatewayService
816 gateway_service = GatewayService()
817 result = await gateway_service.fetch_tools_after_oauth(db, gateway_id, requester_email)
818 tools_count = len(result.get("tools", []))
820 return {"success": True, "message": f"Successfully fetched and created {tools_count} tools"}
822 except HTTPException:
823 raise
824 except Exception as e:
825 logger.error(f"Failed to fetch tools after OAuth for gateway {SecurityValidator.sanitize_log_message(gateway_id)}: {e}")
826 raise HTTPException(status_code=500, detail=f"Failed to fetch tools: {str(e)}")
829# ============================================================================
830# Admin Endpoints for DCR Management
831# ============================================================================
834@oauth_router.get("/registered-clients")
835async def list_registered_oauth_clients(current_user: EmailUserResponse = Depends(get_current_user_with_permissions), db: Session = Depends(get_db)) -> Dict[str, Any]: # noqa: ARG001
836 """List all registered OAuth clients (created via DCR).
838 This endpoint shows OAuth clients that were dynamically registered with external
839 Authorization Servers using RFC 7591 Dynamic Client Registration.
841 Args:
842 current_user: The authenticated user (admin access required)
843 db: Database session
845 Returns:
846 Dict containing list of registered OAuth clients with metadata
848 Raises:
849 HTTPException: If user lacks permissions or database error occurs
850 """
851 _require_admin_user(current_user)
853 try:
854 # First-Party
855 from mcpgateway.db import RegisteredOAuthClient
857 # Query all registered clients
858 clients = db.execute(select(RegisteredOAuthClient)).scalars().all()
860 # Build response
861 clients_data = []
862 for client in clients:
863 clients_data.append(
864 {
865 "id": client.id,
866 "gateway_id": client.gateway_id,
867 "issuer": client.issuer,
868 "client_id": client.client_id,
869 "redirect_uris": client.redirect_uris.split(",") if isinstance(client.redirect_uris, str) else client.redirect_uris,
870 "grant_types": client.grant_types.split(",") if isinstance(client.grant_types, str) else client.grant_types,
871 "scope": client.scope,
872 "token_endpoint_auth_method": client.token_endpoint_auth_method,
873 "created_at": client.created_at.isoformat() if client.created_at else None,
874 "expires_at": client.expires_at.isoformat() if client.expires_at else None,
875 "is_active": client.is_active,
876 }
877 )
879 return {"total": len(clients_data), "clients": clients_data}
881 except Exception as e:
882 logger.error(f"Failed to list registered OAuth clients: {e}")
883 raise HTTPException(status_code=500, detail=f"Failed to list registered clients: {str(e)}")
886@oauth_router.get("/registered-clients/{gateway_id}")
887async def get_registered_client_for_gateway(
888 gateway_id: str,
889 current_user: EmailUserResponse = Depends(get_current_user_with_permissions),
890 db: Session = Depends(get_db), # noqa: ARG001
891) -> Dict[str, Any]:
892 """Get the registered OAuth client for a specific gateway.
894 Args:
895 gateway_id: The gateway ID to lookup
896 current_user: The authenticated user
897 db: Database session
899 Returns:
900 Dict containing registered client information
902 Raises:
903 HTTPException: If gateway or registered client not found
904 """
905 _require_admin_user(current_user)
907 try:
908 # First-Party
909 from mcpgateway.db import RegisteredOAuthClient
911 # Query registered client for this gateway
912 client = db.execute(select(RegisteredOAuthClient).where(RegisteredOAuthClient.gateway_id == gateway_id)).scalar_one_or_none()
914 if not client:
915 raise HTTPException(status_code=404, detail=f"No registered OAuth client found for gateway {gateway_id}")
917 return {
918 "id": client.id,
919 "gateway_id": client.gateway_id,
920 "issuer": client.issuer,
921 "client_id": client.client_id,
922 "redirect_uris": client.redirect_uris.split(",") if isinstance(client.redirect_uris, str) else client.redirect_uris,
923 "grant_types": client.grant_types.split(",") if isinstance(client.grant_types, str) else client.grant_types,
924 "scope": client.scope,
925 "token_endpoint_auth_method": client.token_endpoint_auth_method,
926 "registration_client_uri": client.registration_client_uri,
927 "created_at": client.created_at.isoformat() if client.created_at else None,
928 "expires_at": client.expires_at.isoformat() if client.expires_at else None,
929 "is_active": client.is_active,
930 }
932 except HTTPException:
933 raise
934 except Exception as e:
935 logger.error(f"Failed to get registered client for gateway {SecurityValidator.sanitize_log_message(gateway_id)}: {e}")
936 raise HTTPException(status_code=500, detail=f"Failed to get registered client: {str(e)}")
939@oauth_router.delete("/registered-clients/{client_id}")
940async def delete_registered_client(client_id: str, current_user: EmailUserResponse = Depends(get_current_user_with_permissions), db: Session = Depends(get_db)) -> Dict[str, Any]: # noqa: ARG001
941 """Delete a registered OAuth client.
943 This will revoke the client registration locally. Note: This does not automatically
944 revoke the client at the Authorization Server. You may need to manually revoke the
945 client using the registration_client_uri if available.
947 Args:
948 client_id: The registered client ID to delete
949 current_user: The authenticated user (admin access required)
950 db: Database session
952 Returns:
953 Dict containing success message
955 Raises:
956 HTTPException: If client not found or deletion fails
957 """
958 _require_admin_user(current_user)
960 try:
961 # First-Party
962 from mcpgateway.db import RegisteredOAuthClient
964 # Find the client
965 client = db.execute(select(RegisteredOAuthClient).where(RegisteredOAuthClient.id == client_id)).scalar_one_or_none()
967 if not client:
968 raise HTTPException(status_code=404, detail=f"Registered client {client_id} not found")
970 issuer = client.issuer
971 gateway_id = client.gateway_id
973 # Delete the client
974 db.delete(client)
975 db.commit()
976 db.close()
978 logger.info(
979 f"Deleted registered OAuth client {SecurityValidator.sanitize_log_message(client_id)} for gateway {SecurityValidator.sanitize_log_message(gateway_id)} (issuer: {SecurityValidator.sanitize_log_message(issuer)})"
980 )
982 return {"success": True, "message": f"Registered OAuth client {client_id} deleted successfully", "gateway_id": gateway_id, "issuer": issuer}
984 except HTTPException:
985 raise
986 except Exception as e:
987 logger.error(f"Failed to delete registered client {client_id}: {e}")
988 db.rollback()
989 raise HTTPException(status_code=500, detail=f"Failed to delete registered client: {str(e)}")