Coverage for mcpgateway / routers / oauth_router.py: 99%
236 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-11 07:10 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-11 07:10 +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 MCP Gateway.
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
16import logging
17from typing import Any, Dict
18from urllib.parse import urlparse, urlunparse
20# Third-Party
21from fastapi import APIRouter, Depends, HTTPException, Query, Request
22from fastapi.responses import HTMLResponse, RedirectResponse
23from sqlalchemy import select
24from sqlalchemy.orm import Session
26# First-Party
27from mcpgateway.config import settings
28from mcpgateway.db import Gateway, get_db
29from mcpgateway.middleware.rbac import get_current_user_with_permissions
30from mcpgateway.schemas import EmailUserResponse
31from mcpgateway.services.dcr_service import DcrError, DcrService
32from mcpgateway.services.oauth_manager import OAuthError, OAuthManager
33from mcpgateway.services.token_storage_service import TokenStorageService
35logger = logging.getLogger(__name__)
38def _normalize_resource_url(url: str | None, *, preserve_query: bool = False) -> str | None:
39 """Normalize URL for use as RFC 8707 resource parameter.
41 Per RFC 8707 Section 2:
42 - resource MUST be an absolute URI (scheme required; supports both URLs and URNs)
43 - resource MUST NOT include a fragment component
44 - resource SHOULD NOT include a query component (but allowed when necessary)
46 Args:
47 url: The resource URL to normalize
48 preserve_query: If True, preserve query component (for explicitly configured resources).
49 If False, strip query (for auto-derived resources per RFC 8707 SHOULD NOT).
51 Returns:
52 Normalized URL suitable for RFC 8707 resource parameter, or None if invalid
53 """
54 if not url:
55 return None
56 parsed = urlparse(url)
57 # RFC 8707: resource MUST be an absolute URI (requires scheme)
58 # Support both hierarchical URIs (https://...) and URNs (urn:example:app)
59 if not parsed.scheme:
60 logger.warning(f"Invalid resource URL (must be absolute URI with scheme): {url}")
61 return None
62 # Remove fragment (MUST NOT per RFC 8707)
63 # Query: strip for auto-derived (SHOULD NOT), preserve for explicit config (allowed when necessary)
64 query = parsed.query if preserve_query else ""
65 normalized = urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, query, ""))
66 return normalized
69oauth_router = APIRouter(prefix="/oauth", tags=["oauth"])
72@oauth_router.get("/authorize/{gateway_id}")
73async def initiate_oauth_flow(
74 gateway_id: str, request: Request, current_user: EmailUserResponse = Depends(get_current_user_with_permissions), db: Session = Depends(get_db)
75) -> RedirectResponse: # noqa: ARG001
76 """Initiates the OAuth 2.0 Authorization Code flow for a specified gateway.
78 This endpoint retrieves the OAuth configuration for the given gateway, validates that
79 the gateway supports the Authorization Code flow, and redirects the user to the OAuth
80 provider's authorization URL to begin the OAuth process.
82 **Phase 1.4: DCR Integration**
83 If the gateway has an issuer but no client_id, and DCR is enabled, this endpoint will
84 automatically register the gateway as an OAuth client with the Authorization Server
85 using Dynamic Client Registration (RFC 7591).
87 Args:
88 gateway_id: The unique identifier of the gateway to authorize.
89 request: The FastAPI request object.
90 current_user: The authenticated user initiating the OAuth flow.
91 db: The database session dependency.
93 Returns:
94 A redirect response to the OAuth provider's authorization URL.
96 Raises:
97 HTTPException: If the gateway is not found, not configured for OAuth, or not using
98 the Authorization Code flow. If an unexpected error occurs during the initiation process.
100 Examples:
101 >>> import asyncio
102 >>> asyncio.iscoroutinefunction(initiate_oauth_flow)
103 True
104 """
105 try:
106 # Get gateway configuration
107 gateway = db.execute(select(Gateway).where(Gateway.id == gateway_id)).scalar_one_or_none()
109 if not gateway:
110 raise HTTPException(status_code=404, detail="Gateway not found")
112 # Check gateway access permission
113 # Admins can access any gateway; otherwise check team membership if gateway has team_id
114 user_email = current_user.email if hasattr(current_user, "email") else current_user.get("email")
115 is_admin = current_user.is_admin if hasattr(current_user, "is_admin") else current_user.get("is_admin", False)
117 # Get team_id safely (may not exist on all gateway objects)
118 gateway_team_id = getattr(gateway, "team_id", None)
120 if not is_admin and gateway_team_id:
121 # Import here to avoid circular imports
122 # First-Party
123 from mcpgateway.services.email_auth_service import EmailAuthService
125 auth_service = EmailAuthService(db)
126 user = await auth_service.get_user_by_email(user_email)
127 if not user or not user.is_team_member(gateway_team_id): 127 ↛ 131line 127 didn't jump to line 131 because the condition on line 127 was always true
128 logger.warning(f"OAuth access denied: user {user_email} not member of gateway team {gateway_team_id}")
129 raise HTTPException(status_code=403, detail="You don't have access to this gateway")
131 if not gateway.oauth_config:
132 raise HTTPException(status_code=400, detail="Gateway is not configured for OAuth")
134 if gateway.oauth_config.get("grant_type") != "authorization_code":
135 raise HTTPException(status_code=400, detail="Gateway is not configured for Authorization Code flow")
137 oauth_config = gateway.oauth_config.copy() # Work with a copy to avoid mutating the original
139 # RFC 8707: Set resource parameter for JWT access tokens
140 # Respect pre-configured resource (e.g., for providers requiring pre-registered resources)
141 # Only derive from gateway.url if not explicitly configured
142 if oauth_config.get("resource"):
143 # Normalize existing resource - preserve query for explicit config (RFC 8707 allows when necessary)
144 existing = oauth_config["resource"]
145 if isinstance(existing, list):
146 original_count = len(existing)
147 normalized = [_normalize_resource_url(r, preserve_query=True) for r in existing]
148 oauth_config["resource"] = [r for r in normalized if r]
149 if not oauth_config["resource"] and original_count > 0:
150 logger.warning(f"All {original_count} configured resource values were invalid and removed")
151 else:
152 oauth_config["resource"] = _normalize_resource_url(existing, preserve_query=True)
153 else:
154 # Default to gateway.url as the resource (strip query per RFC 8707 SHOULD NOT)
155 oauth_config["resource"] = _normalize_resource_url(gateway.url)
157 # Phase 1.4: Auto-trigger DCR if credentials are missing
158 # Check if gateway has issuer but no client_id (DCR scenario)
159 issuer = oauth_config.get("issuer")
160 client_id = oauth_config.get("client_id")
162 if issuer and not client_id:
163 if settings.dcr_enabled and settings.dcr_auto_register_on_missing_credentials:
164 logger.info(f"Gateway {gateway_id} has issuer but no client_id. Attempting DCR...")
166 try:
167 # Initialize DCR service
168 dcr_service = DcrService()
170 # Check if client is already registered in database
171 registered_client = await dcr_service.get_or_register_client(
172 gateway_id=gateway_id,
173 gateway_name=gateway.name,
174 issuer=issuer,
175 redirect_uri=oauth_config.get("redirect_uri"),
176 scopes=oauth_config.get("scopes", settings.dcr_default_scopes),
177 db=db,
178 )
180 logger.info(f"✅ DCR successful for gateway {gateway_id}: client_id={registered_client.client_id}")
182 # Decrypt the client secret for use in OAuth flow (if present - public clients may not have secrets)
183 decrypted_secret = None
184 if registered_client.client_secret_encrypted:
185 # First-Party
186 from mcpgateway.services.encryption_service import get_encryption_service
188 encryption = get_encryption_service(settings.auth_encryption_secret)
189 decrypted_secret = await encryption.decrypt_secret_async(registered_client.client_secret_encrypted)
191 # Update oauth_config with registered credentials
192 oauth_config["client_id"] = registered_client.client_id
193 if decrypted_secret:
194 oauth_config["client_secret"] = decrypted_secret
196 # Discover AS metadata to get authorization/token endpoints if not already set
197 # Note: OAuthManager expects 'authorization_url' and 'token_url', not 'authorization_endpoint'/'token_endpoint'
198 if not oauth_config.get("authorization_url") or not oauth_config.get("token_url"): 198 ↛ 205line 198 didn't jump to line 205 because the condition on line 198 was always true
199 metadata = await dcr_service.discover_as_metadata(issuer)
200 oauth_config["authorization_url"] = metadata.get("authorization_endpoint")
201 oauth_config["token_url"] = metadata.get("token_endpoint")
202 logger.info(f"Discovered OAuth endpoints for {issuer}")
204 # Update gateway's oauth_config and auth_type in database for future use
205 gateway.oauth_config = oauth_config
206 gateway.auth_type = "oauth" # Ensure auth_type is set for OAuth-protected servers
207 db.commit()
209 logger.info(f"Updated gateway {gateway_id} with DCR credentials and auth_type=oauth")
211 except DcrError as dcr_err:
212 logger.error(f"DCR failed for gateway {gateway_id}: {dcr_err}")
213 raise HTTPException(
214 status_code=500,
215 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.",
216 )
217 except Exception as dcr_ex:
218 logger.error(f"Unexpected error during DCR for gateway {gateway_id}: {dcr_ex}")
219 raise HTTPException(status_code=500, detail=f"Failed to register OAuth client: {str(dcr_ex)}")
220 else:
221 # DCR is disabled or auto-register is off
222 logger.warning(f"Gateway {gateway_id} has issuer but no client_id, and DCR auto-registration is disabled")
223 raise HTTPException(
224 status_code=400,
225 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",
226 )
228 # Validate required fields for OAuth flow
229 if not oauth_config.get("client_id"):
230 raise HTTPException(status_code=400, detail="OAuth configuration missing client_id")
232 # Initiate OAuth flow with user context (now includes PKCE from existing implementation)
233 oauth_manager = OAuthManager(token_storage=TokenStorageService(db))
234 auth_data = await oauth_manager.initiate_authorization_code_flow(gateway_id, oauth_config, app_user_email=current_user.get("email"))
236 logger.info(f"Initiated OAuth flow for gateway {gateway_id} by user {current_user.get('email')}")
238 # Redirect user to OAuth provider
239 return RedirectResponse(url=auth_data["authorization_url"])
241 except HTTPException:
242 raise
243 except Exception as e:
244 logger.error(f"Failed to initiate OAuth flow: {str(e)}")
245 raise HTTPException(status_code=500, detail=f"Failed to initiate OAuth flow: {str(e)}")
248@oauth_router.get("/callback")
249async def oauth_callback(
250 code: str = Query(..., description="Authorization code from OAuth provider"),
251 state: str = Query(..., description="State parameter for CSRF protection"),
252 # Remove the gateway_id parameter requirement
253 request: Request = None,
254 db: Session = Depends(get_db),
255) -> HTMLResponse:
256 """Handle the OAuth callback and complete the authorization process.
258 This endpoint is called by the OAuth provider after the user authorizes access.
259 It receives the authorization code and state parameters, verifies the state,
260 retrieves the corresponding gateway configuration, and exchanges the code for an access token.
262 Args:
263 code (str): The authorization code returned by the OAuth provider.
264 state (str): The state parameter for CSRF protection, which encodes the gateway ID.
265 request (Request): The incoming HTTP request object.
266 db (Session): The database session dependency.
268 Returns:
269 HTMLResponse: An HTML response indicating the result of the OAuth authorization process.
271 Raises:
272 ValueError: Raised internally when state parameter is missing gateway_id (caught and handled).
274 Examples:
275 >>> import asyncio
276 >>> asyncio.iscoroutinefunction(oauth_callback)
277 True
278 """
280 try:
281 # Get root path for URL construction
282 root_path = request.scope.get("root_path", "") if request else ""
284 # Extract gateway_id from state parameter
285 # Try new base64-encoded JSON format first
286 # Standard
287 import base64
289 # Third-Party
290 import orjson
292 try:
293 # Expect state as base64url(payload || signature) where the last 32 bytes
294 # are the signature. Decode to bytes first so we can split payload vs sig.
295 state_raw = base64.urlsafe_b64decode(state.encode())
296 if len(state_raw) <= 32:
297 raise ValueError("State too short to contain payload and signature")
299 # Split payload and signature. Signature is the last 32 bytes.
300 payload_bytes = state_raw[:-32]
301 # signature_bytes = state_raw[-32:]
303 # Parse the JSON payload only (not including signature bytes)
304 try:
305 state_data = orjson.loads(payload_bytes)
306 except Exception as decode_exc:
307 raise ValueError(f"Failed to parse state payload JSON: {decode_exc}")
309 gateway_id = state_data.get("gateway_id")
310 if not gateway_id:
311 raise ValueError("No gateway_id in state")
312 except Exception as e:
313 # Fallback to legacy format (gateway_id_random)
314 logger.warning(f"Failed to decode state as JSON, trying legacy format: {e}")
315 if "_" not in state:
316 return HTMLResponse(content="<h1>❌ Invalid state parameter</h1>", status_code=400)
317 gateway_id = state.split("_")[0]
319 # Get gateway configuration
320 gateway = db.execute(select(Gateway).where(Gateway.id == gateway_id)).scalar_one_or_none()
322 if not gateway:
323 return HTMLResponse(
324 content="""
325 <!DOCTYPE html>
326 <html>
327 <head><title>OAuth Authorization Failed</title></head>
328 <body>
329 <h1>❌ OAuth Authorization Failed</h1>
330 <p>Error: Gateway not found</p>
331 <a href="{root_path}/admin#gateways">Return to Admin Panel</a>
332 </body>
333 </html>
334 """,
335 status_code=404,
336 )
338 if not gateway.oauth_config:
339 return HTMLResponse(
340 content="""
341 <!DOCTYPE html>
342 <html>
343 <head><title>OAuth Authorization Failed</title></head>
344 <body>
345 <h1>❌ OAuth Authorization Failed</h1>
346 <p>Error: Gateway has no OAuth configuration</p>
347 <a href="{root_path}/admin#gateways">Return to Admin Panel</a>
348 </body>
349 </html>
350 """,
351 status_code=400,
352 )
354 # Complete OAuth flow
355 oauth_manager = OAuthManager(token_storage=TokenStorageService(db))
357 # RFC 8707: Add resource parameter for JWT access tokens
358 # Must be set here in callback, not just in /authorize, because complete_authorization_code_flow
359 # needs it for the token exchange request
360 # Respect pre-configured resource; only derive from gateway.url if not explicitly configured
361 oauth_config_with_resource = gateway.oauth_config.copy()
362 if oauth_config_with_resource.get("resource"):
363 # Preserve query for explicit config (RFC 8707 allows when necessary)
364 existing = oauth_config_with_resource["resource"]
365 if isinstance(existing, list):
366 original_count = len(existing)
367 normalized = [_normalize_resource_url(r, preserve_query=True) for r in existing]
368 oauth_config_with_resource["resource"] = [r for r in normalized if r]
369 if not oauth_config_with_resource["resource"] and original_count > 0: 369 ↛ 377line 369 didn't jump to line 377 because the condition on line 369 was always true
370 logger.warning(f"All {original_count} configured resource values were invalid and removed")
371 else:
372 oauth_config_with_resource["resource"] = _normalize_resource_url(existing, preserve_query=True)
373 else:
374 # Strip query for auto-derived (RFC 8707 SHOULD NOT)
375 oauth_config_with_resource["resource"] = _normalize_resource_url(gateway.url)
377 result = await oauth_manager.complete_authorization_code_flow(gateway_id, code, state, oauth_config_with_resource)
379 logger.info(f"Completed OAuth flow for gateway {gateway_id}, user {result.get('user_id')}")
381 # Return success page with option to return to admin
382 return HTMLResponse(
383 content=f"""
384 <!DOCTYPE html>
385 <html>
386 <head>
387 <title>OAuth Authorization Successful</title>
388 <style>
389 body {{ font-family: Arial, sans-serif; margin: 40px; }}
390 .success {{ color: #059669; }}
391 .error {{ color: #dc2626; }}
392 .info {{ color: #2563eb; }}
393 .button {{
394 display: inline-block;
395 padding: 10px 20px;
396 background-color: #3b82f6;
397 color: white;
398 text-decoration: none;
399 border-radius: 5px;
400 margin-top: 20px;
401 }}
402 .button:hover {{ background-color: #2563eb; }}
403 </style>
404 </head>
405 <body>
406 <h1 class="success">✅ OAuth Authorization Successful</h1>
407 <div class="info">
408 <p><strong>Gateway:</strong> {gateway.name}</p>
409 <p><strong>User ID:</strong> {result.get("user_id", "Unknown")}</p>
410 <p><strong>Expires:</strong> {result.get("expires_at", "Unknown")}</p>
411 <p><strong>Status:</strong> Authorization completed successfully</p>
412 </div>
414 <div style="margin: 30px 0;">
415 <h3>Next Steps:</h3>
416 <p>Now that OAuth authorization is complete, you can fetch tools from the MCP server:</p>
417 <button onclick="fetchTools()" class="button" style="background-color: #059669;">
418 🔧 Fetch Tools from MCP Server
419 </button>
420 <div id="fetch-status" style="margin-top: 15px;"></div>
421 </div>
423 <a href="{root_path}/admin#gateways" class="button">Return to Admin Panel</a>
425 <script>
426 async function fetchTools() {{
427 const button = event.target;
428 const statusDiv = document.getElementById('fetch-status');
430 button.disabled = true;
431 button.textContent = '⏳ Fetching Tools...';
432 statusDiv.innerHTML = '<p style="color: #2563eb;">Fetching tools from MCP server...</p>';
434 try {{
435 const response = await fetch('{root_path}/oauth/fetch-tools/{gateway_id}', {{
436 method: 'POST'
437 }});
439 const result = await response.json();
441 if (response.ok) {{
442 statusDiv.innerHTML = `
443 <div style="color: #059669; padding: 15px; background-color: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 5px;">
444 <h4>✅ Tools Fetched Successfully!</h4>
445 <p>${{result.message}}</p>
446 </div>
447 `;
448 button.textContent = '✅ Tools Fetched';
449 button.style.backgroundColor = '#059669';
450 }} else {{
451 throw new Error(result.detail || 'Failed to fetch tools');
452 }}
453 }} catch (error) {{
454 statusDiv.innerHTML = `
455 <div style="color: #dc2626; padding: 15px; background-color: #fef2f2; border: 1px solid #fecaca; border-radius: 5px;">
456 <h4>❌ Failed to Fetch Tools</h4>
457 <p><strong>Error:</strong> ${{error.message}}</p>
458 <p>You can still return to the admin panel and try again later.</p>
459 </div>
460 `;
461 button.textContent = '❌ Retry Fetch Tools';
462 button.style.backgroundColor = '#dc2626';
463 button.disabled = false;
464 }}
465 }}
466 </script>
467 </body>
468 </html>
469 """
470 )
472 except OAuthError as e:
473 logger.error(f"OAuth callback failed: {str(e)}")
474 return HTMLResponse(
475 content=f"""
476 <!DOCTYPE html>
477 <html>
478 <head>
479 <title>OAuth Authorization Failed</title>
480 <style>
481 body {{ font-family: Arial, sans-serif; margin: 40px; }}
482 .error {{ color: #dc2626; }}
483 .button {{
484 display: inline-block;
485 padding: 10px 20px;
486 background-color: #3b82f6;
487 color: white;
488 text-decoration: none;
489 border-radius: 5px;
490 margin-top: 20px;
491 }}
492 .button:hover {{ background-color: #2563eb; }}
493 </style>
494 </head>
495 <body>
496 <h1 class="error">❌ OAuth Authorization Failed</h1>
497 <p><strong>Error:</strong> {str(e)}</p>
498 <p>Please check your OAuth configuration and try again.</p>
499 <a href="{root_path}/admin#gateways" class="button">Return to Admin Panel</a>
500 </body>
501 </html>
502 """,
503 status_code=400,
504 )
506 except Exception as e:
507 logger.error(f"Unexpected error in OAuth callback: {str(e)}")
508 return HTMLResponse(
509 content=f"""
510 <!DOCTYPE html>
511 <html>
512 <head>
513 <title>OAuth Authorization Failed</title>
514 <style>
515 body {{ font-family: Arial, sans-serif; margin: 40px; }}
516 .error {{ color: #dc2626; }}
517 .button {{
518 display: inline-block;
519 padding: 10px 20px;
520 background-color: #3b82f6;
521 color: white;
522 text-decoration: none;
523 border-radius: 5px;
524 margin-top: 20px;
525 }}
526 .button:hover {{ background-color: #2563eb; }}
527 </style>
528 </head>
529 <body>
530 <h1 class="error">❌ OAuth Authorization Failed</h1>
531 <p><strong>Unexpected Error:</strong> {str(e)}</p>
532 <p>Please contact your administrator for assistance.</p>
533 <a href="{root_path}/admin#gateways" class="button">Return to Admin Panel</a>
534 </body>
535 </html>
536 """,
537 status_code=500,
538 )
541@oauth_router.get("/status/{gateway_id}")
542async def get_oauth_status(
543 gateway_id: str,
544 current_user: dict = Depends(get_current_user_with_permissions),
545 db: Session = Depends(get_db),
546) -> dict:
547 """Get OAuth status for a gateway.
549 Requires authentication and authorization to prevent information disclosure
550 about gateway OAuth configuration (client IDs, scopes, etc.).
552 Args:
553 gateway_id: ID of the gateway
554 current_user: Authenticated user (enforces authentication)
555 db: Database session
557 Returns:
558 OAuth status information
560 Raises:
561 HTTPException: If not authenticated, not authorized, gateway not found, or error
562 """
563 try:
564 # Get gateway configuration
565 gateway = db.execute(select(Gateway).where(Gateway.id == gateway_id)).scalar_one_or_none()
567 if not gateway:
568 raise HTTPException(status_code=404, detail="Gateway not found")
570 # Check team-based authorization (same pattern as initiate_oauth_flow)
571 user_email = current_user.get("email") if isinstance(current_user, dict) else getattr(current_user, "email", None)
572 is_admin = current_user.get("is_admin", False) if isinstance(current_user, dict) else getattr(current_user, "is_admin", False)
573 # Also check nested user.is_admin for JWT tokens
574 if isinstance(current_user, dict) and not is_admin:
575 is_admin = current_user.get("user", {}).get("is_admin", False)
577 gateway_team_id = getattr(gateway, "team_id", None)
579 if not is_admin and gateway_team_id:
580 # First-Party
581 from mcpgateway.services.email_auth_service import EmailAuthService
583 auth_service = EmailAuthService(db)
584 user = await auth_service.get_user_by_email(user_email)
585 if not user or not user.is_team_member(gateway_team_id): 585 ↛ 588line 585 didn't jump to line 588 because the condition on line 585 was always true
586 raise HTTPException(status_code=403, detail="You don't have access to this gateway")
588 if not gateway.oauth_config:
589 return {"oauth_enabled": False, "message": "Gateway is not configured for OAuth"}
591 # Get OAuth configuration info
592 oauth_config = gateway.oauth_config
593 grant_type = oauth_config.get("grant_type")
595 if grant_type == "authorization_code":
596 # For now, return basic info - in a real implementation you might want to
597 # show authorized users, token status, etc.
598 return {
599 "oauth_enabled": True,
600 "grant_type": grant_type,
601 "client_id": oauth_config.get("client_id"),
602 "scopes": oauth_config.get("scopes", []),
603 "authorization_url": oauth_config.get("authorization_url"),
604 "redirect_uri": oauth_config.get("redirect_uri"),
605 "message": "Gateway configured for Authorization Code flow",
606 }
607 else:
608 return {
609 "oauth_enabled": True,
610 "grant_type": grant_type,
611 "client_id": oauth_config.get("client_id"),
612 "scopes": oauth_config.get("scopes", []),
613 "message": f"Gateway configured for {grant_type} flow",
614 }
616 except HTTPException:
617 raise
618 except Exception as e:
619 logger.error(f"Failed to get OAuth status: {str(e)}")
620 raise HTTPException(status_code=500, detail=f"Failed to get OAuth status: {str(e)}")
623@oauth_router.post("/fetch-tools/{gateway_id}")
624async def fetch_tools_after_oauth(gateway_id: str, current_user: EmailUserResponse = Depends(get_current_user_with_permissions), db: Session = Depends(get_db)) -> Dict[str, Any]:
625 """Fetch tools from MCP server after OAuth completion for Authorization Code flow.
627 Args:
628 gateway_id: ID of the gateway to fetch tools for
629 current_user: The authenticated user fetching tools
630 db: Database session
632 Returns:
633 Dict containing success status and message with number of tools fetched
635 Raises:
636 HTTPException: If fetching tools fails
637 """
638 try:
639 # First-Party
640 from mcpgateway.services.gateway_service import GatewayService
642 gateway_service = GatewayService()
643 result = await gateway_service.fetch_tools_after_oauth(db, gateway_id, current_user.get("email"))
644 tools_count = len(result.get("tools", []))
646 return {"success": True, "message": f"Successfully fetched and created {tools_count} tools"}
648 except Exception as e:
649 logger.error(f"Failed to fetch tools after OAuth for gateway {gateway_id}: {e}")
650 raise HTTPException(status_code=500, detail=f"Failed to fetch tools: {str(e)}")
653# ============================================================================
654# Admin Endpoints for DCR Management
655# ============================================================================
658@oauth_router.get("/registered-clients")
659async def list_registered_oauth_clients(current_user: EmailUserResponse = Depends(get_current_user_with_permissions), db: Session = Depends(get_db)) -> Dict[str, Any]: # noqa: ARG001
660 """List all registered OAuth clients (created via DCR).
662 This endpoint shows OAuth clients that were dynamically registered with external
663 Authorization Servers using RFC 7591 Dynamic Client Registration.
665 Args:
666 current_user: The authenticated user (admin access required)
667 db: Database session
669 Returns:
670 Dict containing list of registered OAuth clients with metadata
672 Raises:
673 HTTPException: If user lacks permissions or database error occurs
674 """
675 try:
676 # First-Party
677 from mcpgateway.db import RegisteredOAuthClient
679 # Query all registered clients
680 clients = db.execute(select(RegisteredOAuthClient)).scalars().all()
682 # Build response
683 clients_data = []
684 for client in clients:
685 clients_data.append(
686 {
687 "id": client.id,
688 "gateway_id": client.gateway_id,
689 "issuer": client.issuer,
690 "client_id": client.client_id,
691 "redirect_uris": client.redirect_uris.split(",") if isinstance(client.redirect_uris, str) else client.redirect_uris,
692 "grant_types": client.grant_types.split(",") if isinstance(client.grant_types, str) else client.grant_types,
693 "scope": client.scope,
694 "token_endpoint_auth_method": client.token_endpoint_auth_method,
695 "created_at": client.created_at.isoformat() if client.created_at else None,
696 "expires_at": client.expires_at.isoformat() if client.expires_at else None,
697 "is_active": client.is_active,
698 }
699 )
701 return {"total": len(clients_data), "clients": clients_data}
703 except Exception as e:
704 logger.error(f"Failed to list registered OAuth clients: {e}")
705 raise HTTPException(status_code=500, detail=f"Failed to list registered clients: {str(e)}")
708@oauth_router.get("/registered-clients/{gateway_id}")
709async def get_registered_client_for_gateway(
710 gateway_id: str,
711 current_user: EmailUserResponse = Depends(get_current_user_with_permissions),
712 db: Session = Depends(get_db), # noqa: ARG001
713) -> Dict[str, Any]:
714 """Get the registered OAuth client for a specific gateway.
716 Args:
717 gateway_id: The gateway ID to lookup
718 current_user: The authenticated user
719 db: Database session
721 Returns:
722 Dict containing registered client information
724 Raises:
725 HTTPException: If gateway or registered client not found
726 """
727 try:
728 # First-Party
729 from mcpgateway.db import RegisteredOAuthClient
731 # Query registered client for this gateway
732 client = db.execute(select(RegisteredOAuthClient).where(RegisteredOAuthClient.gateway_id == gateway_id)).scalar_one_or_none()
734 if not client:
735 raise HTTPException(status_code=404, detail=f"No registered OAuth client found for gateway {gateway_id}")
737 return {
738 "id": client.id,
739 "gateway_id": client.gateway_id,
740 "issuer": client.issuer,
741 "client_id": client.client_id,
742 "redirect_uris": client.redirect_uris.split(",") if isinstance(client.redirect_uris, str) else client.redirect_uris,
743 "grant_types": client.grant_types.split(",") if isinstance(client.grant_types, str) else client.grant_types,
744 "scope": client.scope,
745 "token_endpoint_auth_method": client.token_endpoint_auth_method,
746 "registration_client_uri": client.registration_client_uri,
747 "created_at": client.created_at.isoformat() if client.created_at else None,
748 "expires_at": client.expires_at.isoformat() if client.expires_at else None,
749 "is_active": client.is_active,
750 }
752 except HTTPException:
753 raise
754 except Exception as e:
755 logger.error(f"Failed to get registered client for gateway {gateway_id}: {e}")
756 raise HTTPException(status_code=500, detail=f"Failed to get registered client: {str(e)}")
759@oauth_router.delete("/registered-clients/{client_id}")
760async 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
761 """Delete a registered OAuth client.
763 This will revoke the client registration locally. Note: This does not automatically
764 revoke the client at the Authorization Server. You may need to manually revoke the
765 client using the registration_client_uri if available.
767 Args:
768 client_id: The registered client ID to delete
769 current_user: The authenticated user (admin access required)
770 db: Database session
772 Returns:
773 Dict containing success message
775 Raises:
776 HTTPException: If client not found or deletion fails
777 """
778 try:
779 # First-Party
780 from mcpgateway.db import RegisteredOAuthClient
782 # Find the client
783 client = db.execute(select(RegisteredOAuthClient).where(RegisteredOAuthClient.id == client_id)).scalar_one_or_none()
785 if not client:
786 raise HTTPException(status_code=404, detail=f"Registered client {client_id} not found")
788 issuer = client.issuer
789 gateway_id = client.gateway_id
791 # Delete the client
792 db.delete(client)
793 db.commit()
794 db.close()
796 logger.info(f"Deleted registered OAuth client {client_id} for gateway {gateway_id} (issuer: {issuer})")
798 return {"success": True, "message": f"Registered OAuth client {client_id} deleted successfully", "gateway_id": gateway_id, "issuer": issuer}
800 except HTTPException:
801 raise
802 except Exception as e:
803 logger.error(f"Failed to delete registered client {client_id}: {e}")
804 db.rollback()
805 raise HTTPException(status_code=500, detail=f"Failed to delete registered client: {str(e)}")