Coverage for mcpgateway / routers / oauth_router.py: 100%

319 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-06 00:56 +0100

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.common.validators import SecurityValidator 

30from mcpgateway.config import settings 

31from mcpgateway.db import Gateway, get_db 

32from mcpgateway.middleware.rbac import get_current_user_with_permissions, require_permission 

33from mcpgateway.middleware.token_scoping import token_scoping_middleware 

34from mcpgateway.schemas import EmailUserResponse 

35from mcpgateway.services.dcr_service import DcrError, DcrService 

36from mcpgateway.services.encryption_service import protect_oauth_config_for_storage 

37from mcpgateway.services.oauth_manager import OAuthError, OAuthManager 

38from mcpgateway.services.token_storage_service import TokenStorageService 

39from mcpgateway.utils.log_sanitizer import sanitize_for_log 

40 

41logger = logging.getLogger(__name__) 

42 

43 

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

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

46 

47 Per RFC 8707 Section 2: 

48 - resource MUST be an absolute URI (scheme required; supports both URLs and URNs) 

49 - resource MUST NOT include a fragment component 

50 - resource SHOULD NOT include a query component (but allowed when necessary) 

51 

52 Args: 

53 url: The resource URL to normalize 

54 preserve_query: If True, preserve query component (for explicitly configured resources). 

55 If False, strip query (for auto-derived resources per RFC 8707 SHOULD NOT). 

56 

57 Returns: 

58 Normalized URL suitable for RFC 8707 resource parameter, or None if invalid 

59 """ 

60 if not url: 

61 return None 

62 parsed = urlparse(url) 

63 # RFC 8707: resource MUST be an absolute URI (requires scheme) 

64 # Support both hierarchical URIs (https://...) and URNs (urn:example:app) 

65 if not parsed.scheme: 

66 logger.warning(f"Invalid resource URL (must be absolute URI with scheme): {url}") 

67 return None 

68 # Remove fragment (MUST NOT per RFC 8707) 

69 # Query: strip for auto-derived (SHOULD NOT), preserve for explicit config (allowed when necessary) 

70 query = parsed.query if preserve_query else "" 

71 normalized = urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, query, "")) 

72 return normalized 

73 

74 

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

76 

77 

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

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

80 

81 Args: 

82 current_user: Authenticated user context from RBAC dependency. 

83 

84 Raises: 

85 HTTPException: If requester is not an admin user. 

86 """ 

87 is_admin = current_user.is_admin if hasattr(current_user, "is_admin") else current_user.get("is_admin", False) 

88 if not is_admin: 

89 raise HTTPException(status_code=403, detail="Admin permissions required") 

90 

91 

92def _resolve_token_teams_for_scope_check(request: Request, current_user: EmailUserResponse) -> list[str] | None: 

93 """Resolve token teams for scoped ownership checks using normalized token semantics. 

94 

95 Args: 

96 request: Incoming request with token scoping state. 

97 current_user: Authenticated user context. 

98 

99 Returns: 

100 ``None`` for unrestricted admin scope, or a normalized team list for scoped access. 

101 """ 

102 is_admin = False 

103 if hasattr(current_user, "is_admin"): 

104 is_admin = bool(getattr(current_user, "is_admin", False)) 

105 elif isinstance(current_user, dict): 

106 is_admin = bool(current_user.get("is_admin", False) or current_user.get("user", {}).get("is_admin", False)) 

107 

108 _not_set = object() 

109 token_teams = getattr(request.state, "token_teams", _not_set) 

110 if token_teams is _not_set or not (token_teams is None or isinstance(token_teams, list)): 

111 cached = getattr(request.state, "_jwt_verified_payload", None) 

112 if cached and isinstance(cached, tuple) and len(cached) == 2: 

113 _, payload = cached 

114 if payload: 

115 token_teams = normalize_token_teams(payload) 

116 is_admin = bool(payload.get("is_admin", False) or payload.get("user", {}).get("is_admin", False)) 

117 # Fail closed when request.state contains an unexpected token_teams value. 

118 if token_teams is not _not_set and not (token_teams is None or isinstance(token_teams, list)): 

119 token_teams = _not_set 

120 

121 if token_teams is _not_set: 

122 token_teams = None if is_admin else [] 

123 

124 # Empty-team scoped tokens are public-only and must never receive admin bypass. 

125 if isinstance(token_teams, list) and len(token_teams) == 0: 

126 is_admin = False 

127 

128 if is_admin and token_teams is None: 

129 return None 

130 return token_teams 

131 

132 

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

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

135 

136 Args: 

137 current_user: Authenticated user context. 

138 

139 Returns: 

140 Lowercased email when available, otherwise ``None``. 

141 """ 

142 if hasattr(current_user, "email"): 

143 email = getattr(current_user, "email", None) 

144 if isinstance(email, str) and email.strip(): 

145 return email.strip().lower() 

146 if isinstance(current_user, dict): 

147 email = current_user.get("email") or current_user.get("user", {}).get("email") 

148 if isinstance(email, str) and email.strip(): 

149 return email.strip().lower() 

150 return None 

151 

152 

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

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

155 

156 Args: 

157 current_user: Authenticated user context. 

158 

159 Returns: 

160 ``True`` when the user context indicates admin privileges. 

161 """ 

162 if hasattr(current_user, "is_admin"): 

163 return bool(getattr(current_user, "is_admin", False)) 

164 if isinstance(current_user, dict): 

165 return bool(current_user.get("is_admin", False) or current_user.get("user", {}).get("is_admin", False)) 

166 return False 

167 

168 

169async def _enforce_gateway_access( 

170 gateway_id: str, 

171 gateway: Gateway, 

172 current_user: EmailUserResponse, 

173 db: Session, 

174 request: Request | None = None, 

175) -> None: 

176 """Enforce gateway visibility and ownership checks for OAuth endpoints. 

177 

178 Args: 

179 gateway_id: Gateway identifier used for scoped ownership checks. 

180 gateway: Gateway record being accessed. 

181 current_user: Authenticated requester context. 

182 db: Active database session. 

183 request: Optional request carrying token-scoping context. 

184 

185 Raises: 

186 HTTPException: If authentication is missing or access is not permitted. 

187 """ 

188 requester_email = _extract_user_email(current_user) 

189 if not requester_email: 

190 raise HTTPException(status_code=401, detail="User authentication required") 

191 

192 requester_is_admin = _extract_is_admin(current_user) 

193 

194 if request is not None: 

195 token_teams = _resolve_token_teams_for_scope_check(request, current_user) 

196 if token_teams is None: 

197 if requester_is_admin: 

198 return 

199 token_teams = [] 

200 

201 if not token_scoping_middleware._check_resource_team_ownership( 

202 f"/gateways/{gateway_id}", 

203 token_teams, 

204 db=db, 

205 _user_email=requester_email, 

206 ): 

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

208 

209 if requester_is_admin: 

210 return 

211 

212 visibility = str(getattr(gateway, "visibility", "team") or "team").lower() 

213 gateway_owner = getattr(gateway, "owner_email", None) 

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

215 

216 if visibility == "public": 

217 return 

218 

219 if visibility == "team": 

220 if not gateway_team_id: 

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

222 # First-Party 

223 from mcpgateway.services.email_auth_service import EmailAuthService 

224 

225 auth_service = EmailAuthService(db) 

226 user = await auth_service.get_user_by_email(requester_email) 

227 if not user or not user.is_team_member(gateway_team_id): 

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

229 return 

230 

231 if visibility in {"private", "user"}: 

232 if gateway_owner and gateway_owner.strip().lower() == requester_email: 

233 return 

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

235 

236 if gateway_owner and gateway_owner.strip().lower() == requester_email: 

237 return 

238 if gateway_team_id: 

239 # First-Party 

240 from mcpgateway.services.email_auth_service import EmailAuthService 

241 

242 auth_service = EmailAuthService(db) 

243 user = await auth_service.get_user_by_email(requester_email) 

244 if user and user.is_team_member(gateway_team_id): 

245 return 

246 

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

248 

249 

250@oauth_router.get("/authorize/{gateway_id}") 

251async def initiate_oauth_flow( 

252 gateway_id: str, request: Request, current_user: EmailUserResponse = Depends(get_current_user_with_permissions), db: Session = Depends(get_db) 

253) -> RedirectResponse: # noqa: ARG001 

254 """Initiates the OAuth 2.0 Authorization Code flow for a specified gateway. 

255 

256 This endpoint retrieves the OAuth configuration for the given gateway, validates that 

257 the gateway supports the Authorization Code flow, and redirects the user to the OAuth 

258 provider's authorization URL to begin the OAuth process. 

259 

260 **Phase 1.4: DCR Integration** 

261 If the gateway has an issuer but no client_id, and DCR is enabled, this endpoint will 

262 automatically register the gateway as an OAuth client with the Authorization Server 

263 using Dynamic Client Registration (RFC 7591). 

264 

265 Args: 

266 gateway_id: The unique identifier of the gateway to authorize. 

267 request: The FastAPI request object. 

268 current_user: The authenticated user initiating the OAuth flow. 

269 db: The database session dependency. 

270 

271 Returns: 

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

273 

274 Raises: 

275 HTTPException: If the gateway is not found, not configured for OAuth, or not using 

276 the Authorization Code flow. If an unexpected error occurs during the initiation process. 

277 

278 Examples: 

279 >>> import asyncio 

280 >>> asyncio.iscoroutinefunction(initiate_oauth_flow) 

281 True 

282 """ 

283 try: 

284 # Get gateway configuration 

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

286 

287 if not gateway: 

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

289 

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

291 

292 if not gateway.oauth_config: 

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

294 

295 if gateway.oauth_config.get("grant_type") != "authorization_code": 

296 raise HTTPException(status_code=400, detail="Gateway is not configured for Authorization Code flow") 

297 

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

299 

300 # RFC 8707: Set resource parameter for JWT access tokens 

301 # Respect pre-configured resource (e.g., for providers requiring pre-registered resources) 

302 # Only derive from gateway.url if not explicitly configured 

303 if oauth_config.get("resource"): 

304 # Normalize existing resource - preserve query for explicit config (RFC 8707 allows when necessary) 

305 existing = oauth_config["resource"] 

306 if isinstance(existing, list): 

307 original_count = len(existing) 

308 normalized = [_normalize_resource_url(r, preserve_query=True) for r in existing] 

309 oauth_config["resource"] = [r for r in normalized if r] 

310 if not oauth_config["resource"] and original_count > 0: 

311 logger.warning(f"All {original_count} configured resource values were invalid and removed") 

312 else: 

313 oauth_config["resource"] = _normalize_resource_url(existing, preserve_query=True) 

314 else: 

315 # Default to gateway.url as the resource (strip query per RFC 8707 SHOULD NOT) 

316 oauth_config["resource"] = _normalize_resource_url(gateway.url) 

317 

318 # Phase 1.4: Auto-trigger DCR if credentials are missing 

319 # Check if gateway has issuer but no client_id (DCR scenario) 

320 issuer = oauth_config.get("issuer") 

321 client_id = oauth_config.get("client_id") 

322 

323 if issuer and not client_id: 

324 if settings.dcr_enabled and settings.dcr_auto_register_on_missing_credentials: 

325 logger.info(f"Gateway {SecurityValidator.sanitize_log_message(gateway_id)} has issuer but no client_id. Attempting DCR...") 

326 

327 try: 

328 # Initialize DCR service 

329 dcr_service = DcrService() 

330 

331 # Check if client is already registered in database 

332 registered_client = await dcr_service.get_or_register_client( 

333 gateway_id=gateway_id, 

334 gateway_name=gateway.name, 

335 issuer=issuer, 

336 redirect_uri=oauth_config.get("redirect_uri"), 

337 scopes=oauth_config.get("scopes", settings.dcr_default_scopes), 

338 db=db, 

339 ) 

340 

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

342 

343 # Decrypt the client secret for use in OAuth flow (if present - public clients may not have secrets) 

344 decrypted_secret = None 

345 if registered_client.client_secret_encrypted: 

346 # First-Party 

347 from mcpgateway.services.encryption_service import get_encryption_service 

348 

349 encryption = get_encryption_service(settings.auth_encryption_secret) 

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

351 

352 # Update oauth_config with registered credentials 

353 oauth_config["client_id"] = registered_client.client_id 

354 if decrypted_secret: 

355 oauth_config["client_secret"] = decrypted_secret 

356 

357 # Discover AS metadata to get authorization/token endpoints if not already set 

358 # Note: OAuthManager expects 'authorization_url' and 'token_url', not 'authorization_endpoint'/'token_endpoint' 

359 if not oauth_config.get("authorization_url") or not oauth_config.get("token_url"): 

360 metadata = await dcr_service.discover_as_metadata(issuer) 

361 oauth_config["authorization_url"] = metadata.get("authorization_endpoint") 

362 oauth_config["token_url"] = metadata.get("token_endpoint") 

363 logger.info(f"Discovered OAuth endpoints for {issuer}") 

364 

365 # Update gateway's oauth_config and auth_type in database for future use. 

366 # Protect sensitive fields before persistence to keep service-layer behavior consistent. 

367 gateway.oauth_config = await protect_oauth_config_for_storage(oauth_config, existing_oauth_config=gateway.oauth_config) 

368 gateway.auth_type = "oauth" # Ensure auth_type is set for OAuth-protected servers 

369 db.commit() 

370 

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

372 

373 except DcrError as dcr_err: 

374 logger.error(f"DCR failed for gateway {SecurityValidator.sanitize_log_message(gateway_id)}: {dcr_err}") 

375 raise HTTPException( 

376 status_code=500, 

377 detail=f"Dynamic Client Registration failed: {str(dcr_err)}. Please configure client_id and client_secret manually or check your OAuth server supports RFC 7591.", 

378 ) 

379 except Exception as dcr_ex: 

380 logger.error(f"Unexpected error during DCR for gateway {SecurityValidator.sanitize_log_message(gateway_id)}: {dcr_ex}") 

381 raise HTTPException(status_code=500, detail=f"Failed to register OAuth client: {str(dcr_ex)}") 

382 else: 

383 # DCR is disabled or auto-register is off 

384 logger.warning(f"Gateway {SecurityValidator.sanitize_log_message(gateway_id)} has issuer but no client_id, and DCR auto-registration is disabled") 

385 raise HTTPException( 

386 status_code=400, 

387 detail="Gateway OAuth configuration is incomplete. Please provide client_id and client_secret, or enable DCR (Dynamic Client Registration) by setting MCPGATEWAY_DCR_ENABLED=true and MCPGATEWAY_DCR_AUTO_REGISTER_ON_MISSING_CREDENTIALS=true", 

388 ) 

389 

390 # Validate required fields for OAuth flow 

391 if not oauth_config.get("client_id"): 

392 raise HTTPException(status_code=400, detail="OAuth configuration missing client_id") 

393 

394 # Initiate OAuth flow with user context (now includes PKCE from existing implementation) 

395 requester_email = _extract_user_email(current_user) 

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

397 auth_data = await oauth_manager.initiate_authorization_code_flow(gateway_id, oauth_config, app_user_email=requester_email) 

398 

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

400 

401 # Redirect user to OAuth provider 

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

403 

404 except HTTPException: 

405 raise 

406 except Exception as e: 

407 logger.error(f"Failed to initiate OAuth flow: {str(e)}") 

408 raise HTTPException(status_code=500, detail=f"Failed to initiate OAuth flow: {str(e)}") 

409 

410 

411@oauth_router.get("/callback") 

412async def oauth_callback( 

413 code: Annotated[str | None, Query(description="Authorization code from OAuth provider")] = None, 

414 state: Annotated[str, Query(description="State parameter for CSRF protection")] = ..., 

415 error: Annotated[str | None, Query(description="OAuth provider error code")] = None, 

416 error_description: Annotated[str | None, Query(description="OAuth provider error description")] = None, 

417 # Remove the gateway_id parameter requirement 

418 request: Request = None, 

419 db: Session = Depends(get_db), 

420) -> HTMLResponse: 

421 """Handle the OAuth callback and complete the authorization process. 

422 

423 This endpoint is called by the OAuth provider after the user authorizes access. 

424 It receives the authorization code and state parameters, verifies the state, 

425 retrieves the corresponding gateway configuration, and exchanges the code for an access token. 

426 

427 Args: 

428 code (str): The authorization code returned by the OAuth provider. 

429 state (str): The state parameter for CSRF protection, which encodes the gateway ID. 

430 error (str): OAuth provider error code from error callback (RFC 6749 Section 4.1.2.1). 

431 error_description (str): OAuth provider error description. 

432 request (Request): The incoming HTTP request object. 

433 db (Session): The database session dependency. 

434 

435 Returns: 

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

437 

438 Raises: 

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

440 

441 Examples: 

442 >>> import asyncio 

443 >>> asyncio.iscoroutinefunction(oauth_callback) 

444 True 

445 """ 

446 

447 try: 

448 # Get root path for URL construction 

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

450 safe_root_path = escape(str(root_path), quote=True) 

451 

452 # RFC 6749 Section 4.1.2.1: provider may return error instead of code 

453 if error: 

454 error_text = escape(error) 

455 description_text = escape(error_description or "OAuth provider returned an authorization error.") 

456 # Sanitize untrusted query parameters before logging to prevent log injection 

457 logger.warning(f"OAuth provider returned error callback: error={sanitize_for_log(error)}, description={sanitize_for_log(error_description)}") 

458 return HTMLResponse( 

459 content=f""" 

460 <!DOCTYPE html> 

461 <html> 

462 <head><title>OAuth Authorization Failed</title></head> 

463 <body> 

464 <h1>❌ OAuth Authorization Failed</h1> 

465 <p><strong>Error:</strong> {error_text}</p> 

466 <p><strong>Description:</strong> {description_text}</p> 

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

468 </body> 

469 </html> 

470 """, 

471 status_code=400, 

472 ) 

473 

474 if not code: 

475 logger.warning("OAuth callback missing authorization code") 

476 return HTMLResponse( 

477 content=f""" 

478 <!DOCTYPE html> 

479 <html> 

480 <head><title>OAuth Authorization Failed</title></head> 

481 <body> 

482 <h1>❌ OAuth Authorization Failed</h1> 

483 <p>Error: Missing authorization code in callback response.</p> 

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

485 </body> 

486 </html> 

487 """, 

488 status_code=400, 

489 ) 

490 

491 def _invalid_state_response() -> HTMLResponse: 

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

493 

494 Returns: 

495 HTMLResponse: A 400 error page describing the invalid state. 

496 """ 

497 return HTMLResponse( 

498 content=f""" 

499 <!DOCTYPE html> 

500 <html> 

501 <head><title>OAuth Authorization Failed</title></head> 

502 <body> 

503 <h1>❌ OAuth Authorization Failed</h1> 

504 <p>Error: Invalid OAuth state parameter.</p> 

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

506 </body> 

507 </html> 

508 """, 

509 status_code=400, 

510 ) 

511 

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

513 gateway_id = await oauth_manager.resolve_gateway_id_from_state(state, allow_legacy_fallback=False) 

514 if not gateway_id: 

515 logger.warning("OAuth callback received invalid or unknown state token") 

516 return _invalid_state_response() 

517 

518 # Get gateway configuration 

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

520 

521 if not gateway: 

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

523 return _invalid_state_response() 

524 

525 if not gateway.oauth_config: 

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

527 return _invalid_state_response() 

528 

529 # Complete OAuth flow 

530 

531 # RFC 8707: Add resource parameter for JWT access tokens 

532 # Must be set here in callback, not just in /authorize, because complete_authorization_code_flow 

533 # needs it for the token exchange request 

534 # Respect pre-configured resource; only derive from gateway.url if not explicitly configured 

535 oauth_config_with_resource = gateway.oauth_config.copy() 

536 if oauth_config_with_resource.get("resource"): 

537 # Preserve query for explicit config (RFC 8707 allows when necessary) 

538 existing = oauth_config_with_resource["resource"] 

539 if isinstance(existing, list): 

540 original_count = len(existing) 

541 normalized = [_normalize_resource_url(r, preserve_query=True) for r in existing] 

542 oauth_config_with_resource["resource"] = [r for r in normalized if r] 

543 if not oauth_config_with_resource["resource"] and original_count > 0: 

544 logger.warning(f"All {original_count} configured resource values were invalid and removed") 

545 else: 

546 oauth_config_with_resource["resource"] = _normalize_resource_url(existing, preserve_query=True) 

547 else: 

548 # Strip query for auto-derived (RFC 8707 SHOULD NOT) 

549 oauth_config_with_resource["resource"] = _normalize_resource_url(gateway.url) 

550 

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

552 

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

554 

555 # Return success page with option to return to admin 

556 return HTMLResponse(content=f""" 

557 <!DOCTYPE html> 

558 <html> 

559 <head> 

560 <title>OAuth Authorization Successful</title> 

561 <style> 

562 body {{ font-family: Arial, sans-serif; margin: 40px; }} 

563 .success {{ color: #059669; }} 

564 .error {{ color: #dc2626; }} 

565 .info {{ color: #2563eb; }} 

566 .button {{ 

567 display: inline-block; 

568 padding: 10px 20px; 

569 background-color: #3b82f6; 

570 color: white; 

571 text-decoration: none; 

572 border-radius: 5px; 

573 margin-top: 20px; 

574 }} 

575 .button:hover {{ background-color: #2563eb; }} 

576 </style> 

577 </head> 

578 <body> 

579 <h1 class="success">✅ OAuth Authorization Successful</h1> 

580 <div class="info"> 

581 <p><strong>Gateway:</strong> {escape(str(gateway.name))}</p> 

582 <p><strong>User ID:</strong> {escape(str(result.get("user_id", "Unknown")))}</p> 

583 <p><strong>Expires:</strong> {escape(str(result.get("expires_at", "Unknown")))}</p> 

584 <p><strong>Status:</strong> Authorization completed successfully</p> 

585 </div> 

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), quote=True)}', {{ 

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 except OAuthError as e: 

647 logger.error(f"OAuth callback failed: {str(e)}") 

648 return HTMLResponse( 

649 content=f""" 

650 <!DOCTYPE html> 

651 <html> 

652 <head> 

653 <title>OAuth Authorization Failed</title> 

654 <style> 

655 body {{ font-family: Arial, sans-serif; margin: 40px; }} 

656 .error {{ color: #dc2626; }} 

657 .button {{ 

658 display: inline-block; 

659 padding: 10px 20px; 

660 background-color: #3b82f6; 

661 color: white; 

662 text-decoration: none; 

663 border-radius: 5px; 

664 margin-top: 20px; 

665 }} 

666 .button:hover {{ background-color: #2563eb; }} 

667 </style> 

668 </head> 

669 <body> 

670 <h1 class="error">❌ OAuth Authorization Failed</h1> 

671 <p><strong>Error:</strong> {escape(str(e))}</p> 

672 <p>Please check your OAuth configuration and try again.</p> 

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

674 </body> 

675 </html> 

676 """, 

677 status_code=400, 

678 ) 

679 

680 except Exception as e: 

681 logger.error(f"Unexpected error in OAuth callback: {str(e)}") 

682 return HTMLResponse( 

683 content=f""" 

684 <!DOCTYPE html> 

685 <html> 

686 <head> 

687 <title>OAuth Authorization Failed</title> 

688 <style> 

689 body {{ font-family: Arial, sans-serif; margin: 40px; }} 

690 .error {{ color: #dc2626; }} 

691 .button {{ 

692 display: inline-block; 

693 padding: 10px 20px; 

694 background-color: #3b82f6; 

695 color: white; 

696 text-decoration: none; 

697 border-radius: 5px; 

698 margin-top: 20px; 

699 }} 

700 .button:hover {{ background-color: #2563eb; }} 

701 </style> 

702 </head> 

703 <body> 

704 <h1 class="error">❌ OAuth Authorization Failed</h1> 

705 <p><strong>Unexpected Error:</strong> {escape(str(e))}</p> 

706 <p>Please contact your administrator for assistance.</p> 

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

708 </body> 

709 </html> 

710 """, 

711 status_code=500, 

712 ) 

713 

714 

715@oauth_router.get("/status/{gateway_id}") 

716async def get_oauth_status( 

717 gateway_id: str, 

718 request: Request, 

719 current_user: dict = Depends(get_current_user_with_permissions), 

720 db: Session = Depends(get_db), 

721) -> dict: 

722 """Get OAuth status for a gateway. 

723 

724 Requires authentication and authorization to prevent information disclosure 

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

726 

727 Args: 

728 gateway_id: ID of the gateway 

729 current_user: Authenticated user (enforces authentication) 

730 db: Database session 

731 request: Request with token-scoping context. 

732 

733 Returns: 

734 OAuth status information 

735 

736 Raises: 

737 HTTPException: If not authenticated, not authorized, gateway not found, or error 

738 """ 

739 try: 

740 # Get gateway configuration 

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

742 

743 if not gateway: 

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

745 

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

747 

748 if not gateway.oauth_config: 

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

750 

751 # Get OAuth configuration info 

752 oauth_config = gateway.oauth_config 

753 grant_type = oauth_config.get("grant_type") 

754 

755 if grant_type == "authorization_code": 

756 # For now, return basic info - in a real implementation you might want to 

757 # show authorized users, token status, etc. 

758 return { 

759 "oauth_enabled": True, 

760 "grant_type": grant_type, 

761 "client_id": oauth_config.get("client_id"), 

762 "scopes": oauth_config.get("scopes", []), 

763 "authorization_url": oauth_config.get("authorization_url"), 

764 "redirect_uri": oauth_config.get("redirect_uri"), 

765 "message": "Gateway configured for Authorization Code flow", 

766 } 

767 else: 

768 return { 

769 "oauth_enabled": True, 

770 "grant_type": grant_type, 

771 "client_id": oauth_config.get("client_id"), 

772 "scopes": oauth_config.get("scopes", []), 

773 "message": f"Gateway configured for {grant_type} flow", 

774 } 

775 

776 except HTTPException: 

777 raise 

778 except Exception as e: 

779 logger.error(f"Failed to get OAuth status: {str(e)}") 

780 raise HTTPException(status_code=500, detail=f"Failed to get OAuth status: {str(e)}") 

781 

782 

783@oauth_router.post("/fetch-tools/{gateway_id}") 

784@require_permission("gateways.update") 

785async def fetch_tools_after_oauth( 

786 gateway_id: str, 

787 request: Request, 

788 current_user: EmailUserResponse = Depends(get_current_user_with_permissions), 

789 db: Session = Depends(get_db), 

790) -> Dict[str, Any]: 

791 """Fetch tools from MCP server after OAuth completion for Authorization Code flow. 

792 

793 Args: 

794 gateway_id: ID of the gateway to fetch tools for 

795 request: Incoming request used for token scope context 

796 current_user: The authenticated user fetching tools 

797 db: Database session 

798 

799 Returns: 

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

801 

802 Raises: 

803 HTTPException: If fetching tools fails 

804 """ 

805 try: 

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

807 if not gateway: 

808 raise HTTPException(status_code=404, detail=f"Gateway not found: {gateway_id}") 

809 

810 requester_email = current_user.get("email") if isinstance(current_user, dict) else getattr(current_user, "email", None) 

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

812 

813 # First-Party 

814 from mcpgateway.services.gateway_service import GatewayService 

815 

816 gateway_service = GatewayService() 

817 result = await gateway_service.fetch_tools_after_oauth(db, gateway_id, requester_email) 

818 tools_count = len(result.get("tools", [])) 

819 

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

821 

822 except HTTPException: 

823 raise 

824 except Exception as e: 

825 logger.error(f"Failed to fetch tools after OAuth for gateway {SecurityValidator.sanitize_log_message(gateway_id)}: {e}") 

826 raise HTTPException(status_code=500, detail=f"Failed to fetch tools: {str(e)}") 

827 

828 

829# ============================================================================ 

830# Admin Endpoints for DCR Management 

831# ============================================================================ 

832 

833 

834@oauth_router.get("/registered-clients") 

835async def list_registered_oauth_clients(current_user: EmailUserResponse = Depends(get_current_user_with_permissions), db: Session = Depends(get_db)) -> Dict[str, Any]: # noqa: ARG001 

836 """List all registered OAuth clients (created via DCR). 

837 

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

839 Authorization Servers using RFC 7591 Dynamic Client Registration. 

840 

841 Args: 

842 current_user: The authenticated user (admin access required) 

843 db: Database session 

844 

845 Returns: 

846 Dict containing list of registered OAuth clients with metadata 

847 

848 Raises: 

849 HTTPException: If user lacks permissions or database error occurs 

850 """ 

851 _require_admin_user(current_user) 

852 

853 try: 

854 # First-Party 

855 from mcpgateway.db import RegisteredOAuthClient 

856 

857 # Query all registered clients 

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

859 

860 # Build response 

861 clients_data = [] 

862 for client in clients: 

863 clients_data.append( 

864 { 

865 "id": client.id, 

866 "gateway_id": client.gateway_id, 

867 "issuer": client.issuer, 

868 "client_id": client.client_id, 

869 "redirect_uris": client.redirect_uris.split(",") if isinstance(client.redirect_uris, str) else client.redirect_uris, 

870 "grant_types": client.grant_types.split(",") if isinstance(client.grant_types, str) else client.grant_types, 

871 "scope": client.scope, 

872 "token_endpoint_auth_method": client.token_endpoint_auth_method, 

873 "created_at": client.created_at.isoformat() if client.created_at else None, 

874 "expires_at": client.expires_at.isoformat() if client.expires_at else None, 

875 "is_active": client.is_active, 

876 } 

877 ) 

878 

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

880 

881 except Exception as e: 

882 logger.error(f"Failed to list registered OAuth clients: {e}") 

883 raise HTTPException(status_code=500, detail=f"Failed to list registered clients: {str(e)}") 

884 

885 

886@oauth_router.get("/registered-clients/{gateway_id}") 

887async def get_registered_client_for_gateway( 

888 gateway_id: str, 

889 current_user: EmailUserResponse = Depends(get_current_user_with_permissions), 

890 db: Session = Depends(get_db), # noqa: ARG001 

891) -> Dict[str, Any]: 

892 """Get the registered OAuth client for a specific gateway. 

893 

894 Args: 

895 gateway_id: The gateway ID to lookup 

896 current_user: The authenticated user 

897 db: Database session 

898 

899 Returns: 

900 Dict containing registered client information 

901 

902 Raises: 

903 HTTPException: If gateway or registered client not found 

904 """ 

905 _require_admin_user(current_user) 

906 

907 try: 

908 # First-Party 

909 from mcpgateway.db import RegisteredOAuthClient 

910 

911 # Query registered client for this gateway 

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

913 

914 if not client: 

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

916 

917 return { 

918 "id": client.id, 

919 "gateway_id": client.gateway_id, 

920 "issuer": client.issuer, 

921 "client_id": client.client_id, 

922 "redirect_uris": client.redirect_uris.split(",") if isinstance(client.redirect_uris, str) else client.redirect_uris, 

923 "grant_types": client.grant_types.split(",") if isinstance(client.grant_types, str) else client.grant_types, 

924 "scope": client.scope, 

925 "token_endpoint_auth_method": client.token_endpoint_auth_method, 

926 "registration_client_uri": client.registration_client_uri, 

927 "created_at": client.created_at.isoformat() if client.created_at else None, 

928 "expires_at": client.expires_at.isoformat() if client.expires_at else None, 

929 "is_active": client.is_active, 

930 } 

931 

932 except HTTPException: 

933 raise 

934 except Exception as e: 

935 logger.error(f"Failed to get registered client for gateway {SecurityValidator.sanitize_log_message(gateway_id)}: {e}") 

936 raise HTTPException(status_code=500, detail=f"Failed to get registered client: {str(e)}") 

937 

938 

939@oauth_router.delete("/registered-clients/{client_id}") 

940async def delete_registered_client(client_id: str, current_user: EmailUserResponse = Depends(get_current_user_with_permissions), db: Session = Depends(get_db)) -> Dict[str, Any]: # noqa: ARG001 

941 """Delete a registered OAuth client. 

942 

943 This will revoke the client registration locally. Note: This does not automatically 

944 revoke the client at the Authorization Server. You may need to manually revoke the 

945 client using the registration_client_uri if available. 

946 

947 Args: 

948 client_id: The registered client ID to delete 

949 current_user: The authenticated user (admin access required) 

950 db: Database session 

951 

952 Returns: 

953 Dict containing success message 

954 

955 Raises: 

956 HTTPException: If client not found or deletion fails 

957 """ 

958 _require_admin_user(current_user) 

959 

960 try: 

961 # First-Party 

962 from mcpgateway.db import RegisteredOAuthClient 

963 

964 # Find the client 

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

966 

967 if not client: 

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

969 

970 issuer = client.issuer 

971 gateway_id = client.gateway_id 

972 

973 # Delete the client 

974 db.delete(client) 

975 db.commit() 

976 db.close() 

977 

978 logger.info( 

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

980 ) 

981 

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

983 

984 except HTTPException: 

985 raise 

986 except Exception as e: 

987 logger.error(f"Failed to delete registered client {client_id}: {e}") 

988 db.rollback() 

989 raise HTTPException(status_code=500, detail=f"Failed to delete registered client: {str(e)}")