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

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

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 

16from html import escape 

17import logging 

18from typing import Annotated, Any, Dict 

19from urllib.parse import urlparse, urlunparse 

20 

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 

26 

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 

39 

40logger = logging.getLogger(__name__) 

41 

42 

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

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

45 

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) 

50 

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

55 

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 

72 

73 

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

75 

76 

77def _require_admin_user(current_user: EmailUserResponse) -> None: 

78 """Require admin context for DCR management endpoints. 

79 

80 Args: 

81 current_user: Authenticated user context from RBAC dependency. 

82 

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

89 

90 

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. 

93 

94 Args: 

95 request: Incoming request with token scoping state. 

96 current_user: Authenticated user context. 

97 

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

106 

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 

119 

120 if token_teams is _not_set: 

121 token_teams = None if is_admin else [] 

122 

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 

126 

127 if is_admin and token_teams is None: 

128 return None 

129 return token_teams 

130 

131 

132def _extract_user_email(current_user: EmailUserResponse | dict) -> str | None: 

133 """Extract requester email from typed or dict user contexts. 

134 

135 Args: 

136 current_user: Authenticated user context. 

137 

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 

150 

151 

152def _extract_is_admin(current_user: EmailUserResponse | dict) -> bool: 

153 """Extract admin flag from typed or dict user contexts. 

154 

155 Args: 

156 current_user: Authenticated user context. 

157 

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 

166 

167 

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. 

176 

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. 

183 

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

190 

191 requester_is_admin = _extract_is_admin(current_user) 

192 

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 = [] 

199 

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

207 

208 if requester_is_admin: 

209 return 

210 

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) 

214 

215 if visibility == "public": 

216 return 

217 

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 

223 

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 

229 

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

234 

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 

240 

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 

245 

246 raise HTTPException(status_code=403, detail="You don't have access to this gateway") 

247 

248 

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. 

254 

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. 

258 

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

263 

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. 

269 

270 Returns: 

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

272 

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. 

276 

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

285 

286 if not gateway: 

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

288 

289 await _enforce_gateway_access(gateway_id, gateway, current_user, db, request=request) 

290 

291 if not gateway.oauth_config: 

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

293 

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

296 

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

298 

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) 

316 

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

321 

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

325 

326 try: 

327 # Initialize DCR service 

328 dcr_service = DcrService() 

329 

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 ) 

339 

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

341 

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 

347 

348 encryption = get_encryption_service(settings.auth_encryption_secret) 

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

350 

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 

355 

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

363 

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

369 

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

371 

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 ) 

388 

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

392 

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) 

397 

398 logger.info(f"Initiated OAuth flow for gateway {gateway_id} by user {requester_email}") 

399 

400 # Redirect user to OAuth provider 

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

402 

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

408 

409 

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. 

421 

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. 

425 

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. 

433 

434 Returns: 

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

436 

437 Raises: 

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

439 

440 Examples: 

441 >>> import asyncio 

442 >>> asyncio.iscoroutinefunction(oauth_callback) 

443 True 

444 """ 

445 

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) 

450 

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 ) 

472 

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 ) 

489 

490 def _invalid_state_response() -> HTMLResponse: 

491 """Return an HTML error page for invalid or missing OAuth state. 

492 

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 ) 

510 

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

516 

517 # Get gateway configuration 

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

519 

520 if not gateway: 

521 logger.warning("OAuth callback state resolved to unknown gateway id") 

522 return _invalid_state_response() 

523 

524 if not gateway.oauth_config: 

525 logger.warning("OAuth callback state resolved to gateway without OAuth configuration") 

526 return _invalid_state_response() 

527 

528 # Complete OAuth flow 

529 

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) 

549 

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

551 

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

553 

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> 

586 

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> 

595 

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

597 

598 <script> 

599 async function fetchTools() {{ 

600 const button = event.target; 

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

602 

603 button.disabled = true; 

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

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

606 

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

613 

614 const result = await response.json(); 

615 

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 ) 

646 

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 ) 

680 

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 ) 

714 

715 

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. 

724 

725 Requires authentication and authorization to prevent information disclosure 

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

727 

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. 

733 

734 Returns: 

735 OAuth status information 

736 

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

743 

744 if not gateway: 

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

746 

747 await _enforce_gateway_access(gateway_id, gateway, current_user, db, request=request) 

748 

749 if not gateway.oauth_config: 

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

751 

752 # Get OAuth configuration info 

753 oauth_config = gateway.oauth_config 

754 grant_type = oauth_config.get("grant_type") 

755 

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 } 

776 

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

782 

783 

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. 

793 

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 

799 

800 Returns: 

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

802 

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

810 

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) 

813 

814 # First-Party 

815 from mcpgateway.services.gateway_service import GatewayService 

816 

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

820 

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

822 

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

828 

829 

830# ============================================================================ 

831# Admin Endpoints for DCR Management 

832# ============================================================================ 

833 

834 

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

838 

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

840 Authorization Servers using RFC 7591 Dynamic Client Registration. 

841 

842 Args: 

843 current_user: The authenticated user (admin access required) 

844 db: Database session 

845 

846 Returns: 

847 Dict containing list of registered OAuth clients with metadata 

848 

849 Raises: 

850 HTTPException: If user lacks permissions or database error occurs 

851 """ 

852 _require_admin_user(current_user) 

853 

854 try: 

855 # First-Party 

856 from mcpgateway.db import RegisteredOAuthClient 

857 

858 # Query all registered clients 

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

860 

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 ) 

879 

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

881 

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

885 

886 

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. 

894 

895 Args: 

896 gateway_id: The gateway ID to lookup 

897 current_user: The authenticated user 

898 db: Database session 

899 

900 Returns: 

901 Dict containing registered client information 

902 

903 Raises: 

904 HTTPException: If gateway or registered client not found 

905 """ 

906 _require_admin_user(current_user) 

907 

908 try: 

909 # First-Party 

910 from mcpgateway.db import RegisteredOAuthClient 

911 

912 # Query registered client for this gateway 

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

914 

915 if not client: 

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

917 

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 } 

932 

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

938 

939 

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. 

943 

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. 

947 

948 Args: 

949 client_id: The registered client ID to delete 

950 current_user: The authenticated user (admin access required) 

951 db: Database session 

952 

953 Returns: 

954 Dict containing success message 

955 

956 Raises: 

957 HTTPException: If client not found or deletion fails 

958 """ 

959 _require_admin_user(current_user) 

960 

961 try: 

962 # First-Party 

963 from mcpgateway.db import RegisteredOAuthClient 

964 

965 # Find the client 

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

967 

968 if not client: 

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

970 

971 issuer = client.issuer 

972 gateway_id = client.gateway_id 

973 

974 # Delete the client 

975 db.delete(client) 

976 db.commit() 

977 db.close() 

978 

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

980 

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

982 

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