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

1# -*- coding: utf-8 -*- 

2"""Location: ./mcpgateway/routers/oauth_router.py 

3Copyright 2025 

4SPDX-License-Identifier: Apache-2.0 

5Authors: Mihai Criveti 

6 

7OAuth Router for MCP Gateway. 

8 

9This module handles OAuth 2.0 Authorization Code flow endpoints including: 

10- Initiating OAuth flows 

11- Handling OAuth callbacks 

12- Token management 

13""" 

14 

15# Standard 

16import logging 

17from typing import Any, Dict 

18from urllib.parse import urlparse, urlunparse 

19 

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 

25 

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 

34 

35logger = logging.getLogger(__name__) 

36 

37 

38def _normalize_resource_url(url: str | None, *, preserve_query: bool = False) -> str | None: 

39 """Normalize URL for use as RFC 8707 resource parameter. 

40 

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) 

45 

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). 

50 

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 

67 

68 

69oauth_router = APIRouter(prefix="/oauth", tags=["oauth"]) 

70 

71 

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. 

77 

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. 

81 

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). 

86 

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. 

92 

93 Returns: 

94 A redirect response to the OAuth provider's authorization URL. 

95 

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. 

99 

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() 

108 

109 if not gateway: 

110 raise HTTPException(status_code=404, detail="Gateway not found") 

111 

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) 

116 

117 # Get team_id safely (may not exist on all gateway objects) 

118 gateway_team_id = getattr(gateway, "team_id", None) 

119 

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 

124 

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") 

130 

131 if not gateway.oauth_config: 

132 raise HTTPException(status_code=400, detail="Gateway is not configured for OAuth") 

133 

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") 

136 

137 oauth_config = gateway.oauth_config.copy() # Work with a copy to avoid mutating the original 

138 

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) 

156 

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") 

161 

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...") 

165 

166 try: 

167 # Initialize DCR service 

168 dcr_service = DcrService() 

169 

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 ) 

179 

180 logger.info(f"✅ DCR successful for gateway {gateway_id}: client_id={registered_client.client_id}") 

181 

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 

187 

188 encryption = get_encryption_service(settings.auth_encryption_secret) 

189 decrypted_secret = await encryption.decrypt_secret_async(registered_client.client_secret_encrypted) 

190 

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 

195 

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}") 

203 

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() 

208 

209 logger.info(f"Updated gateway {gateway_id} with DCR credentials and auth_type=oauth") 

210 

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 ) 

227 

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") 

231 

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")) 

235 

236 logger.info(f"Initiated OAuth flow for gateway {gateway_id} by user {current_user.get('email')}") 

237 

238 # Redirect user to OAuth provider 

239 return RedirectResponse(url=auth_data["authorization_url"]) 

240 

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)}") 

246 

247 

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. 

257 

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. 

261 

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. 

267 

268 Returns: 

269 HTMLResponse: An HTML response indicating the result of the OAuth authorization process. 

270 

271 Raises: 

272 ValueError: Raised internally when state parameter is missing gateway_id (caught and handled). 

273 

274 Examples: 

275 >>> import asyncio 

276 >>> asyncio.iscoroutinefunction(oauth_callback) 

277 True 

278 """ 

279 

280 try: 

281 # Get root path for URL construction 

282 root_path = request.scope.get("root_path", "") if request else "" 

283 

284 # Extract gateway_id from state parameter 

285 # Try new base64-encoded JSON format first 

286 # Standard 

287 import base64 

288 

289 # Third-Party 

290 import orjson 

291 

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") 

298 

299 # Split payload and signature. Signature is the last 32 bytes. 

300 payload_bytes = state_raw[:-32] 

301 # signature_bytes = state_raw[-32:] 

302 

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}") 

308 

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] 

318 

319 # Get gateway configuration 

320 gateway = db.execute(select(Gateway).where(Gateway.id == gateway_id)).scalar_one_or_none() 

321 

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 ) 

337 

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 ) 

353 

354 # Complete OAuth flow 

355 oauth_manager = OAuthManager(token_storage=TokenStorageService(db)) 

356 

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) 

376 

377 result = await oauth_manager.complete_authorization_code_flow(gateway_id, code, state, oauth_config_with_resource) 

378 

379 logger.info(f"Completed OAuth flow for gateway {gateway_id}, user {result.get('user_id')}") 

380 

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> 

413 

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> 

422 

423 <a href="{root_path}/admin#gateways" class="button">Return to Admin Panel</a> 

424 

425 <script> 

426 async function fetchTools() {{ 

427 const button = event.target; 

428 const statusDiv = document.getElementById('fetch-status'); 

429 

430 button.disabled = true; 

431 button.textContent = '⏳ Fetching Tools...'; 

432 statusDiv.innerHTML = '<p style="color: #2563eb;">Fetching tools from MCP server...</p>'; 

433 

434 try {{ 

435 const response = await fetch('{root_path}/oauth/fetch-tools/{gateway_id}', {{ 

436 method: 'POST' 

437 }}); 

438 

439 const result = await response.json(); 

440 

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 ) 

471 

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 ) 

505 

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 ) 

539 

540 

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. 

548 

549 Requires authentication and authorization to prevent information disclosure 

550 about gateway OAuth configuration (client IDs, scopes, etc.). 

551 

552 Args: 

553 gateway_id: ID of the gateway 

554 current_user: Authenticated user (enforces authentication) 

555 db: Database session 

556 

557 Returns: 

558 OAuth status information 

559 

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() 

566 

567 if not gateway: 

568 raise HTTPException(status_code=404, detail="Gateway not found") 

569 

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) 

576 

577 gateway_team_id = getattr(gateway, "team_id", None) 

578 

579 if not is_admin and gateway_team_id: 

580 # First-Party 

581 from mcpgateway.services.email_auth_service import EmailAuthService 

582 

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") 

587 

588 if not gateway.oauth_config: 

589 return {"oauth_enabled": False, "message": "Gateway is not configured for OAuth"} 

590 

591 # Get OAuth configuration info 

592 oauth_config = gateway.oauth_config 

593 grant_type = oauth_config.get("grant_type") 

594 

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 } 

615 

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)}") 

621 

622 

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. 

626 

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 

631 

632 Returns: 

633 Dict containing success status and message with number of tools fetched 

634 

635 Raises: 

636 HTTPException: If fetching tools fails 

637 """ 

638 try: 

639 # First-Party 

640 from mcpgateway.services.gateway_service import GatewayService 

641 

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", [])) 

645 

646 return {"success": True, "message": f"Successfully fetched and created {tools_count} tools"} 

647 

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)}") 

651 

652 

653# ============================================================================ 

654# Admin Endpoints for DCR Management 

655# ============================================================================ 

656 

657 

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). 

661 

662 This endpoint shows OAuth clients that were dynamically registered with external 

663 Authorization Servers using RFC 7591 Dynamic Client Registration. 

664 

665 Args: 

666 current_user: The authenticated user (admin access required) 

667 db: Database session 

668 

669 Returns: 

670 Dict containing list of registered OAuth clients with metadata 

671 

672 Raises: 

673 HTTPException: If user lacks permissions or database error occurs 

674 """ 

675 try: 

676 # First-Party 

677 from mcpgateway.db import RegisteredOAuthClient 

678 

679 # Query all registered clients 

680 clients = db.execute(select(RegisteredOAuthClient)).scalars().all() 

681 

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 ) 

700 

701 return {"total": len(clients_data), "clients": clients_data} 

702 

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)}") 

706 

707 

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. 

715 

716 Args: 

717 gateway_id: The gateway ID to lookup 

718 current_user: The authenticated user 

719 db: Database session 

720 

721 Returns: 

722 Dict containing registered client information 

723 

724 Raises: 

725 HTTPException: If gateway or registered client not found 

726 """ 

727 try: 

728 # First-Party 

729 from mcpgateway.db import RegisteredOAuthClient 

730 

731 # Query registered client for this gateway 

732 client = db.execute(select(RegisteredOAuthClient).where(RegisteredOAuthClient.gateway_id == gateway_id)).scalar_one_or_none() 

733 

734 if not client: 

735 raise HTTPException(status_code=404, detail=f"No registered OAuth client found for gateway {gateway_id}") 

736 

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 } 

751 

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)}") 

757 

758 

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. 

762 

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. 

766 

767 Args: 

768 client_id: The registered client ID to delete 

769 current_user: The authenticated user (admin access required) 

770 db: Database session 

771 

772 Returns: 

773 Dict containing success message 

774 

775 Raises: 

776 HTTPException: If client not found or deletion fails 

777 """ 

778 try: 

779 # First-Party 

780 from mcpgateway.db import RegisteredOAuthClient 

781 

782 # Find the client 

783 client = db.execute(select(RegisteredOAuthClient).where(RegisteredOAuthClient.id == client_id)).scalar_one_or_none() 

784 

785 if not client: 

786 raise HTTPException(status_code=404, detail=f"Registered client {client_id} not found") 

787 

788 issuer = client.issuer 

789 gateway_id = client.gateway_id 

790 

791 # Delete the client 

792 db.delete(client) 

793 db.commit() 

794 db.close() 

795 

796 logger.info(f"Deleted registered OAuth client {client_id} for gateway {gateway_id} (issuer: {issuer})") 

797 

798 return {"success": True, "message": f"Registered OAuth client {client_id} deleted successfully", "gateway_id": gateway_id, "issuer": issuer} 

799 

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)}")