main
py 1,076 lines 43.7 KB
Raw
1 import asyncio
2 import json
3 import logging
4 import re
5 import secrets
6 from datetime import UTC, datetime
7 from http.cookies import SimpleCookie
8 from urllib.parse import parse_qsl, urlencode, urljoin, urlsplit, urlunsplit
9 from uuid import UUID, uuid4
10
11 import httpx
12 from fastapi import Depends, FastAPI, Header, HTTPException, Request, Response
13 from fastapi.responses import JSONResponse
14
15 from app.models import ConnectorState, CreateConnectorRequest, GatewayPayload
16 from app.observability import configure_logging, reset_request_id, set_request_id
17 from app.runtime import ConnectorError, ConnectorManager
18 from app.settings import Settings
19
20 settings = Settings()
21 configure_logging(settings.service_name, settings.aip_environment)
22 logging.getLogger("httpx").setLevel(logging.WARNING)
23 manager = ConnectorManager(settings)
24 app = FastAPI(title="IBKR Connector", version="0.1.0")
25 logger = logging.getLogger(__name__)
26 HOP_BY_HOP_HEADERS = {
27 "connection",
28 "host",
29 "keep-alive",
30 "proxy-authenticate",
31 "proxy-authorization",
32 "te",
33 "trailer",
34 "transfer-encoding",
35 "upgrade",
36 "http2-settings",
37 }
38 EXCLUDED_RESPONSE_HEADERS = {"content-encoding", "transfer-encoding", "connection", "content-length"}
39 GATEWAY_HOSTS = {"127.0.0.1:5000", "localhost:5000"}
40 IBKR_BROWSER_REDIRECT_HOSTS = {"api.ibkr.com", "www.interactivebrokers.com", "interactivebrokers.com"}
41 SERVER_SIDE_POST_REDIRECT_PATHS = {"/authenticator", "/report"}
42 SSO_RECOVERY_PATHS = {
43 "/Authenticator": "/sso/Authenticator",
44 "/Dispatcher": "/sso/Dispatcher",
45 }
46 LOGIN_CONTEXT_COOKIE = "aip_ibkr_login_context"
47 GATEWAY_BROWSER_IDENTITY_COOKIES = {"USERID"}
48 PROXY_TARGET_GATEWAY = "GATEWAY"
49 PROXY_TARGET_IBKR_UPSTREAM = "IBKR_UPSTREAM"
50 ROUTE_TYPE_LOGIN = "LOGIN"
51 ROUTE_TYPE_RECOVERY = "RECOVERY"
52 GATEWAY_LOGIN_STARTUP_ATTEMPTS = 8
53
54
55 @app.middleware("http")
56 async def correlation_id_middleware(request: Request, call_next):
57 candidate = request.headers.get("X-Request-ID") or request.headers.get("X-Correlation-Id")
58 request_id = candidate.strip() if candidate else ""
59 if not request_id or len(request_id) > 120 or not all(
60 character.isalnum() or character in "-_.:/" for character in request_id
61 ):
62 request_id = str(uuid4())
63 token = set_request_id(request_id)
64 try:
65 response = await call_next(request)
66 finally:
67 reset_request_id(token)
68 response.headers["X-Request-ID"] = request_id
69 response.headers["X-Correlation-Id"] = request_id
70 return response
71
72
73 def require_internal_token(x_internal_token: str | None = Header(default=None)) -> None:
74 if not settings.aip_connector_require_internal_token:
75 return
76 if not settings.aip_internal_token or x_internal_token != settings.aip_internal_token:
77 raise HTTPException(status_code=403, detail="Internal connector token is required.")
78
79
80 def user_header(x_aip_user_id: str = Header(alias="X-AIP-User-Id")) -> UUID:
81 try:
82 return UUID(x_aip_user_id)
83 except ValueError as exc:
84 raise HTTPException(status_code=400, detail="X-AIP-User-Id must be a UUID.") from exc
85
86
87 @app.exception_handler(ConnectorError)
88 def connector_error_handler(_: Request, exc: ConnectorError) -> JSONResponse:
89 status = 403 if exc.code == "CONNECTOR_FORBIDDEN" else 404 if exc.code == "CONNECTOR_NOT_FOUND" else 409
90 return JSONResponse(status_code=status, content={"code": exc.code, "message": exc.message})
91
92
93 @app.get("/health")
94 def health() -> dict[str, str]:
95 return {"status": "ok", "service": settings.service_name}
96
97
98 @app.get("/actuator/health/readiness")
99 def readiness() -> dict[str, str]:
100 return {"status": "UP"}
101
102
103 @app.get("/actuator/health/liveness")
104 def liveness() -> dict[str, str]:
105 return {"status": "UP"}
106
107
108 @app.post("/internal/connectors", dependencies=[Depends(require_internal_token)])
109 async def create_connector(request: CreateConnectorRequest):
110 session = await manager.create(request.connector_id, request.user_id)
111 return manager.response(session)
112
113
114 @app.get("/internal/connectors/{connector_id}/status", dependencies=[Depends(require_internal_token)])
115 async def connector_status(connector_id: UUID, user_id: UUID = Depends(user_header)):
116 session = await manager.status(connector_id, user_id)
117 return manager.response(session)
118
119
120 @app.get("/internal/connectors/{connector_id}/login", dependencies=[Depends(require_internal_token)])
121 async def connector_login(connector_id: UUID, user_id: UUID = Depends(user_header)):
122 session = await manager.status(connector_id, user_id)
123
124 if session.auth_status == ConnectorState.SESSION_EXPIRED or (
125 session.auth_status == ConnectorState.AUTHENTICATION_REQUIRED
126 and session.authenticated_at is not None
127 ):
128 session = await manager.restart(connector_id, user_id)
129
130 return {
131 "connectorId": connector_id,
132 "loginUrl": manager.login_url(session),
133 "authStatus": session.auth_status,
134 }
135
136
137 @app.post("/internal/connectors/{connector_id}/stop", dependencies=[Depends(require_internal_token)])
138 async def stop_connector(connector_id: UUID, user_id: UUID = Depends(user_header)):
139 session = await manager.stop(connector_id, user_id)
140 return manager.response(session)
141
142
143 @app.post("/internal/connectors/{connector_id}/restart", dependencies=[Depends(require_internal_token)])
144 async def restart_connector(connector_id: UUID, user_id: UUID = Depends(user_header)):
145 session = await manager.restart(connector_id, user_id)
146 return manager.response(session)
147
148
149 @app.get("/internal/connectors/{connector_id}/accounts", dependencies=[Depends(require_internal_token)])
150 async def accounts(connector_id: UUID, user_id: UUID = Depends(user_header)) -> GatewayPayload:
151 return GatewayPayload(data=await manager.gateway_get(connector_id, user_id, "/portfolio/accounts"))
152
153
154 @app.get("/internal/connectors/{connector_id}/positions", dependencies=[Depends(require_internal_token)])
155 async def positions(
156 connector_id: UUID, account_id: str, page: int = 0, user_id: UUID = Depends(user_header)
157 ) -> GatewayPayload:
158 return GatewayPayload(
159 data=await manager.gateway_get(connector_id, user_id, f"/portfolio/{account_id}/positions/{page}")
160 )
161
162
163 @app.get("/internal/connectors/{connector_id}/instruments/{conid}", dependencies=[Depends(require_internal_token)])
164 async def instrument(connector_id: UUID, conid: str, user_id: UUID = Depends(user_header)) -> GatewayPayload:
165 return GatewayPayload(data=await manager.gateway_get(connector_id, user_id, f"/iserver/contract/{conid}/info"))
166
167
168 @app.get("/internal/connectors/{connector_id}/ledger", dependencies=[Depends(require_internal_token)])
169 async def ledger(connector_id: UUID, account_id: str, user_id: UUID = Depends(user_header)) -> GatewayPayload:
170 return GatewayPayload(data=await manager.gateway_get(connector_id, user_id, f"/portfolio/{account_id}/ledger"))
171
172
173 @app.api_route("/internal/connectors/{connector_id}/{path:path}", methods=["POST", "PUT", "PATCH", "DELETE"])
174 async def deny_internal_mutations() -> None:
175 raise HTTPException(status_code=404, detail="Trading and mutation routes are not exposed.")
176
177
178 @app.api_route(
179 "/connector-sessions/{connector_id}/login/{path:path}",
180 methods=["GET", "POST", "PUT", "HEAD", "OPTIONS"],
181 )
182 @app.api_route("/connector-sessions/{connector_id}/login", methods=["GET", "POST", "PUT", "HEAD", "OPTIONS"])
183 async def login_proxy(connector_id: UUID, request: Request, path: str = "") -> Response:
184 session = _require_login_session(connector_id, request, ROUTE_TYPE_LOGIN)
185 target_path = path or "sso/Login"
186 return await _proxy_login_request(connector_id, session, request, target_path)
187
188
189 @app.get("/connector-sessions/{connector_id}/auth-status")
190 async def browser_auth_status(connector_id: UUID, request: Request) -> JSONResponse:
191 session = _require_login_session(connector_id, request, ROUTE_TYPE_LOGIN)
192
193 cookie_header = _gateway_cookie_header(request, connector_id, session)
194 if cookie_header:
195 manager.remember_gateway_request_cookies(session, cookie_header)
196
197 await manager.refresh_status(session)
198
199 return JSONResponse({
200 "authenticated": session.auth_status == ConnectorState.CONNECTED
201 })
202
203
204 @app.get("/connector-sessions/{connector_id}/completion-monitor.js")
205 async def browser_completion_monitor(connector_id: UUID, request: Request) -> Response:
206 _require_login_session(connector_id, request, ROUTE_TYPE_LOGIN)
207 return Response(content=_completion_monitor_javascript(), media_type="application/javascript")
208
209
210 @app.api_route(
211 "/connector-sessions/{connector_id}/upstream/{upstream_host}/{path:path}",
212 methods=["GET", "POST", "PUT", "HEAD", "OPTIONS"],
213 )
214 @app.api_route(
215 "/connector-sessions/{connector_id}/upstream/{upstream_host}",
216 methods=["GET", "POST", "PUT", "HEAD", "OPTIONS"],
217 )
218 async def upstream_login_proxy(
219 connector_id: UUID, upstream_host: str, request: Request, path: str = ""
220 ) -> Response:
221 session = _require_login_session(connector_id, request, PROXY_TARGET_IBKR_UPSTREAM)
222 upstream_origin = _upstream_origin(upstream_host)
223 if not upstream_origin:
224 raise HTTPException(status_code=404, detail="Connector upstream route was not found.")
225 return await _proxy_login_request(connector_id, session, request, path, upstream_origin=upstream_origin)
226
227
228 @app.api_route("/connector-sessions/{path:path}", methods=["GET", "POST", "PUT", "HEAD", "OPTIONS"])
229 async def escaped_login_proxy(request: Request, path: str = "") -> Response:
230 connector_id, browser_path, target_path = _resolve_escaped_login_path(request, path)
231 session = _require_login_session(connector_id, request, ROUTE_TYPE_RECOVERY)
232 if browser_path != target_path:
233 _trace_recovery_mapping(connector_id, request.method, browser_path, target_path)
234 return await _proxy_login_request(connector_id, session, request, target_path)
235
236
237 async def _proxy_login_request(
238 connector_id: UUID, session, request: Request, target_path: str, upstream_origin: str | None = None
239 ) -> Response:
240 if not settings.gateway_api_base_url:
241 raise HTTPException(status_code=503, detail="AIP_IBKR_GATEWAY_BASE_URL must be configured.")
242 if _is_trading_path(target_path):
243 raise HTTPException(status_code=404, detail="Trading routes are not exposed.")
244 proxy_target_type = PROXY_TARGET_IBKR_UPSTREAM if upstream_origin else PROXY_TARGET_GATEWAY
245 target_origin = upstream_origin or settings.gateway_api_base_url.rsplit("/v1/api", 1)[0].rstrip("/")
246 target = f"{target_origin}/{target_path.lstrip('/')}"
247 outbound_headers = {
248 k: v
249 for k, v in request.headers.items()
250 if k.lower() not in HOP_BY_HOP_HEADERS and k.lower() != "content-length"
251 }
252 # Cookies are rebuilt below for the destination's scoped session. Leaving
253 # the browser header here would leak stale cookies whenever the rebuilt
254 # header is intentionally empty (notably the first request after reset).
255 outbound_headers.pop("cookie", None)
256 _rewrite_origin_headers(request, connector_id, target_path, outbound_headers, target_origin)
257 reset_gateway_browser_cookies = (
258 proxy_target_type == PROXY_TARGET_GATEWAY
259 and target_path.lstrip("/") == "sso/Login"
260 and session.gateway_browser_reset_pending
261 )
262 if reset_gateway_browser_cookies:
263 # Include names which may only exist in the browser. They are all
264 # scoped to this connector's Gateway login route, never an external
265 # SSO upstream route.
266 session.gateway_cookie_names_to_clear.update(
267 name for name in _gateway_request_cookie_names(request, connector_id)
268 if name not in GATEWAY_BROWSER_IDENTITY_COOKIES
269 )
270 cookie_header = (
271 _upstream_cookie_header(request, connector_id, session)
272 if proxy_target_type == PROXY_TARGET_IBKR_UPSTREAM
273 else _gateway_cookie_header(
274 request,
275 connector_id,
276 session,
277 exclude_browser_cookies=reset_gateway_browser_cookies,
278 )
279 )
280 if cookie_header:
281 outbound_headers["cookie"] = cookie_header
282 if proxy_target_type == PROXY_TARGET_GATEWAY:
283 manager.remember_gateway_request_cookies(session, cookie_header)
284 request_cookie_names = (
285 _cookie_header_names(cookie_header)
286 if proxy_target_type == PROXY_TARGET_IBKR_UPSTREAM
287 else _gateway_request_cookie_names(request, connector_id)
288 )
289 request_body = await request.body()
290 try:
291 async with httpx.AsyncClient(verify=settings.aip_ibkr_gateway_tls_verify, timeout=30) as client:
292 attempts = (
293 GATEWAY_LOGIN_STARTUP_ATTEMPTS
294 if proxy_target_type == PROXY_TARGET_GATEWAY and target_path.lstrip("/") == "sso/Login"
295 else 1
296 )
297 for attempt in range(attempts):
298 try:
299 proxied = await client.request(
300 request.method,
301 target,
302 params={k: v for k, v in request.query_params.items() if k != "loginToken"},
303 content=request_body,
304 headers=outbound_headers,
305 follow_redirects=False,
306 )
307 break
308 except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
309 logger.warning(
310 "ibkr_login_proxy_http_error connectorId=%s exceptionClass=%s exception=%r "
311 "target=%s targetPath=%s proxyTargetType=%s attempt=%s method=%s",
312 connector_id,
313 type(exc).__name__,
314 exc,
315 target,
316 target_path,
317 proxy_target_type,
318 attempt + 1,
319 request.method,
320 )
321 if attempt + 1 == attempts:
322 raise
323 logger.info(
324 "ibkr_login_gateway_startup_wait connectorId=%s attempt=%s",
325 connector_id,
326 attempt + 1,
327 )
328 await asyncio.sleep(min(0.25 * (attempt + 1), 1.0))
329 server_side_redirect = _server_side_post_redirect_target(request.method, target_path, proxied)
330 if proxy_target_type == PROXY_TARGET_GATEWAY and server_side_redirect:
331 manager.remember_gateway_response_cookies(session, proxied.headers.get_list("set-cookie"))
332 redirect_origin, redirect_path, redirect_query = server_side_redirect
333 redirected_headers = {
334 k: v
335 for k, v in request.headers.items()
336 if k.lower() not in HOP_BY_HOP_HEADERS and k.lower() != "content-length"
337 }
338 _rewrite_origin_headers(request, connector_id, redirect_path, redirected_headers, redirect_origin)
339 if redirected_cookie_header := _gateway_cookie_header(request, connector_id, session):
340 redirected_headers["cookie"] = redirected_cookie_header
341 proxied = await client.request(
342 request.method,
343 f"{redirect_origin}/{redirect_path.lstrip('/')}",
344 params=_params_without_login_token(redirect_query),
345 content=request_body,
346 headers=redirected_headers,
347 follow_redirects=False,
348 )
349 _trace_server_side_post_redirect(
350 connector_id,
351 request.method,
352 redirect_origin,
353 redirect_path,
354 proxied.status_code,
355 )
356 proxy_target_type = PROXY_TARGET_IBKR_UPSTREAM
357 target_origin = redirect_origin
358 target_path = redirect_path
359 request_cookie_names = _cookie_header_names(redirected_headers.get("cookie", ""))
360 except httpx.HTTPError as exc:
361 logger.warning(
362 "ibkr_login_proxy_http_error connectorId=%s exceptionClass=%s exception=%r "
363 "target=%s targetPath=%s proxyTargetType=%s attempt=%s method=%s",
364 connector_id,
365 type(exc).__name__,
366 exc,
367 target,
368 target_path,
369 proxy_target_type,
370 attempt + 1,
371 request.method,
372 )
373 raise HTTPException(status_code=503, detail="IBKR Gateway login UI is unavailable.") from exc
374 prefix = f"/connector-sessions/{connector_id}/login"
375 cookie_path = _proxy_cookie_path(connector_id, proxy_target_type, target_origin)
376 headers = []
377 if reset_gateway_browser_cookies:
378 # These headers deliberately precede the fresh Gateway Set-Cookie
379 # headers below, so a newly-issued cookie with the same name wins.
380 cookie_path = _proxy_cookie_path(connector_id, PROXY_TARGET_GATEWAY, target_origin)
381 headers.extend(
382 ("set-cookie", _expired_cookie_header(name, cookie_path))
383 for name in sorted(session.gateway_cookie_names_to_clear)
384 )
385 rewritten_location = None
386 for name, value in proxied.headers.multi_items():
387 lower = name.lower()
388 if lower in EXCLUDED_RESPONSE_HEADERS:
389 continue
390 if lower == "location":
391 value = _rewrite_location(value, prefix, session.login_token)
392 rewritten_location = value
393 elif lower == "set-cookie":
394 value = _rewrite_set_cookie(value, cookie_path)
395 headers.append((name, value))
396 _trace_login_proxy(
397 connector_id,
398 request.method,
399 target_path,
400 request_cookie_names,
401 proxied,
402 rewritten_location,
403 proxy_target_type,
404 target_origin,
405 )
406 if _is_repeated_redirect(request, proxied, rewritten_location):
407 _trace_redirect_loop(connector_id, request.method, target_path, proxied, rewritten_location)
408 raise HTTPException(status_code=502, detail="IBKR Gateway login redirect loop detected.")
409 if proxy_target_type == PROXY_TARGET_GATEWAY:
410 manager.remember_gateway_response_cookies(session, proxied.headers.get_list("set-cookie"))
411 if reset_gateway_browser_cookies:
412 session.gateway_cookie_names_to_clear.clear()
413 session.gateway_browser_reset_pending = False
414 content = _rewrite_content(proxied, prefix, session.login_token, target_path)
415 top_level_document = _is_top_level_document_request(request)
416 upstream_content_type = proxied.headers.get("content-type", "")
417 content = _inject_auth_completion_monitor(
418 content, upstream_content_type, connector_id, top_level_document=top_level_document
419 )
420 response = Response(content=content, status_code=proxied.status_code)
421 for name, value in headers:
422 response.headers.append(name, value)
423 if top_level_document and not upstream_content_type:
424 response.headers["content-type"] = "text/html; charset=utf-8"
425 response.set_cookie(
426 key=_login_cookie_name(connector_id),
427 value=session.login_token,
428 httponly=True,
429 samesite="lax",
430 path="/connector-sessions",
431 )
432 response.set_cookie(
433 key=LOGIN_CONTEXT_COOKIE,
434 value=str(connector_id),
435 httponly=True,
436 samesite="lax",
437 path="/connector-sessions",
438 )
439 return response
440
441
442 def _is_top_level_document_request(request: Request) -> bool:
443 return request.headers.get("sec-fetch-dest", "").lower() == "document" or request.headers.get(
444 "sec-fetch-mode", ""
445 ).lower() == "navigate"
446
447
448 def _inject_auth_completion_monitor(
449 content: bytes, content_type: str, connector_id: UUID, *, top_level_document: bool
450 ) -> bytes:
451 if not top_level_document or (content_type and "text/html" not in content_type.lower()):
452 return content
453 text = content.decode("utf-8", errors="replace")
454 script_url = f"/connector-sessions/{connector_id}/completion-monitor.js"
455 script = f'<script src="{script_url}"></script>'
456 return (text.replace("</body>", script + "</body>") if "</body>" in text else text + script).encode("utf-8")
457
458
459 def _completion_monitor_javascript() -> str:
460 return """(function(){
461 const status=document.currentScript.src.replace('/completion-monitor.js','/auth-status');
462 const check=async()=>{try{const r=await fetch(status,{credentials:'same-origin',cache:'no-store'});const s=await r.json();
463 if(s.authenticated){if(window.opener&&!window.opener.closed){window.opener.postMessage({type:'aip:ibkr-authenticated'},window.location.origin);window.close();}else{document.body.textContent='Authentication completed. You may close this window.';}return;}}catch(_){}setTimeout(check,5000);};setTimeout(check,5000);
464 })();"""
465
466
467 def _login_cookie(request: Request, connector_id: UUID) -> str:
468 parsed = SimpleCookie(request.headers.get("cookie", ""))
469 morsel = parsed.get(_login_cookie_name(connector_id))
470 return morsel.value if morsel else ""
471
472
473 def _require_login_session(connector_id: UUID, request: Request, route_type: str):
474 metadata = _login_context_metadata(connector_id, request, route_type)
475 _trace_login_context(metadata)
476 token = _selected_login_token(connector_id, request)
477 try:
478 return manager.require_login_token(connector_id, token)
479 except ConnectorError as exc:
480 if exc.code == "LOGIN_TOKEN_INVALID":
481 _trace_login_context_failure(metadata)
482 raise
483
484
485 def _login_context_metadata(connector_id: UUID, request: Request, route_type: str) -> dict[str, object]:
486 parsed = SimpleCookie(request.headers.get("cookie", ""))
487 query_token = request.query_params.get("loginToken", "")
488 cookie_name = _login_cookie_name(connector_id)
489 cookie_token = parsed.get(cookie_name).value if parsed.get(cookie_name) else ""
490 selected_token = _selected_login_token(connector_id, request)
491 session = manager.sessions.get(connector_id)
492 server_token = session.login_token if session else ""
493 login_token_matches = bool(selected_token and server_token and secrets.compare_digest(server_token, selected_token))
494 return {
495 "connectorId": connector_id,
496 "routeType": route_type,
497 "method": request.method,
498 "loginTokenQueryPresent": bool(query_token),
499 "loginTokenCookiePresent": bool(cookie_token),
500 "loginContextCookiePresent": LOGIN_CONTEXT_COOKIE in parsed,
501 "serverSessionPresent": session is not None,
502 "serverLoginTokenPresent": bool(server_token),
503 "loginTokenMatches": login_token_matches,
504 }
505
506
507 def _selected_login_token(connector_id: UUID, request: Request) -> str:
508 query_token = request.query_params.get("loginToken", "")
509 cookie_token = _login_cookie(request, connector_id)
510 session = manager.sessions.get(connector_id)
511 server_token = session.login_token if session else ""
512 if cookie_token and server_token and secrets.compare_digest(cookie_token, server_token):
513 return cookie_token
514 return query_token or cookie_token
515
516
517 def _trace_login_context(metadata: dict[str, object]) -> None:
518 logger.info(
519 "ibkr_login_context connectorId=%s routeType=%s method=%s loginTokenQueryPresent=%s "
520 "loginTokenCookiePresent=%s loginContextCookiePresent=%s serverSessionPresent=%s "
521 "serverLoginTokenPresent=%s loginTokenMatches=%s",
522 metadata["connectorId"],
523 metadata["routeType"],
524 metadata["method"],
525 metadata["loginTokenQueryPresent"],
526 metadata["loginTokenCookiePresent"],
527 metadata["loginContextCookiePresent"],
528 metadata["serverSessionPresent"],
529 metadata["serverLoginTokenPresent"],
530 metadata["loginTokenMatches"],
531 )
532
533
534 def _trace_login_context_failure(metadata: dict[str, object]) -> None:
535 logger.warning(
536 "ibkr_login_context_invalid connectorId=%s routeType=%s method=%s loginTokenQueryPresent=%s "
537 "loginTokenCookiePresent=%s loginContextCookiePresent=%s serverSessionPresent=%s "
538 "serverLoginTokenPresent=%s loginTokenMatches=%s",
539 metadata["connectorId"],
540 metadata["routeType"],
541 metadata["method"],
542 metadata["loginTokenQueryPresent"],
543 metadata["loginTokenCookiePresent"],
544 metadata["loginContextCookiePresent"],
545 metadata["serverSessionPresent"],
546 metadata["serverLoginTokenPresent"],
547 metadata["loginTokenMatches"],
548 )
549
550
551 def _login_cookie_name(connector_id: UUID) -> str:
552 return f"aip_ibkr_login_{connector_id.hex}"
553
554
555 def _gateway_cookie_header(request: Request, connector_id: UUID, session, *, exclude_browser_cookies: bool = False) -> str:
556 parsed = SimpleCookie(request.headers.get("cookie", ""))
557 parsed.pop(_login_cookie_name(connector_id), None)
558 parsed.pop(LOGIN_CONTEXT_COOKIE, None)
559 # A browser value can have been replaced by an SSO redirect, so prefer it
560 # over the server-side fallback jar.
561 values = dict(session.gateway_cookies or {})
562 browser_values = {name: morsel.value for name, morsel in parsed.items()}
563 if exclude_browser_cookies:
564 # USERID is an IBKR browser identity association required by the
565 # mobile-approval handoff, not a destroyed local Gateway session.
566 browser_values = {
567 name: value for name, value in browser_values.items()
568 if name in GATEWAY_BROWSER_IDENTITY_COOKIES
569 }
570 values.update(browser_values)
571 return "; ".join(f"{name}={value}" for name, value in values.items())
572
573
574 def _expired_cookie_header(name: str, path: str) -> str:
575 response = Response()
576 response.delete_cookie(key=name, path=path, samesite="lax")
577 return response.headers["set-cookie"]
578
579
580 def _upstream_cookie_header(request: Request, connector_id: UUID, session) -> str:
581 parsed = SimpleCookie(request.headers.get("cookie", ""))
582 parsed.pop(_login_cookie_name(connector_id), None)
583 parsed.pop(LOGIN_CONTEXT_COOKIE, None)
584 for name, value in (session.gateway_cookies or {}).items():
585 # Gateway and external SSO both issue same-named cookies (notably
586 # JSESSIONID). Remove only an actual Gateway value, not a newer SSO
587 # value that shares its name.
588 morsel = parsed.get(name)
589 if morsel and morsel.value == value:
590 parsed.pop(name, None)
591 return "; ".join(f"{name}={morsel.value}" for name, morsel in parsed.items())
592
593
594 def _cookie_header_names(cookie_header: str) -> list[str]:
595 parsed = SimpleCookie(cookie_header)
596 return sorted(parsed.keys())
597
598
599 def _gateway_request_cookie_names(request: Request, connector_id: UUID) -> list[str]:
600 parsed = SimpleCookie(request.headers.get("cookie", ""))
601 parsed.pop(_login_cookie_name(connector_id), None)
602 parsed.pop(LOGIN_CONTEXT_COOKIE, None)
603 return sorted(parsed.keys())
604
605
606 def _resolve_escaped_login_path(request: Request, path: str) -> tuple[UUID, str, str]:
607 segments = [segment for segment in path.split("/") if segment]
608 if segments:
609 try:
610 connector_id = UUID(segments[0])
611 target_path = "/".join(segments[1:])
612 if target_path.startswith("login/"):
613 target_path = target_path.removeprefix("login/")
614 if target_path:
615 browser_path = "/" + target_path.lstrip("/")
616 return connector_id, browser_path, _sso_recovery_path(browser_path) or target_path
617 except ValueError:
618 pass
619 connector_id = _login_context_connector_id(request)
620 if not connector_id or not path:
621 raise HTTPException(status_code=404, detail="Connector login route was not found.")
622 browser_path = "/" + path.lstrip("/")
623 return connector_id, browser_path, _sso_recovery_path(browser_path) or path
624
625
626 def _sso_recovery_path(browser_path: str) -> str | None:
627 return SSO_RECOVERY_PATHS.get("/" + browser_path.lstrip("/"))
628
629
630 def _trace_recovery_mapping(connector_id: UUID, method: str, browser_path: str, gateway_path: str) -> None:
631 logger.info(
632 "ibkr_login_recovery_mapping connectorId=%s routeType=%s recoveryMapping=SSO "
633 "browserPath=%s gatewayPath=%s method=%s",
634 connector_id,
635 ROUTE_TYPE_RECOVERY,
636 "/" + browser_path.lstrip("/"),
637 "/" + gateway_path.lstrip("/"),
638 method,
639 )
640
641
642 def _login_context_connector_id(request: Request) -> UUID | None:
643 parsed = SimpleCookie(request.headers.get("cookie", ""))
644 morsel = parsed.get(LOGIN_CONTEXT_COOKIE)
645 if not morsel:
646 return None
647 try:
648 return UUID(morsel.value)
649 except ValueError:
650 return None
651
652
653 def _rewrite_origin_headers(
654 request: Request,
655 connector_id: UUID,
656 target_path: str,
657 outbound_headers: dict[str, str],
658 target_origin: str,
659 ) -> None:
660 public_origin = f"{request.url.scheme}://{request.url.netloc}"
661 for name in list(outbound_headers.keys()):
662 if name.lower() == "origin" and outbound_headers[name] == public_origin:
663 outbound_headers[name] = target_origin
664 if name.lower() == "referer":
665 outbound_headers[name] = _rewrite_referer(
666 outbound_headers[name], public_origin, connector_id, target_path, target_origin
667 )
668
669
670 def _rewrite_referer(
671 value: str, public_origin: str, connector_id: UUID, fallback_path: str, target_origin: str
672 ) -> str:
673 split = urlsplit(value)
674 referer_origin = urlunsplit((split.scheme, split.netloc, "", "", ""))
675 if referer_origin != public_origin:
676 return value
677 query = _strip_login_token(split.query)
678 gateway_path = _gateway_path_from_public_path(split.path, connector_id) or fallback_path
679 return f"{target_origin}/{gateway_path.lstrip('/')}" + (f"?{query}" if query else "")
680
681
682 def _gateway_path_from_public_path(path: str, connector_id: UUID) -> str | None:
683 login_prefix = f"/connector-sessions/{connector_id}/login"
684 upstream_prefix = f"/connector-sessions/{connector_id}/upstream/"
685 connector_prefix = f"/connector-sessions/{connector_id}"
686 if path == login_prefix:
687 return "sso/Login"
688 if path.startswith(login_prefix + "/"):
689 return path.removeprefix(login_prefix + "/")
690 if path.startswith(upstream_prefix):
691 parts = path.removeprefix(upstream_prefix).split("/", 1)
692 return parts[1] if len(parts) > 1 else ""
693 if path.startswith(connector_prefix + "/"):
694 return path.removeprefix(connector_prefix + "/")
695 if path.startswith("/connector-sessions/"):
696 return path.removeprefix("/connector-sessions/")
697 return None
698
699
700 def _strip_login_token(query: str) -> str:
701 return urlencode([(name, value) for name, value in parse_qsl(query, keep_blank_values=True) if name != "loginToken"])
702
703
704 def _params_without_login_token(query: str) -> dict[str, str]:
705 return {name: value for name, value in parse_qsl(query, keep_blank_values=True) if name != "loginToken"}
706
707
708 def _gateway_origin() -> str:
709 split = urlsplit(settings.gateway_api_base_url)
710 return urlunsplit((split.scheme, split.netloc, "", "", ""))
711
712
713 def _upstream_origin(upstream_host: str) -> str | None:
714 normalized = upstream_host.strip().lower()
715 if (
716 not normalized
717 or normalized.endswith(".")
718 or any(marker in normalized for marker in (":", "/", "\\", "@", "%"))
719 ):
720 return None
721 if normalized in IBKR_BROWSER_REDIRECT_HOSTS:
722 return f"https://{normalized}"
723 if normalized in _gateway_hosts():
724 configured = urlsplit(settings.gateway_api_base_url)
725 scheme = configured.scheme or "https"
726 return f"{scheme}://{normalized}"
727 return None
728
729
730 def _is_trading_path(path: str) -> bool:
731 normalized = "/" + path.lower().lstrip("/")
732 blocked_terms = ("/orders", "/iserver/account/orders", "/iserver/reply", "/iserver/order", "/trades")
733 return any(term in normalized for term in blocked_terms)
734
735
736 def _server_side_post_redirect_target(
737 method: str, current_path: str, response: httpx.Response
738 ) -> tuple[str, str, str] | None:
739 if method.upper() != "POST" or response.status_code not in {301, 302, 303}:
740 return None
741 if "/" + current_path.lower().lstrip("/") not in SERVER_SIDE_POST_REDIRECT_PATHS:
742 return None
743 location = response.headers.get("location", "")
744 split = urlsplit(location)
745 if not split.scheme or not split.netloc:
746 return None
747 upstream_origin = _upstream_origin(split.netloc)
748 if not upstream_origin:
749 return None
750 if urlsplit(upstream_origin).netloc not in IBKR_BROWSER_REDIRECT_HOSTS:
751 return None
752 return upstream_origin, split.path or "/", split.query
753
754
755 def _set_cookie_names(headers: httpx.Headers) -> list[str]:
756 names = set()
757 for header in headers.get_list("set-cookie"):
758 parsed = SimpleCookie(header)
759 names.update(parsed.keys())
760 return sorted(names)
761
762
763 def _trace_login_proxy(
764 connector_id: UUID,
765 method: str,
766 proxied_path: str,
767 cookie_request_names: list[str],
768 response: httpx.Response,
769 rewritten_location: str | None = None,
770 proxy_target_type: str = PROXY_TARGET_GATEWAY,
771 target_origin: str = "",
772 ) -> None:
773 location = response.headers.get("location", "")
774 location_split = urlsplit(location)
775 rewritten_split = urlsplit(rewritten_location or "")
776 target_split = urlsplit(target_origin)
777 logger.info(
778 "ibkr_login_trace timestamp=%s connectorId=%s method=%s proxyTargetType=%s upstreamHost=%s "
779 "upstreamPath=%s proxiedPath=%s upstreamStatus=%s "
780 "contentType=%s locationPresent=%s upstreamLocationHost=%s upstreamLocationPath=%s "
781 "rewrittenLocationPath=%s setCookieNames=%s cookieRequestNames=%s",
782 datetime.now(UTC).isoformat(),
783 connector_id,
784 method,
785 proxy_target_type,
786 target_split.netloc,
787 "/" + proxied_path.lstrip("/"),
788 "/" + proxied_path.lstrip("/"),
789 response.status_code,
790 response.headers.get("content-type", ""),
791 bool(response.headers.get("location")),
792 location_split.netloc,
793 location_split.path,
794 rewritten_split.path,
795 ",".join(_set_cookie_names(response.headers)),
796 ",".join(cookie_request_names),
797 )
798 if proxied_path.lstrip("/") == "sso/Authenticator":
799 _trace_authenticator_state(connector_id, response)
800
801
802 def _trace_authenticator_state(connector_id: UUID, response: httpx.Response) -> None:
803 """Log only structural MFA state, never response values or credentials."""
804 content_type = response.headers.get("content-type", "").lower()
805 is_json = "json" in content_type
806 payload = None
807 if is_json:
808 try:
809 payload = json.loads(response.content)
810 except (UnicodeDecodeError, json.JSONDecodeError):
811 pass
812 state_fields = {
813 "challenge",
814 "challengecreated",
815 "challengecomplete",
816 "challengecompleted",
817 "push",
818 "pushchallenge",
819 "pushcomplete",
820 "pushcompleted",
821 "approved",
822 "complete",
823 "completed",
824 "success",
825 "authenticated",
826 "authres",
827 "isibkey",
828 "pushsent",
829 "dispatcher",
830 "dispatch",
831 "forwardto",
832 "status",
833 }
834 present_fields: list[str] = []
835 boolean_states: list[str] = []
836 value_categories: list[str] = []
837 if isinstance(payload, dict):
838 for key, value in payload.items():
839 normalized = key.replace("_", "").replace("-", "").lower()
840 if normalized not in state_fields:
841 continue
842 present_fields.append(normalized)
843 if normalized == "authres":
844 value_categories.append("authres=" + _classify_auth_res(value))
845 elif isinstance(value, bool):
846 boolean_states.append(f"{normalized}={value}")
847 logger.info(
848 "ibkr_authenticator_state connectorId=%s upstreamStatus=%s jsonResponse=%s jsonObject=%s "
849 "stateFields=%s booleanStates=%s valueCategories=%s",
850 connector_id,
851 response.status_code,
852 is_json,
853 isinstance(payload, dict),
854 ",".join(sorted(present_fields)),
855 ",".join(sorted(boolean_states)),
856 ",".join(sorted(value_categories)),
857 )
858
859
860 def _classify_auth_res(value: object) -> str:
861 """Classify the Gateway completion signal without retaining its value."""
862 if not isinstance(value, str):
863 return "OPAQUE"
864 if value == "true":
865 return "TRUE_STRING"
866 if value == "false":
867 return "FALSE_STRING"
868 normalized = value.lower()
869 if normalized in {"success", "successful", "approved", "complete", "completed", "ok"}:
870 return "SUCCESS_WORD"
871 if normalized in {"pending", "waiting", "wait", "challenge", "pushsent"}:
872 return "PENDING_WORD"
873 if normalized in {"failed", "failure", "error", "rejected", "denied", "cancelled", "canceled"}:
874 return "FAILURE_WORD"
875 if value == "":
876 return "EMPTY_STRING"
877 if len(value) <= 64 and value.isprintable() and re.fullmatch(r"[A-Za-z0-9._:-]+", value):
878 return "OTHER_PROTOCOL"
879 return "OPAQUE"
880
881
882 def _trace_redirect_loop(
883 connector_id: UUID,
884 method: str,
885 proxied_path: str,
886 response: httpx.Response,
887 rewritten_location: str | None,
888 ) -> None:
889 location_split = urlsplit(response.headers.get("location", ""))
890 rewritten_split = urlsplit(rewritten_location or "")
891 logger.warning(
892 "ibkr_login_redirect_loop timestamp=%s connectorId=%s method=%s proxiedPath=%s upstreamStatus=%s "
893 "upstreamLocationHost=%s upstreamLocationPath=%s rewrittenLocationPath=%s",
894 datetime.now(UTC).isoformat(),
895 connector_id,
896 method,
897 "/" + proxied_path.lstrip("/"),
898 response.status_code,
899 location_split.netloc,
900 location_split.path,
901 rewritten_split.path,
902 )
903
904
905 def _trace_server_side_post_redirect(
906 connector_id: UUID,
907 original_method: str,
908 target_origin: str,
909 target_path: str,
910 upstream_status: int,
911 ) -> None:
912 target_split = urlsplit(target_origin)
913 logger.info(
914 "ibkr_login_redirect_bridge connectorId=%s redirectHandling=SERVER_SIDE_POST_PRESERVED "
915 "originalMethod=%s targetHost=%s targetPath=%s upstreamStatus=%s",
916 connector_id,
917 original_method,
918 target_split.netloc,
919 "/" + target_path.lstrip("/"),
920 upstream_status,
921 )
922
923
924 def _is_repeated_redirect(request: Request, response: httpx.Response, rewritten_location: str | None) -> bool:
925 if request.method.upper() != "GET" or response.status_code not in {301, 302, 303, 307, 308}:
926 return False
927 if not rewritten_location:
928 return False
929 rewritten = urlsplit(rewritten_location)
930 request_url = request.url
931 current_path = request_url.path
932 current_query = _strip_login_token(request_url.query)
933 rewritten_query = _strip_login_token(rewritten.query)
934 return rewritten.path == current_path and rewritten_query == current_query
935
936
937 def _rewrite_location(value: str, prefix: str, login_token: str) -> str:
938 split = urlsplit(value)
939 connector_prefix = prefix.removesuffix("/login")
940 if split.scheme and not _is_gateway_redirect_host(split):
941 return value
942 if split.scheme:
943 path = split.path
944 query = split.query
945 if split.netloc.lower() in IBKR_BROWSER_REDIRECT_HOSTS:
946 return urlunsplit(
947 ("", "", f"{connector_prefix}/upstream/{split.netloc.lower()}{path}", query, split.fragment)
948 )
949 elif value.startswith("/"):
950 path = split.path
951 query = split.query
952 else:
953 return _with_login_token(f"{prefix}/{value.lstrip('/')}", login_token)
954 return urlunsplit(("", "", f"{connector_prefix}{path}", query, split.fragment))
955
956
957 def _rewrite_content(response: httpx.Response, prefix: str, login_token: str, current_path: str) -> bytes:
958 content_type = response.headers.get("content-type", "")
959 if not any(kind in content_type.lower() for kind in ("text/html", "text/css", "javascript")):
960 return response.content
961 is_javascript = "javascript" in content_type.lower()
962 text = response.text
963 text = _rewrite_absolute_gateway_urls(text, prefix, login_token)
964 if is_javascript:
965 text = _rewrite_login_bundle_origin_prefix(text, prefix)
966 text = re.sub(
967 r"""(?P<before>[:=,\[\(\s]\s*["'])(?P<url>/(?!/|connector-sessions/)[^"'\\\s<>)]+)""",
968 lambda match: match.group("before") + _with_login_token(prefix + match.group("url"), login_token),
969 text,
970 flags=re.IGNORECASE,
971 )
972 text = re.sub(
973 r"""(?P<attr>\b(?:href|src|action)=["'])(?P<url>/(?!/|connector-sessions/)[^"']*)""",
974 lambda match: match.group("attr") + _with_login_token(prefix + match.group("url"), login_token),
975 text,
976 flags=re.IGNORECASE,
977 )
978 text = re.sub(
979 r"""(?P<attr>\b(?:href|src|action)=["'])(?P<url>(?![a-z][a-z0-9+.-]*:|/|#|javascript:)[^"']+)""",
980 lambda match: match.group("attr")
981 + (
982 match.group("url")
983 if is_javascript and match.group("attr").lower().startswith("action=")
984 else _with_login_token(_relative_proxy_url(prefix, current_path, match.group("url")), login_token)
985 ),
986 text,
987 flags=re.IGNORECASE,
988 )
989 text = re.sub(
990 r"""url\((?P<quote>["']?)(?P<url>/(?!/|connector-sessions/)[^"')]+)(?P=quote)\)""",
991 lambda match: f"url({match.group('quote')}{_with_login_token(prefix + match.group('url'), login_token)}{match.group('quote')})",
992 text,
993 flags=re.IGNORECASE,
994 )
995 return text.encode(response.encoding or "utf-8")
996
997
998 def _relative_proxy_url(prefix: str, current_path: str, value: str) -> str:
999 base = f"/{current_path}" if current_path else "/sso/Login"
1000 normalized = urljoin(base, value)
1001 return f"{prefix}{normalized}"
1002
1003
1004 def _rewrite_login_bundle_origin_prefix(text: str, prefix: str) -> str:
1005 """Keep the Gateway login bundle's Authenticator POST connector-scoped.
1006
1007 The observed xyz bundle derives its request prefix from only the first path
1008 segment of ``document.location.pathname``. Under the connector route that
1009 becomes ``/connector-sessions/``, dropping the connector id and login
1010 prefix. Replace that one known prefix expression in the login bundle with
1011 the already-scoped Gateway SSO route.
1012 """
1013 origin_prefix_expression = (
1014 'document.location.protocol+"//"+document.location.host+"/"+'
1015 'document.location.pathname.split("/")[1]+"/"'
1016 )
1017 return text.replace(origin_prefix_expression, f'"{prefix}/sso/"')
1018
1019
1020 def _rewrite_absolute_gateway_urls(text: str, prefix: str, login_token: str) -> str:
1021 for host in _gateway_hosts():
1022 for scheme in ("https", "http"):
1023 base = f"{scheme}://{host}"
1024 text = text.replace(base, _with_login_token(prefix, login_token).split("?")[0])
1025 return text
1026
1027
1028 def _gateway_hosts() -> set[str]:
1029 hosts = set(GATEWAY_HOSTS)
1030 configured = urlsplit(settings.gateway_api_base_url)
1031 if configured.netloc:
1032 hosts.add(configured.netloc)
1033 return hosts
1034
1035
1036 def _is_gateway_redirect_host(split) -> bool:
1037 host = split.netloc.lower()
1038 return host in _gateway_hosts() or host in IBKR_BROWSER_REDIRECT_HOSTS
1039
1040
1041 def _rewrite_set_cookie(value: str, prefix: str) -> str:
1042 parts = [part.strip() for part in value.split(";")]
1043 if not parts:
1044 return value
1045 rewritten = [parts[0]]
1046 has_path = False
1047 for part in parts[1:]:
1048 lower = part.lower()
1049 if lower.startswith("domain="):
1050 continue
1051 if lower.startswith("path="):
1052 rewritten.append(f"Path={prefix}")
1053 has_path = True
1054 else:
1055 rewritten.append(part)
1056 if not has_path:
1057 rewritten.append(f"Path={prefix}")
1058 return "; ".join(rewritten)
1059
1060
1061 def _proxy_cookie_path(connector_id: UUID, proxy_target_type: str, target_origin: str) -> str:
1062 """Keep Gateway and external SSO cookies in separate browser namespaces."""
1063 base = f"/connector-sessions/{connector_id}"
1064 if proxy_target_type == PROXY_TARGET_IBKR_UPSTREAM:
1065 return f"{base}/upstream/{urlsplit(target_origin).netloc.lower()}"
1066 return f"{base}/login"
1067
1068
1069 def _with_login_token(value: str, login_token: str) -> str:
1070 split = urlsplit(value)
1071 params = parse_qsl(split.query, keep_blank_values=True)
1072 if not any(name == "loginToken" for name, _ in params):
1073 params.append(("loginToken", login_token))
1074 return urlunsplit((split.scheme, split.netloc, split.path, urlencode(params), split.fragment))
1075
1076