Coverage for mcpgateway / routers / oauth_router.py: 100%
318 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 03:05 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 03:05 +0000
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.config import settings
30from mcpgateway.db import Gateway, get_db
31from mcpgateway.middleware.rbac import get_current_user_with_permissions, require_permission
32from mcpgateway.middleware.token_scoping import token_scoping_middleware
33from mcpgateway.schemas import EmailUserResponse
34from mcpgateway.services.dcr_service import DcrError, DcrService
35from mcpgateway.services.encryption_service import protect_oauth_config_for_storage
36from mcpgateway.services.oauth_manager import OAuthError, OAuthManager
37from mcpgateway.services.token_storage_service import TokenStorageService
38from mcpgateway.utils.log_sanitizer import sanitize_for_log
40logger = logging.getLogger(__name__)
43def _normalize_resource_url(url: str | None, *, preserve_query: bool = False) -> str | None:
44 """Normalize URL for use as RFC 8707 resource parameter.
46 Per RFC 8707 Section 2:
47 - resource MUST be an absolute URI (scheme required; supports both URLs and URNs)
48 - resource MUST NOT include a fragment component
49 - resource SHOULD NOT include a query component (but allowed when necessary)
51 Args:
52 url: The resource URL to normalize
53 preserve_query: If True, preserve query component (for explicitly configured resources).
54 If False, strip query (for auto-derived resources per RFC 8707 SHOULD NOT).
56 Returns:
57 Normalized URL suitable for RFC 8707 resource parameter, or None if invalid
58 """
59 if not url:
60 return None
61 parsed = urlparse(url)
62 # RFC 8707: resource MUST be an absolute URI (requires scheme)
63 # Support both hierarchical URIs (https://...) and URNs (urn:example:app)
64 if not parsed.scheme:
65 logger.warning(f"Invalid resource URL (must be absolute URI with scheme): {url}")
66 return None
67 # Remove fragment (MUST NOT per RFC 8707)
68 # Query: strip for auto-derived (SHOULD NOT), preserve for explicit config (allowed when necessary)
69 query = parsed.query if preserve_query else ""
70 normalized = urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, query, ""))
71 return normalized
74oauth_router = APIRouter(prefix="/oauth", tags=["oauth"])
77def _require_admin_user(current_user: EmailUserResponse) -> None:
78 """Require admin context for DCR management endpoints.
80 Args:
81 current_user: Authenticated user context from RBAC dependency.
83 Raises:
84 HTTPException: If requester is not an admin user.
85 """
86 is_admin = current_user.is_admin if hasattr(current_user, "is_admin") else current_user.get("is_admin", False)
87 if not is_admin:
88 raise HTTPException(status_code=403, detail="Admin permissions required")
91def _resolve_token_teams_for_scope_check(request: Request, current_user: EmailUserResponse) -> list[str] | None:
92 """Resolve token teams for scoped ownership checks using normalized token semantics.
94 Args:
95 request: Incoming request with token scoping state.
96 current_user: Authenticated user context.
98 Returns:
99 ``None`` for unrestricted admin scope, or a normalized team list for scoped access.
100 """
101 is_admin = False
102 if hasattr(current_user, "is_admin"):
103 is_admin = bool(getattr(current_user, "is_admin", False))
104 elif isinstance(current_user, dict):
105 is_admin = bool(current_user.get("is_admin", False) or current_user.get("user", {}).get("is_admin", False))
107 _not_set = object()
108 token_teams = getattr(request.state, "token_teams", _not_set)
109 if token_teams is _not_set or not (token_teams is None or isinstance(token_teams, list)):
110 cached = getattr(request.state, "_jwt_verified_payload", None)
111 if cached and isinstance(cached, tuple) and len(cached) == 2:
112 _, payload = cached
113 if payload:
114 token_teams = normalize_token_teams(payload)
115 is_admin = bool(payload.get("is_admin", False) or payload.get("user", {}).get("is_admin", False))
116 # Fail closed when request.state contains an unexpected token_teams value.
117 if token_teams is not _not_set and not (token_teams is None or isinstance(token_teams, list)):
118 token_teams = _not_set
120 if token_teams is _not_set:
121 token_teams = None if is_admin else []
123 # Empty-team scoped tokens are public-only and must never receive admin bypass.
124 if isinstance(token_teams, list) and len(token_teams) == 0:
125 is_admin = False
127 if is_admin and token_teams is None:
128 return None
129 return token_teams
132def _extract_user_email(current_user: EmailUserResponse | dict) -> str | None:
133 """Extract requester email from typed or dict user contexts.
135 Args:
136 current_user: Authenticated user context.
138 Returns:
139 Lowercased email when available, otherwise ``None``.
140 """
141 if hasattr(current_user, "email"):
142 email = getattr(current_user, "email", None)
143 if isinstance(email, str) and email.strip():
144 return email.strip().lower()
145 if isinstance(current_user, dict):
146 email = current_user.get("email") or current_user.get("user", {}).get("email")
147 if isinstance(email, str) and email.strip():
148 return email.strip().lower()
149 return None
152def _extract_is_admin(current_user: EmailUserResponse | dict) -> bool:
153 """Extract admin flag from typed or dict user contexts.
155 Args:
156 current_user: Authenticated user context.
158 Returns:
159 ``True`` when the user context indicates admin privileges.
160 """
161 if hasattr(current_user, "is_admin"):
162 return bool(getattr(current_user, "is_admin", False))
163 if isinstance(current_user, dict):
164 return bool(current_user.get("is_admin", False) or current_user.get("user", {}).get("is_admin", False))
165 return False
168async def _enforce_gateway_access(
169 gateway_id: str,
170 gateway: Gateway,
171 current_user: EmailUserResponse,
172 db: Session,
173 request: Request | None = None,
174) -> None:
175 """Enforce gateway visibility and ownership checks for OAuth endpoints.
177 Args:
178 gateway_id: Gateway identifier used for scoped ownership checks.
179 gateway: Gateway record being accessed.
180 current_user: Authenticated requester context.
181 db: Active database session.
182 request: Optional request carrying token-scoping context.
184 Raises:
185 HTTPException: If authentication is missing or access is not permitted.
186 """
187 requester_email = _extract_user_email(current_user)
188 if not requester_email:
189 raise HTTPException(status_code=401, detail="User authentication required")
191 requester_is_admin = _extract_is_admin(current_user)
193 if request is not None:
194 token_teams = _resolve_token_teams_for_scope_check(request, current_user)
195 if token_teams is None:
196 if requester_is_admin:
197 return
198 token_teams = []
200 if not token_scoping_middleware._check_resource_team_ownership(
201 f"/gateways/{gateway_id}",
202 token_teams,
203 db=db,
204 _user_email=requester_email,
205 ):
206 raise HTTPException(status_code=403, detail="You don't have access to this gateway")
208 if requester_is_admin:
209 return
211 visibility = str(getattr(gateway, "visibility", "team") or "team").lower()
212 gateway_owner = getattr(gateway, "owner_email", None)
213 gateway_team_id = getattr(gateway, "team_id", None)
215 if visibility == "public":
216 return
218 if visibility == "team":
219 if not gateway_team_id:
220 raise HTTPException(status_code=403, detail="You don't have access to this gateway")
221 # First-Party
222 from mcpgateway.services.email_auth_service import EmailAuthService
224 auth_service = EmailAuthService(db)
225 user = await auth_service.get_user_by_email(requester_email)
226 if not user or not user.is_team_member(gateway_team_id):
227 raise HTTPException(status_code=403, detail="You don't have access to this gateway")
228 return
230 if visibility in {"private", "user"}:
231 if gateway_owner and gateway_owner.strip().lower() == requester_email:
232 return
233 raise HTTPException(status_code=403, detail="You don't have access to this gateway")
235 if gateway_owner and gateway_owner.strip().lower() == requester_email:
236 return
237 if gateway_team_id:
238 # First-Party
239 from mcpgateway.services.email_auth_service import EmailAuthService
241 auth_service = EmailAuthService(db)
242 user = await auth_service.get_user_by_email(requester_email)
243 if user and user.is_team_member(gateway_team_id):
244 return
246 raise HTTPException(status_code=403, detail="You don't have access to this gateway")
249@oauth_router.get("/authorize/{gateway_id}")
250async def initiate_oauth_flow(
251 gateway_id: str, request: Request, current_user: EmailUserResponse = Depends(get_current_user_with_permissions), db: Session = Depends(get_db)
252) -> RedirectResponse: # noqa: ARG001
253 """Initiates the OAuth 2.0 Authorization Code flow for a specified gateway.
255 This endpoint retrieves the OAuth configuration for the given gateway, validates that
256 the gateway supports the Authorization Code flow, and redirects the user to the OAuth
257 provider's authorization URL to begin the OAuth process.
259 **Phase 1.4: DCR Integration**
260 If the gateway has an issuer but no client_id, and DCR is enabled, this endpoint will
261 automatically register the gateway as an OAuth client with the Authorization Server
262 using Dynamic Client Registration (RFC 7591).
264 Args:
265 gateway_id: The unique identifier of the gateway to authorize.
266 request: The FastAPI request object.
267 current_user: The authenticated user initiating the OAuth flow.
268 db: The database session dependency.
270 Returns:
271 A redirect response to the OAuth provider's authorization URL.
273 Raises:
274 HTTPException: If the gateway is not found, not configured for OAuth, or not using
275 the Authorization Code flow. If an unexpected error occurs during the initiation process.
277 Examples:
278 >>> import asyncio
279 >>> asyncio.iscoroutinefunction(initiate_oauth_flow)
280 True
281 """
282 try:
283 # Get gateway configuration
284 gateway = db.execute(select(Gateway).where(Gateway.id == gateway_id)).scalar_one_or_none()
286 if not gateway:
287 raise HTTPException(status_code=404, detail="Gateway not found")
289 await _enforce_gateway_access(gateway_id, gateway, current_user, db, request=request)
291 if not gateway.oauth_config:
292 raise HTTPException(status_code=400, detail="Gateway is not configured for OAuth")
294 if gateway.oauth_config.get("grant_type") != "authorization_code":
295 raise HTTPException(status_code=400, detail="Gateway is not configured for Authorization Code flow")
297 oauth_config = gateway.oauth_config.copy() # Work with a copy to avoid mutating the original
299 # RFC 8707: Set resource parameter for JWT access tokens
300 # Respect pre-configured resource (e.g., for providers requiring pre-registered resources)
301 # Only derive from gateway.url if not explicitly configured
302 if oauth_config.get("resource"):
303 # Normalize existing resource - preserve query for explicit config (RFC 8707 allows when necessary)
304 existing = oauth_config["resource"]
305 if isinstance(existing, list):
306 original_count = len(existing)
307 normalized = [_normalize_resource_url(r, preserve_query=True) for r in existing]
308 oauth_config["resource"] = [r for r in normalized if r]
309 if not oauth_config["resource"] and original_count > 0:
310 logger.warning(f"All {original_count} configured resource values were invalid and removed")
311 else:
312 oauth_config["resource"] = _normalize_resource_url(existing, preserve_query=True)
313 else:
314 # Default to gateway.url as the resource (strip query per RFC 8707 SHOULD NOT)
315 oauth_config["resource"] = _normalize_resource_url(gateway.url)
317 # Phase 1.4: Auto-trigger DCR if credentials are missing
318 # Check if gateway has issuer but no client_id (DCR scenario)
319 issuer = oauth_config.get("issuer")
320 client_id = oauth_config.get("client_id")
322 if issuer and not client_id:
323 if settings.dcr_enabled and settings.dcr_auto_register_on_missing_credentials:
324 logger.info(f"Gateway {gateway_id} has issuer but no client_id. Attempting DCR...")
326 try:
327 # Initialize DCR service
328 dcr_service = DcrService()
330 # Check if client is already registered in database
331 registered_client = await dcr_service.get_or_register_client(
332 gateway_id=gateway_id,
333 gateway_name=gateway.name,
334 issuer=issuer,
335 redirect_uri=oauth_config.get("redirect_uri"),
336 scopes=oauth_config.get("scopes", settings.dcr_default_scopes),
337 db=db,
338 )
340 logger.info(f"✅ DCR successful for gateway {gateway_id}: client_id={registered_client.client_id}")
342 # Decrypt the client secret for use in OAuth flow (if present - public clients may not have secrets)
343 decrypted_secret = None
344 if registered_client.client_secret_encrypted:
345 # First-Party
346 from mcpgateway.services.encryption_service import get_encryption_service
348 encryption = get_encryption_service(settings.auth_encryption_secret)
349 decrypted_secret = await encryption.decrypt_secret_async(registered_client.client_secret_encrypted)
351 # Update oauth_config with registered credentials
352 oauth_config["client_id"] = registered_client.client_id
353 if decrypted_secret:
354 oauth_config["client_secret"] = decrypted_secret
356 # Discover AS metadata to get authorization/token endpoints if not already set
357 # Note: OAuthManager expects 'authorization_url' and 'token_url', not 'authorization_endpoint'/'token_endpoint'
358 if not oauth_config.get("authorization_url") or not oauth_config.get("token_url"):
359 metadata = await dcr_service.discover_as_metadata(issuer)
360 oauth_config["authorization_url"] = metadata.get("authorization_endpoint")
361 oauth_config["token_url"] = metadata.get("token_endpoint")
362 logger.info(f"Discovered OAuth endpoints for {issuer}")
364 # Update gateway's oauth_config and auth_type in database for future use.
365 # Protect sensitive fields before persistence to keep service-layer behavior consistent.
366 gateway.oauth_config = await protect_oauth_config_for_storage(oauth_config, existing_oauth_config=gateway.oauth_config)
367 gateway.auth_type = "oauth" # Ensure auth_type is set for OAuth-protected servers
368 db.commit()
370 logger.info(f"Updated gateway {gateway_id} with DCR credentials and auth_type=oauth")
372 except DcrError as dcr_err:
373 logger.error(f"DCR failed for gateway {gateway_id}: {dcr_err}")
374 raise HTTPException(
375 status_code=500,
376 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.",
377 )
378 except Exception as dcr_ex:
379 logger.error(f"Unexpected error during DCR for gateway {gateway_id}: {dcr_ex}")
380 raise HTTPException(status_code=500, detail=f"Failed to register OAuth client: {str(dcr_ex)}")
381 else:
382 # DCR is disabled or auto-register is off
383 logger.warning(f"Gateway {gateway_id} has issuer but no client_id, and DCR auto-registration is disabled")
384 raise HTTPException(
385 status_code=400,
386 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",
387 )
389 # Validate required fields for OAuth flow
390 if not oauth_config.get("client_id"):
391 raise HTTPException(status_code=400, detail="OAuth configuration missing client_id")
393 # Initiate OAuth flow with user context (now includes PKCE from existing implementation)
394 requester_email = _extract_user_email(current_user)
395 oauth_manager = OAuthManager(token_storage=TokenStorageService(db))
396 auth_data = await oauth_manager.initiate_authorization_code_flow(gateway_id, oauth_config, app_user_email=requester_email)
398 logger.info(f"Initiated OAuth flow for gateway {gateway_id} by user {requester_email}")
400 # Redirect user to OAuth provider
401 return RedirectResponse(url=auth_data["authorization_url"])
403 except HTTPException:
404 raise
405 except Exception as e:
406 logger.error(f"Failed to initiate OAuth flow: {str(e)}")
407 raise HTTPException(status_code=500, detail=f"Failed to initiate OAuth flow: {str(e)}")
410@oauth_router.get("/callback")
411async def oauth_callback(
412 code: Annotated[str | None, Query(description="Authorization code from OAuth provider")] = None,
413 state: Annotated[str, Query(description="State parameter for CSRF protection")] = ...,
414 error: Annotated[str | None, Query(description="OAuth provider error code")] = None,
415 error_description: Annotated[str | None, Query(description="OAuth provider error description")] = None,
416 # Remove the gateway_id parameter requirement
417 request: Request = None,
418 db: Session = Depends(get_db),
419) -> HTMLResponse:
420 """Handle the OAuth callback and complete the authorization process.
422 This endpoint is called by the OAuth provider after the user authorizes access.
423 It receives the authorization code and state parameters, verifies the state,
424 retrieves the corresponding gateway configuration, and exchanges the code for an access token.
426 Args:
427 code (str): The authorization code returned by the OAuth provider.
428 state (str): The state parameter for CSRF protection, which encodes the gateway ID.
429 error (str): OAuth provider error code from error callback (RFC 6749 Section 4.1.2.1).
430 error_description (str): OAuth provider error description.
431 request (Request): The incoming HTTP request object.
432 db (Session): The database session dependency.
434 Returns:
435 HTMLResponse: An HTML response indicating the result of the OAuth authorization process.
437 Raises:
438 ValueError: Raised internally when state parameter is missing gateway_id (caught and handled).
440 Examples:
441 >>> import asyncio
442 >>> asyncio.iscoroutinefunction(oauth_callback)
443 True
444 """
446 try:
447 # Get root path for URL construction
448 root_path = request.scope.get("root_path", "") if request else ""
449 safe_root_path = escape(str(root_path), quote=True)
451 # RFC 6749 Section 4.1.2.1: provider may return error instead of code
452 if error:
453 error_text = escape(error)
454 description_text = escape(error_description or "OAuth provider returned an authorization error.")
455 # Sanitize untrusted query parameters before logging to prevent log injection
456 logger.warning(f"OAuth provider returned error callback: error={sanitize_for_log(error)}, description={sanitize_for_log(error_description)}")
457 return HTMLResponse(
458 content=f"""
459 <!DOCTYPE html>
460 <html>
461 <head><title>OAuth Authorization Failed</title></head>
462 <body>
463 <h1>❌ OAuth Authorization Failed</h1>
464 <p><strong>Error:</strong> {error_text}</p>
465 <p><strong>Description:</strong> {description_text}</p>
466 <a href="{safe_root_path}/admin#gateways">Return to Admin Panel</a>
467 </body>
468 </html>
469 """,
470 status_code=400,
471 )
473 if not code:
474 logger.warning("OAuth callback missing authorization code")
475 return HTMLResponse(
476 content=f"""
477 <!DOCTYPE html>
478 <html>
479 <head><title>OAuth Authorization Failed</title></head>
480 <body>
481 <h1>❌ OAuth Authorization Failed</h1>
482 <p>Error: Missing authorization code in callback response.</p>
483 <a href="{safe_root_path}/admin#gateways">Return to Admin Panel</a>
484 </body>
485 </html>
486 """,
487 status_code=400,
488 )
490 def _invalid_state_response() -> HTMLResponse:
491 """Return an HTML error page for invalid or missing OAuth state.
493 Returns:
494 HTMLResponse: A 400 error page describing the invalid state.
495 """
496 return HTMLResponse(
497 content=f"""
498 <!DOCTYPE html>
499 <html>
500 <head><title>OAuth Authorization Failed</title></head>
501 <body>
502 <h1>❌ OAuth Authorization Failed</h1>
503 <p>Error: Invalid OAuth state parameter.</p>
504 <a href="{safe_root_path}/admin#gateways">Return to Admin Panel</a>
505 </body>
506 </html>
507 """,
508 status_code=400,
509 )
511 oauth_manager = OAuthManager(token_storage=TokenStorageService(db))
512 gateway_id = await oauth_manager.resolve_gateway_id_from_state(state, allow_legacy_fallback=False)
513 if not gateway_id:
514 logger.warning("OAuth callback received invalid or unknown state token")
515 return _invalid_state_response()
517 # Get gateway configuration
518 gateway = db.execute(select(Gateway).where(Gateway.id == gateway_id)).scalar_one_or_none()
520 if not gateway:
521 logger.warning("OAuth callback state resolved to unknown gateway id")
522 return _invalid_state_response()
524 if not gateway.oauth_config:
525 logger.warning("OAuth callback state resolved to gateway without OAuth configuration")
526 return _invalid_state_response()
528 # Complete OAuth flow
530 # RFC 8707: Add resource parameter for JWT access tokens
531 # Must be set here in callback, not just in /authorize, because complete_authorization_code_flow
532 # needs it for the token exchange request
533 # Respect pre-configured resource; only derive from gateway.url if not explicitly configured
534 oauth_config_with_resource = gateway.oauth_config.copy()
535 if oauth_config_with_resource.get("resource"):
536 # Preserve query for explicit config (RFC 8707 allows when necessary)
537 existing = oauth_config_with_resource["resource"]
538 if isinstance(existing, list):
539 original_count = len(existing)
540 normalized = [_normalize_resource_url(r, preserve_query=True) for r in existing]
541 oauth_config_with_resource["resource"] = [r for r in normalized if r]
542 if not oauth_config_with_resource["resource"] and original_count > 0:
543 logger.warning(f"All {original_count} configured resource values were invalid and removed")
544 else:
545 oauth_config_with_resource["resource"] = _normalize_resource_url(existing, preserve_query=True)
546 else:
547 # Strip query for auto-derived (RFC 8707 SHOULD NOT)
548 oauth_config_with_resource["resource"] = _normalize_resource_url(gateway.url)
550 result = await oauth_manager.complete_authorization_code_flow(gateway_id, code, state, oauth_config_with_resource)
552 logger.info(f"Completed OAuth flow for gateway {gateway_id}, user {result.get('user_id')}")
554 # Return success page with option to return to admin
555 return HTMLResponse(
556 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))}', {{
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 """
645 )
647 except OAuthError as e:
648 logger.error(f"OAuth callback failed: {str(e)}")
649 return HTMLResponse(
650 content=f"""
651 <!DOCTYPE html>
652 <html>
653 <head>
654 <title>OAuth Authorization Failed</title>
655 <style>
656 body {{ font-family: Arial, sans-serif; margin: 40px; }}
657 .error {{ color: #dc2626; }}
658 .button {{
659 display: inline-block;
660 padding: 10px 20px;
661 background-color: #3b82f6;
662 color: white;
663 text-decoration: none;
664 border-radius: 5px;
665 margin-top: 20px;
666 }}
667 .button:hover {{ background-color: #2563eb; }}
668 </style>
669 </head>
670 <body>
671 <h1 class="error">❌ OAuth Authorization Failed</h1>
672 <p><strong>Error:</strong> {escape(str(e))}</p>
673 <p>Please check your OAuth configuration and try again.</p>
674 <a href="{safe_root_path}/admin#gateways" class="button">Return to Admin Panel</a>
675 </body>
676 </html>
677 """,
678 status_code=400,
679 )
681 except Exception as e:
682 logger.error(f"Unexpected error in OAuth callback: {str(e)}")
683 return HTMLResponse(
684 content=f"""
685 <!DOCTYPE html>
686 <html>
687 <head>
688 <title>OAuth Authorization Failed</title>
689 <style>
690 body {{ font-family: Arial, sans-serif; margin: 40px; }}
691 .error {{ color: #dc2626; }}
692 .button {{
693 display: inline-block;
694 padding: 10px 20px;
695 background-color: #3b82f6;
696 color: white;
697 text-decoration: none;
698 border-radius: 5px;
699 margin-top: 20px;
700 }}
701 .button:hover {{ background-color: #2563eb; }}
702 </style>
703 </head>
704 <body>
705 <h1 class="error">❌ OAuth Authorization Failed</h1>
706 <p><strong>Unexpected Error:</strong> {escape(str(e))}</p>
707 <p>Please contact your administrator for assistance.</p>
708 <a href="{safe_root_path}/admin#gateways" class="button">Return to Admin Panel</a>
709 </body>
710 </html>
711 """,
712 status_code=500,
713 )
716@oauth_router.get("/status/{gateway_id}")
717async def get_oauth_status(
718 gateway_id: str,
719 request: Request,
720 current_user: dict = Depends(get_current_user_with_permissions),
721 db: Session = Depends(get_db),
722) -> dict:
723 """Get OAuth status for a gateway.
725 Requires authentication and authorization to prevent information disclosure
726 about gateway OAuth configuration (client IDs, scopes, etc.).
728 Args:
729 gateway_id: ID of the gateway
730 current_user: Authenticated user (enforces authentication)
731 db: Database session
732 request: Request with token-scoping context.
734 Returns:
735 OAuth status information
737 Raises:
738 HTTPException: If not authenticated, not authorized, gateway not found, or error
739 """
740 try:
741 # Get gateway configuration
742 gateway = db.execute(select(Gateway).where(Gateway.id == gateway_id)).scalar_one_or_none()
744 if not gateway:
745 raise HTTPException(status_code=404, detail="Gateway not found")
747 await _enforce_gateway_access(gateway_id, gateway, current_user, db, request=request)
749 if not gateway.oauth_config:
750 return {"oauth_enabled": False, "message": "Gateway is not configured for OAuth"}
752 # Get OAuth configuration info
753 oauth_config = gateway.oauth_config
754 grant_type = oauth_config.get("grant_type")
756 if grant_type == "authorization_code":
757 # For now, return basic info - in a real implementation you might want to
758 # show authorized users, token status, etc.
759 return {
760 "oauth_enabled": True,
761 "grant_type": grant_type,
762 "client_id": oauth_config.get("client_id"),
763 "scopes": oauth_config.get("scopes", []),
764 "authorization_url": oauth_config.get("authorization_url"),
765 "redirect_uri": oauth_config.get("redirect_uri"),
766 "message": "Gateway configured for Authorization Code flow",
767 }
768 else:
769 return {
770 "oauth_enabled": True,
771 "grant_type": grant_type,
772 "client_id": oauth_config.get("client_id"),
773 "scopes": oauth_config.get("scopes", []),
774 "message": f"Gateway configured for {grant_type} flow",
775 }
777 except HTTPException:
778 raise
779 except Exception as e:
780 logger.error(f"Failed to get OAuth status: {str(e)}")
781 raise HTTPException(status_code=500, detail=f"Failed to get OAuth status: {str(e)}")
784@oauth_router.post("/fetch-tools/{gateway_id}")
785@require_permission("gateways.update")
786async def fetch_tools_after_oauth(
787 gateway_id: str,
788 request: Request,
789 current_user: EmailUserResponse = Depends(get_current_user_with_permissions),
790 db: Session = Depends(get_db),
791) -> Dict[str, Any]:
792 """Fetch tools from MCP server after OAuth completion for Authorization Code flow.
794 Args:
795 gateway_id: ID of the gateway to fetch tools for
796 request: Incoming request used for token scope context
797 current_user: The authenticated user fetching tools
798 db: Database session
800 Returns:
801 Dict containing success status and message with number of tools fetched
803 Raises:
804 HTTPException: If fetching tools fails
805 """
806 try:
807 gateway = db.execute(select(Gateway).where(Gateway.id == gateway_id)).scalar_one_or_none()
808 if not gateway:
809 raise HTTPException(status_code=404, detail=f"Gateway not found: {gateway_id}")
811 requester_email = current_user.get("email") if isinstance(current_user, dict) else getattr(current_user, "email", None)
812 await _enforce_gateway_access(gateway_id, gateway, current_user, db, request=request)
814 # First-Party
815 from mcpgateway.services.gateway_service import GatewayService
817 gateway_service = GatewayService()
818 result = await gateway_service.fetch_tools_after_oauth(db, gateway_id, requester_email)
819 tools_count = len(result.get("tools", []))
821 return {"success": True, "message": f"Successfully fetched and created {tools_count} tools"}
823 except HTTPException:
824 raise
825 except Exception as e:
826 logger.error(f"Failed to fetch tools after OAuth for gateway {gateway_id}: {e}")
827 raise HTTPException(status_code=500, detail=f"Failed to fetch tools: {str(e)}")
830# ============================================================================
831# Admin Endpoints for DCR Management
832# ============================================================================
835@oauth_router.get("/registered-clients")
836async def list_registered_oauth_clients(current_user: EmailUserResponse = Depends(get_current_user_with_permissions), db: Session = Depends(get_db)) -> Dict[str, Any]: # noqa: ARG001
837 """List all registered OAuth clients (created via DCR).
839 This endpoint shows OAuth clients that were dynamically registered with external
840 Authorization Servers using RFC 7591 Dynamic Client Registration.
842 Args:
843 current_user: The authenticated user (admin access required)
844 db: Database session
846 Returns:
847 Dict containing list of registered OAuth clients with metadata
849 Raises:
850 HTTPException: If user lacks permissions or database error occurs
851 """
852 _require_admin_user(current_user)
854 try:
855 # First-Party
856 from mcpgateway.db import RegisteredOAuthClient
858 # Query all registered clients
859 clients = db.execute(select(RegisteredOAuthClient)).scalars().all()
861 # Build response
862 clients_data = []
863 for client in clients:
864 clients_data.append(
865 {
866 "id": client.id,
867 "gateway_id": client.gateway_id,
868 "issuer": client.issuer,
869 "client_id": client.client_id,
870 "redirect_uris": client.redirect_uris.split(",") if isinstance(client.redirect_uris, str) else client.redirect_uris,
871 "grant_types": client.grant_types.split(",") if isinstance(client.grant_types, str) else client.grant_types,
872 "scope": client.scope,
873 "token_endpoint_auth_method": client.token_endpoint_auth_method,
874 "created_at": client.created_at.isoformat() if client.created_at else None,
875 "expires_at": client.expires_at.isoformat() if client.expires_at else None,
876 "is_active": client.is_active,
877 }
878 )
880 return {"total": len(clients_data), "clients": clients_data}
882 except Exception as e:
883 logger.error(f"Failed to list registered OAuth clients: {e}")
884 raise HTTPException(status_code=500, detail=f"Failed to list registered clients: {str(e)}")
887@oauth_router.get("/registered-clients/{gateway_id}")
888async def get_registered_client_for_gateway(
889 gateway_id: str,
890 current_user: EmailUserResponse = Depends(get_current_user_with_permissions),
891 db: Session = Depends(get_db), # noqa: ARG001
892) -> Dict[str, Any]:
893 """Get the registered OAuth client for a specific gateway.
895 Args:
896 gateway_id: The gateway ID to lookup
897 current_user: The authenticated user
898 db: Database session
900 Returns:
901 Dict containing registered client information
903 Raises:
904 HTTPException: If gateway or registered client not found
905 """
906 _require_admin_user(current_user)
908 try:
909 # First-Party
910 from mcpgateway.db import RegisteredOAuthClient
912 # Query registered client for this gateway
913 client = db.execute(select(RegisteredOAuthClient).where(RegisteredOAuthClient.gateway_id == gateway_id)).scalar_one_or_none()
915 if not client:
916 raise HTTPException(status_code=404, detail=f"No registered OAuth client found for gateway {gateway_id}")
918 return {
919 "id": client.id,
920 "gateway_id": client.gateway_id,
921 "issuer": client.issuer,
922 "client_id": client.client_id,
923 "redirect_uris": client.redirect_uris.split(",") if isinstance(client.redirect_uris, str) else client.redirect_uris,
924 "grant_types": client.grant_types.split(",") if isinstance(client.grant_types, str) else client.grant_types,
925 "scope": client.scope,
926 "token_endpoint_auth_method": client.token_endpoint_auth_method,
927 "registration_client_uri": client.registration_client_uri,
928 "created_at": client.created_at.isoformat() if client.created_at else None,
929 "expires_at": client.expires_at.isoformat() if client.expires_at else None,
930 "is_active": client.is_active,
931 }
933 except HTTPException:
934 raise
935 except Exception as e:
936 logger.error(f"Failed to get registered client for gateway {gateway_id}: {e}")
937 raise HTTPException(status_code=500, detail=f"Failed to get registered client: {str(e)}")
940@oauth_router.delete("/registered-clients/{client_id}")
941async 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
942 """Delete a registered OAuth client.
944 This will revoke the client registration locally. Note: This does not automatically
945 revoke the client at the Authorization Server. You may need to manually revoke the
946 client using the registration_client_uri if available.
948 Args:
949 client_id: The registered client ID to delete
950 current_user: The authenticated user (admin access required)
951 db: Database session
953 Returns:
954 Dict containing success message
956 Raises:
957 HTTPException: If client not found or deletion fails
958 """
959 _require_admin_user(current_user)
961 try:
962 # First-Party
963 from mcpgateway.db import RegisteredOAuthClient
965 # Find the client
966 client = db.execute(select(RegisteredOAuthClient).where(RegisteredOAuthClient.id == client_id)).scalar_one_or_none()
968 if not client:
969 raise HTTPException(status_code=404, detail=f"Registered client {client_id} not found")
971 issuer = client.issuer
972 gateway_id = client.gateway_id
974 # Delete the client
975 db.delete(client)
976 db.commit()
977 db.close()
979 logger.info(f"Deleted registered OAuth client {client_id} for gateway {gateway_id} (issuer: {issuer})")
981 return {"success": True, "message": f"Registered OAuth client {client_id} deleted successfully", "gateway_id": gateway_id, "issuer": issuer}
983 except HTTPException:
984 raise
985 except Exception as e:
986 logger.error(f"Failed to delete registered client {client_id}: {e}")
987 db.rollback()
988 raise HTTPException(status_code=500, detail=f"Failed to delete registered client: {str(e)}")