main
py 2,860 lines 103 KB
Raw
1 from pathlib import Path
2 from http.cookies import SimpleCookie
3 from datetime import UTC, datetime
4 from uuid import uuid4
5
6 import httpx
7 import pytest
8 from fastapi.testclient import TestClient
9
10 import app.runtime as runtime
11 from app.main import (
12 _completion_monitor_javascript,
13 _inject_auth_completion_monitor,
14 _rewrite_content,
15 _trace_authenticator_state,
16 app,
17 manager,
18 settings,
19 )
20 from app.models import ConnectorState
21 from app.settings import Settings
22
23
24 TOKEN = "test-internal-token"
25
26
27 @pytest.fixture(autouse=True)
28 def reset_runtime(monkeypatch, tmp_path):
29 manager.sessions.clear()
30 settings.aip_internal_token = TOKEN
31 settings.aip_connector_require_internal_token = True
32 settings.aip_ibkr_gateway_package_path = str(tmp_path / "missing")
33 settings.aip_ibkr_gateway_base_url = "https://gateway.internal/v1/api"
34 settings.aip_ibkr_gateway_tls_verify = True
35 settings.aip_ibkr_login_public_base_url = "https://broker.example.test"
36 yield
37 for session in manager.sessions.values():
38 if session.process and session.process.poll() is None:
39 if hasattr(session.process, "kill"):
40 session.process.kill()
41 manager.sessions.clear()
42
43
44 def headers(user_id):
45 return {"X-Internal-Token": TOKEN, "X-AIP-User-Id": str(user_id)}
46
47
48 def create(client, connector_id, user_id):
49 return client.post(
50 "/internal/connectors",
51 headers={"X-Internal-Token": TOKEN},
52 json={"connectorId": str(connector_id), "userId": str(user_id)},
53 )
54
55
56 def test_missing_gateway_artifact_sets_error() -> None:
57 client = TestClient(app)
58 connector_id = uuid4()
59 user_id = uuid4()
60
61 response = create(client, connector_id, user_id)
62
63 assert response.status_code == 200
64 body = response.json()
65 assert body["runtimeStatus"] == ConnectorState.ERROR
66 assert body["code"] == "GATEWAY_PACKAGE_INVALID"
67
68
69 def test_existing_connected_runtime_is_live_reconciled_on_start(monkeypatch) -> None:
70 connector_id = uuid4()
71 user_id = uuid4()
72 session = runtime.ConnectorSession(connector_id=connector_id, user_id=user_id, login_token="opaque")
73 session.runtime_status = ConnectorState.CONNECTED
74 session.auth_status = ConnectorState.CONNECTED
75 manager.sessions[connector_id] = session
76
77 async def unauthenticated_gateway(path, current_session=None):
78 assert path == "/iserver/auth/status"
79 assert current_session is session
80 return {"authenticated": False, "connected": True}
81
82 monkeypatch.setattr(manager, "_gateway_get", unauthenticated_gateway)
83 response = create(TestClient(app), connector_id, user_id)
84
85 assert response.status_code == 200
86 assert response.json()["authStatus"] == ConnectorState.AUTHENTICATION_REQUIRED
87 assert response.json()["loginUrl"]
88
89
90 def test_login_proxy_waits_for_healthy_gateway_during_runtime_startup(monkeypatch) -> None:
91 connector_id = uuid4()
92 user_id = uuid4()
93 calls = 0
94
95 async def unauthenticated_gateway(path, session=None):
96 return {"authenticated": False, "connected": True}
97
98 class FakeResponse:
99 status_code = 200
100 content = b'<html><script src="/sso/js/login.js"></script></html>'
101 encoding = "utf-8"
102 headers = httpx.Headers({"content-type": "text/html;charset=utf-8"})
103
104 @property
105 def text(self):
106 return self.content.decode()
107
108 class StartupClient:
109 def __init__(self, verify, timeout):
110 pass
111
112 async def __aenter__(self):
113 return self
114
115 async def __aexit__(self, exc_type, exc, tb):
116 return None
117
118 async def request(self, method, url, params, content, headers, follow_redirects):
119 nonlocal calls
120 calls += 1
121 if calls == 1:
122 raise httpx.ConnectError("gateway is starting")
123 return FakeResponse()
124
125 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
126 monkeypatch.setattr(manager, "_gateway_get", unauthenticated_gateway)
127 monkeypatch.setattr(httpx, "AsyncClient", StartupClient)
128 monkeypatch.setattr("app.main.asyncio.sleep", lambda delay: _completed())
129 client = TestClient(app)
130 create(client, connector_id, user_id)
131 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
132
133 response = client.get(login.replace("https://broker.example.test", ""))
134
135 assert response.status_code == 200
136 assert calls == 2
137 assert f'/connector-sessions/{connector_id}/login/sso/js/login.js' in response.text
138
139
140 async def _completed():
141 return None
142
143
144 def test_startup_uses_verified_unix_launcher(monkeypatch, tmp_path: Path) -> None:
145 package = tmp_path / "clientportal.gw"
146 (package / "bin").mkdir(parents=True)
147 (package / "root").mkdir()
148 (package / "bin" / "run.sh").write_text("#!/bin/sh\n", encoding="utf-8")
149 (package / "root" / "conf.yaml").write_text("listen: true\n", encoding="utf-8")
150 settings.aip_ibkr_gateway_package_path = str(package)
151 launched = {}
152
153 class FakeProcess:
154 def poll(self):
155 return None
156
157 def terminate(self):
158 launched["terminated"] = True
159
160 def wait(self, timeout=None):
161 return 0
162
163 def fake_popen(args, cwd, stdout, stderr, **kwargs):
164 launched["args"] = args
165 launched["cwd"] = cwd
166 launched["kwargs"] = kwargs
167 return FakeProcess()
168
169 async def fake_gateway_get(path, session=None):
170 assert path == "/iserver/auth/status"
171 return {"authenticated": False, "connected": True}
172
173 monkeypatch.setattr("app.runtime.subprocess.Popen", fake_popen)
174 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
175 client = TestClient(app)
176
177 response = create(client, uuid4(), uuid4())
178
179 assert response.status_code == 200
180 assert launched["args"] == [str(package / "bin" / "run.sh"), "root/conf.yaml"]
181 assert launched["cwd"] == str(package)
182 if runtime.os.name == "posix":
183 assert launched["kwargs"] == {"start_new_session": True}
184 else:
185 assert launched["kwargs"] == {}
186 assert response.json()["runtimeStatus"] == ConnectorState.AUTHENTICATION_REQUIRED
187
188
189 def test_stop_terminates_gateway_process_group_on_posix(monkeypatch) -> None:
190 events = []
191
192 class LiveProcess:
193 pid = 1234
194
195 def poll(self):
196 return None
197
198 def wait(self, timeout=None):
199 events.append(("wait", timeout))
200 return 0
201
202 monkeypatch.setattr(runtime.os, "name", "posix")
203 monkeypatch.setattr(runtime.os, "getpgid", lambda pid: events.append(("getpgid", pid)) or 4321, raising=False)
204 monkeypatch.setattr(runtime.os, "killpg", lambda pgid, sig: events.append(("killpg", pgid, sig)), raising=False)
205
206 runtime._terminate_gateway_process(LiveProcess())
207
208 assert events == [
209 ("getpgid", 1234),
210 ("killpg", 4321, runtime.GATEWAY_TERMINATE_SIGNAL),
211 ("wait", runtime.GATEWAY_STOP_TIMEOUT_SECONDS),
212 ]
213
214
215 def test_stop_force_kills_gateway_process_group_after_timeout(monkeypatch) -> None:
216 events = []
217
218 class StubbornProcess:
219 pid = 1234
220
221 def poll(self):
222 return None
223
224 def wait(self, timeout=None):
225 events.append(("wait", timeout))
226 if len([event for event in events if event[0] == "wait"]) == 1:
227 raise runtime.subprocess.TimeoutExpired(cmd="gateway", timeout=timeout)
228 return 0
229
230 monkeypatch.setattr(runtime.os, "name", "posix")
231 monkeypatch.setattr(runtime.os, "getpgid", lambda pid: events.append(("getpgid", pid)) or 4321, raising=False)
232 monkeypatch.setattr(runtime.os, "killpg", lambda pgid, sig: events.append(("killpg", pgid, sig)), raising=False)
233
234 runtime._terminate_gateway_process(StubbornProcess())
235
236 assert events == [
237 ("getpgid", 1234),
238 ("killpg", 4321, runtime.GATEWAY_TERMINATE_SIGNAL),
239 ("wait", runtime.GATEWAY_STOP_TIMEOUT_SECONDS),
240 ("killpg", 4321, runtime.GATEWAY_KILL_SIGNAL),
241 ("wait", runtime.GATEWAY_STOP_TIMEOUT_SECONDS),
242 ]
243
244
245 def test_auth_success_session_expiry_heartbeat_and_read_only_proxy(monkeypatch) -> None:
246 connector_id = uuid4()
247 user_id = uuid4()
248 calls = []
249
250 async def fake_gateway_get(path, session=None):
251 calls.append(path)
252 if path == "/iserver/auth/status":
253 return {"authenticated": len(calls) > 1, "connected": True}
254 if path == "/portfolio/accounts":
255 return [{"id": "U1234567", "currency": "EUR"}]
256 if path == "/portfolio/U1234567/positions/0":
257 return [{"conid": "1", "ticker": "AIXA", "position": "1"}]
258 if path == "/portfolio/U1234567/ledger":
259 return {"EUR": {"cashbalance": "10", "currency": "EUR"}}
260 raise AssertionError(path)
261
262 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
263 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
264 client = TestClient(app)
265
266 create_response = create(client, connector_id, user_id)
267 assert create_response.json()["authStatus"] == ConnectorState.AUTHENTICATION_REQUIRED
268
269 status = client.get(f"/internal/connectors/{connector_id}/status", headers=headers(user_id)).json()
270 assert status["authStatus"] == ConnectorState.CONNECTED
271 assert status["heartbeatAt"] is not None
272
273 accounts = client.get(f"/internal/connectors/{connector_id}/accounts", headers=headers(user_id))
274 positions = client.get(
275 f"/internal/connectors/{connector_id}/positions?account_id=U1234567&page=0",
276 headers=headers(user_id),
277 )
278 ledger = client.get(
279 f"/internal/connectors/{connector_id}/ledger?account_id=U1234567",
280 headers=headers(user_id),
281 )
282
283 assert accounts.json()["data"][0]["id"] == "U1234567"
284 assert positions.json()["data"][0]["ticker"] == "AIXA"
285 assert ledger.json()["data"]["EUR"]["cashbalance"] == "10"
286
287
288 def test_process_failure_restart_and_cleanup(monkeypatch) -> None:
289 connector_id = uuid4()
290 user_id = uuid4()
291 events = []
292
293 class FailedProcess:
294 def poll(self):
295 return 1
296
297 class LiveProcess:
298 pid = 4242
299
300 def poll(self):
301 return None
302
303 def terminate(self):
304 events.append("terminate")
305
306 def wait(self, timeout=None):
307 events.append("wait")
308
309 def first_start(session):
310 session.process = FailedProcess()
311
312 async def fake_gateway_get(_, session=None):
313 return {"authenticated": False, "connected": True}
314
315 monkeypatch.setattr(manager, "_start_gateway", first_start)
316 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
317 monkeypatch.setattr(runtime.os, "getpgid", lambda pid: pid, raising=False)
318 monkeypatch.setattr(runtime.os, "killpg", lambda pgid, sig: events.append("killpg"), raising=False)
319 client = TestClient(app)
320 create(client, connector_id, user_id)
321
322 status = client.get(f"/internal/connectors/{connector_id}/status", headers=headers(user_id))
323 assert status.json()["code"] == "GATEWAY_PROCESS_EXITED"
324
325 monkeypatch.setattr(manager, "_start_gateway", lambda session: setattr(session, "process", LiveProcess()))
326 restart = client.post(f"/internal/connectors/{connector_id}/restart", headers=headers(user_id))
327 assert restart.status_code == 200
328
329 stopped = client.post(f"/internal/connectors/{connector_id}/stop", headers=headers(user_id))
330 assert stopped.json()["runtimeStatus"] == ConnectorState.STOPPED
331 assert "terminate" in events or "killpg" in events
332
333
334 def test_restart_replaces_connector_session_and_cookie_jar(monkeypatch) -> None:
335 connector_id = uuid4()
336 user_id = uuid4()
337
338 async def fake_gateway_get(path, session=None):
339 return {"authenticated": False, "connected": True}
340
341 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
342 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
343 client = TestClient(app)
344
345 create(client, connector_id, user_id)
346 old_session = manager.sessions[connector_id]
347 old_cookie_jar = old_session.gateway_cookies
348 old_cookie_jar["STALE_GATEWAY_COOKIE"] = "old"
349
350 restart = client.post(f"/internal/connectors/{connector_id}/restart", headers=headers(user_id))
351
352 assert restart.status_code == 200
353 new_session = manager.sessions[connector_id]
354 assert new_session is not old_session
355 assert new_session.gateway_cookies is not old_cookie_jar
356 assert new_session.gateway_cookies == {}
357
358
359 def test_explicit_second_login_restarts_previously_authenticated_stale_session(monkeypatch) -> None:
360 connector_id = uuid4()
361 user_id = uuid4()
362
363 async def unauthenticated(path, session=None):
364 return {"authenticated": False, "connected": True}
365
366 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
367 monkeypatch.setattr(manager, "_gateway_get", unauthenticated)
368 client = TestClient(app)
369 create(client, connector_id, user_id)
370 old_session = manager.sessions[connector_id]
371 old_session.authenticated_at = datetime.now(UTC)
372 old_session.gateway_cookies["JSESSIONID"] = "first-gateway-session"
373 old_session.gateway_cookies["URL_PARAM"] = "first-login-state"
374 old_token = old_session.login_token
375
376 response = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id))
377
378 assert response.status_code == 200
379 assert manager.sessions[connector_id] is not old_session
380 assert manager.sessions[connector_id].login_token != old_token
381 assert manager.sessions[connector_id].gateway_cookie_names_to_clear == {"JSESSIONID", "URL_PARAM"}
382 assert str(connector_id) in response.json()["loginUrl"]
383
384
385 def test_second_login_discards_stale_browser_gateway_session_before_fresh_login(monkeypatch) -> None:
386 connector_id = uuid4()
387 user_id = uuid4()
388 observed = {}
389
390 async def unauthenticated(path, session=None):
391 return {"authenticated": False, "connected": True}
392
393 class FakeResponse:
394 status_code = 200
395 content = b"<html></html>"
396 encoding = "utf-8"
397 headers = httpx.Headers([
398 ("content-type", "text/html"),
399 ("set-cookie", "JSESSIONID=fresh-gateway-session; Path=/"),
400 ])
401
402 @property
403 def text(self):
404 return self.content.decode("utf-8")
405
406 class FakeClient:
407 def __init__(self, verify, timeout):
408 return None
409
410 async def __aenter__(self):
411 return self
412
413 async def __aexit__(self, exc_type, exc, tb):
414 return None
415
416 async def request(self, method, url, params, content, headers, follow_redirects):
417 observed["cookie"] = headers.get("cookie", "")
418 return FakeResponse()
419
420 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
421 monkeypatch.setattr(manager, "_gateway_get", unauthenticated)
422 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
423 client = TestClient(app)
424 create(client, connector_id, user_id)
425 old_session = manager.sessions[connector_id]
426 old_session.authenticated_at = datetime.now(UTC)
427 old_session.gateway_cookies.update({"JSESSIONID": "first", "URL_PARAM": "first-state"})
428
429 login_url = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
430 assert manager.sessions[connector_id].gateway_cookie_names_to_clear == {"JSESSIONID", "URL_PARAM"}
431 response = client.get(
432 login_url,
433 headers={"cookie": "JSESSIONID=first; URL_PARAM=first-state; browser-only=stale"},
434 )
435
436 assert response.status_code == 200
437 assert observed["cookie"] == ""
438 set_cookies = response.headers.get_list("set-cookie")
439 assert any("JSESSIONID=\"\"" in header and "Max-Age=0" in header for header in set_cookies)
440 assert any("URL_PARAM=\"\"" in header and "Max-Age=0" in header for header in set_cookies)
441 assert any("browser-only=\"\"" in header and "Max-Age=0" in header for header in set_cookies)
442 assert any("JSESSIONID=fresh-gateway-session" in header for header in set_cookies)
443 assert manager.sessions[connector_id].gateway_cookie_names_to_clear == set()
444
445
446 def test_fresh_runtime_discards_stale_browser_gateway_cookies_only_on_first_login(monkeypatch) -> None:
447 connector_id = uuid4()
448 user_id = uuid4()
449 observed_cookies = []
450
451 async def unauthenticated(path, session=None):
452 return {"authenticated": False, "connected": True}
453
454 class FakeResponse:
455 status_code = 200
456 content = b"<html></html>"
457 encoding = "utf-8"
458 headers = httpx.Headers([
459 ("content-type", "text/html"),
460 ("set-cookie", "JSESSIONID=fresh-gateway-session; Path=/"),
461 ])
462
463 @property
464 def text(self):
465 return self.content.decode("utf-8")
466
467 class FakeClient:
468 def __init__(self, verify, timeout):
469 return None
470
471 async def __aenter__(self):
472 return self
473
474 async def __aexit__(self, exc_type, exc, tb):
475 return None
476
477 async def request(self, method, url, params, content, headers, follow_redirects):
478 observed_cookies.append(headers.get("cookie", ""))
479 return FakeResponse()
480
481 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
482 monkeypatch.setattr(manager, "_gateway_get", unauthenticated)
483 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
484 client = TestClient(app)
485 create(client, connector_id, user_id)
486 session = manager.sessions[connector_id]
487 assert session.gateway_browser_reset_pending is True
488
489 login_url = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
490 first = client.get(
491 login_url,
492 headers={"cookie": "JSESSIONID=stale; URL_PARAM=stale-state; USERID=stable-identity; browser-only=stale"},
493 )
494
495 assert first.status_code == 200
496 assert observed_cookies == ["USERID=stable-identity"]
497 assert session.gateway_cookies == {"JSESSIONID": "fresh-gateway-session", "USERID": "stable-identity"}
498 assert session.gateway_browser_reset_pending is False
499 expired = first.headers.get_list("set-cookie")
500 assert any("JSESSIONID=\"\"" in header and "Max-Age=0" in header for header in expired)
501 assert any("URL_PARAM=\"\"" in header and "Max-Age=0" in header for header in expired)
502 assert any("browser-only=\"\"" in header and "Max-Age=0" in header for header in expired)
503 assert not any("USERID=\"\"" in header and "Max-Age=0" in header for header in expired)
504
505 second = client.get(login_url, headers={"cookie": "JSESSIONID=fresh-gateway-session; USERID=stable-identity"})
506
507 assert second.status_code == 200
508 assert observed_cookies[0] == "USERID=stable-identity"
509 assert SimpleCookie(observed_cookies[1]).keys() == {"JSESSIONID", "USERID"}
510 assert not any("Max-Age=0" in header for header in second.headers.get_list("set-cookie"))
511
512
513 def test_repeated_login_during_current_mfa_does_not_restart_runtime(monkeypatch) -> None:
514 connector_id = uuid4()
515 user_id = uuid4()
516
517 async def unauthenticated(path, session=None):
518 return {"authenticated": False, "connected": True}
519
520 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
521 monkeypatch.setattr(manager, "_gateway_get", unauthenticated)
522 client = TestClient(app)
523 create(client, connector_id, user_id)
524 current_session = manager.sessions[connector_id]
525 current_token = current_session.login_token
526
527 first = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id))
528 second = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id))
529
530 assert first.status_code == 200
531 assert second.status_code == 200
532 assert manager.sessions[connector_id] is current_session
533 assert manager.sessions[connector_id].login_token == current_token
534
535
536 def test_user_ownership_and_cross_user_access_are_enforced(monkeypatch) -> None:
537 connector_id = uuid4()
538 user_a = uuid4()
539 user_b = uuid4()
540 async def fake_gateway_get(path, session=None):
541 return {"authenticated": False, "connected": True}
542
543 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
544 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
545 client = TestClient(app)
546
547 create(client, connector_id, user_a)
548 response = client.get(f"/internal/connectors/{connector_id}/status", headers=headers(user_b))
549
550 assert response.status_code == 403
551 assert response.json()["code"] == "CONNECTOR_FORBIDDEN"
552
553
554 def test_no_trading_route_and_internal_mutations_are_absent(monkeypatch) -> None:
555 connector_id = uuid4()
556 user_id = uuid4()
557 async def fake_gateway_get(path, session=None):
558 return {"authenticated": True, "connected": True}
559
560 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
561 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
562 client = TestClient(app)
563 create(client, connector_id, user_id)
564
565 response = client.post(
566 f"/internal/connectors/{connector_id}/orders",
567 headers=headers(user_id),
568 json={"side": "BUY"},
569 )
570
571 assert response.status_code == 404
572
573
574 def test_login_route_uses_token_and_does_not_expose_gateway_url(monkeypatch) -> None:
575 connector_id = uuid4()
576 user_id = uuid4()
577 async def fake_gateway_get(path, session=None):
578 return {"authenticated": False, "connected": True}
579
580 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
581 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
582 client = TestClient(app)
583
584 create(client, connector_id, user_id)
585 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()
586
587 assert login["loginUrl"].startswith("https://broker.example.test/connector-sessions/")
588 assert (
589 f"/connector-sessions/{connector_id}/login/sso/Login?forwardTo=22&RL=1&ip2loc=on&loginToken="
590 in login["loginUrl"]
591 )
592 assert "gateway.internal" not in login["loginUrl"]
593 assert client.get(f"/connector-sessions/{connector_id}/login").status_code == 409
594
595
596 def test_valid_login_query_sets_connector_login_cookie(monkeypatch) -> None:
597 connector_id = uuid4()
598 user_id = uuid4()
599
600 async def fake_gateway_get(path, session=None):
601 return {"authenticated": False, "connected": True}
602
603 class FakeResponse:
604 status_code = 200
605 content = b"<html></html>"
606 encoding = "utf-8"
607 headers = httpx.Headers({"content-type": "text/html"})
608
609 @property
610 def text(self):
611 return self.content.decode("utf-8")
612
613 class FakeClient:
614 def __init__(self, verify, timeout):
615 return None
616
617 async def __aenter__(self):
618 return self
619
620 async def __aexit__(self, exc_type, exc, tb):
621 return None
622
623 async def request(self, method, url, params, content, headers, follow_redirects):
624 return FakeResponse()
625
626 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
627 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
628 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
629 client = TestClient(app)
630 create(client, connector_id, user_id)
631 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
632 token = login.split("loginToken=", 1)[1]
633
634 response = client.get(f"/connector-sessions/{connector_id}/login?loginToken={token}")
635 cookies = response.headers.get_list("set-cookie")
636 parsed = SimpleCookie()
637 for header in cookies:
638 parsed.load(header)
639
640 cookie_name = f"aip_ibkr_login_{connector_id.hex}"
641 assert response.status_code == 200
642 assert cookie_name in parsed
643 assert parsed[cookie_name]["path"] == "/connector-sessions"
644 assert parsed[cookie_name]["httponly"] is True
645 assert parsed[cookie_name]["samesite"] == "lax"
646 assert parsed[cookie_name]["secure"] == ""
647
648
649 def test_upstream_route_accepts_connector_login_cookie_from_prior_login(monkeypatch) -> None:
650 connector_id = uuid4()
651 user_id = uuid4()
652 observed = {}
653
654 async def fake_gateway_get(path, session=None):
655 return {"authenticated": False, "connected": True}
656
657 class FakeResponse:
658 status_code = 200
659 content = b"{}"
660 encoding = "utf-8"
661 headers = httpx.Headers({"content-type": "application/json"})
662
663 @property
664 def text(self):
665 return self.content.decode("utf-8")
666
667 class FakeClient:
668 def __init__(self, verify, timeout):
669 return None
670
671 async def __aenter__(self):
672 return self
673
674 async def __aexit__(self, exc_type, exc, tb):
675 return None
676
677 async def request(self, method, url, params, content, headers, follow_redirects):
678 observed["url"] = url
679 return FakeResponse()
680
681 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
682 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
683 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
684 client = TestClient(app)
685 create(client, connector_id, user_id)
686 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
687 token = login.split("loginToken=", 1)[1]
688 assert client.get(f"/connector-sessions/{connector_id}/login?loginToken={token}").status_code == 200
689
690 response = client.get(f"/connector-sessions/{connector_id}/upstream/api.ibkr.com/Authenticator")
691
692 assert response.status_code == 200
693 assert observed["url"] == "https://api.ibkr.com/Authenticator"
694
695
696 def test_upstream_route_without_cookie_or_query_token_fails(monkeypatch) -> None:
697 connector_id = uuid4()
698 user_id = uuid4()
699
700 async def fake_gateway_get(path, session=None):
701 return {"authenticated": False, "connected": True}
702
703 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
704 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
705 client = TestClient(app)
706 create(client, connector_id, user_id)
707
708 response = client.get(f"/connector-sessions/{connector_id}/upstream/api.ibkr.com/Authenticator")
709
710 assert response.status_code == 409
711 assert response.json()["code"] == "LOGIN_TOKEN_INVALID"
712
713
714 def test_upstream_route_with_wrong_token_fails(monkeypatch) -> None:
715 connector_id = uuid4()
716 user_id = uuid4()
717
718 async def fake_gateway_get(path, session=None):
719 return {"authenticated": False, "connected": True}
720
721 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
722 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
723 client = TestClient(app)
724 create(client, connector_id, user_id)
725
726 response = client.get(
727 f"/connector-sessions/{connector_id}/upstream/api.ibkr.com/Authenticator",
728 headers={"cookie": f"aip_ibkr_login_{connector_id.hex}=stale-token"},
729 )
730
731 assert response.status_code == 409
732 assert response.json()["code"] == "LOGIN_TOKEN_INVALID"
733
734
735 def test_valid_connector_cookie_takes_precedence_over_stale_query_token(monkeypatch) -> None:
736 connector_id = uuid4()
737 user_id = uuid4()
738 observed = {}
739
740 async def fake_gateway_get(path, session=None):
741 return {"authenticated": False, "connected": True}
742
743 class FakeResponse:
744 status_code = 200
745 content = b"{}"
746 encoding = "utf-8"
747 headers = httpx.Headers({"content-type": "application/json"})
748
749 @property
750 def text(self):
751 return self.content.decode("utf-8")
752
753 class FakeClient:
754 def __init__(self, verify, timeout):
755 return None
756
757 async def __aenter__(self):
758 return self
759
760 async def __aexit__(self, exc_type, exc, tb):
761 return None
762
763 async def request(self, method, url, params, content, headers, follow_redirects):
764 observed["url"] = url
765 observed["params"] = params
766 return FakeResponse()
767
768 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
769 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
770 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
771 client = TestClient(app)
772 create(client, connector_id, user_id)
773 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
774 token = login.split("loginToken=", 1)[1]
775
776 response = client.get(
777 f"/connector-sessions/{connector_id}/upstream/api.ibkr.com/Authenticator?loginToken=stale-token&a=1",
778 headers={"cookie": f"aip_ibkr_login_{connector_id.hex}={token}"},
779 )
780
781 assert response.status_code == 200
782 assert observed["url"] == "https://api.ibkr.com/Authenticator"
783 assert observed["params"] == {"a": "1"}
784
785
786 def test_runtime_recreated_with_new_token_rejects_old_cookie(monkeypatch) -> None:
787 connector_id = uuid4()
788 user_id = uuid4()
789
790 async def fake_gateway_get(path, session=None):
791 return {"authenticated": False, "connected": True}
792
793 class FakeResponse:
794 status_code = 200
795 content = b"{}"
796 encoding = "utf-8"
797 headers = httpx.Headers({"content-type": "application/json"})
798
799 @property
800 def text(self):
801 return self.content.decode("utf-8")
802
803 class FakeClient:
804 def __init__(self, verify, timeout):
805 return None
806
807 async def __aenter__(self):
808 return self
809
810 async def __aexit__(self, exc_type, exc, tb):
811 return None
812
813 async def request(self, method, url, params, content, headers, follow_redirects):
814 return FakeResponse()
815
816 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
817 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
818 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
819 client = TestClient(app)
820 create(client, connector_id, user_id)
821 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
822 old_token = login.split("loginToken=", 1)[1]
823 assert client.get(f"/connector-sessions/{connector_id}/login?loginToken={old_token}").status_code == 200
824 manager.sessions[connector_id].login_token = "new-server-token"
825
826 response = client.get(f"/connector-sessions/{connector_id}/upstream/api.ibkr.com/Authenticator")
827
828 assert response.status_code == 409
829 assert response.json()["code"] == "LOGIN_TOKEN_INVALID"
830
831
832 def test_successful_login_query_refreshes_connector_login_cookie(monkeypatch) -> None:
833 connector_id = uuid4()
834 user_id = uuid4()
835
836 async def fake_gateway_get(path, session=None):
837 return {"authenticated": False, "connected": True}
838
839 class FakeResponse:
840 status_code = 200
841 content = b"{}"
842 encoding = "utf-8"
843 headers = httpx.Headers({"content-type": "application/json"})
844
845 @property
846 def text(self):
847 return self.content.decode("utf-8")
848
849 class FakeClient:
850 def __init__(self, verify, timeout):
851 return None
852
853 async def __aenter__(self):
854 return self
855
856 async def __aexit__(self, exc_type, exc, tb):
857 return None
858
859 async def request(self, method, url, params, content, headers, follow_redirects):
860 return FakeResponse()
861
862 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
863 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
864 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
865 client = TestClient(app)
866 create(client, connector_id, user_id)
867 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
868 token = login.split("loginToken=", 1)[1]
869
870 response = client.get(
871 f"/connector-sessions/{connector_id}/login?loginToken={token}",
872 headers={"cookie": f"aip_ibkr_login_{connector_id.hex}=stale-token"},
873 )
874 parsed = SimpleCookie()
875 for header in response.headers.get_list("set-cookie"):
876 parsed.load(header)
877
878 assert response.status_code == 200
879 assert parsed[f"aip_ibkr_login_{connector_id.hex}"].value == token
880
881
882 def test_login_context_diagnostics_do_not_log_secret_values(monkeypatch, caplog) -> None:
883 connector_id = uuid4()
884 user_id = uuid4()
885
886 async def fake_gateway_get(path, session=None):
887 return {"authenticated": False, "connected": True}
888
889 class FakeResponse:
890 status_code = 200
891 content = b"{}"
892 encoding = "utf-8"
893 headers = httpx.Headers({"content-type": "application/json"})
894
895 @property
896 def text(self):
897 return self.content.decode("utf-8")
898
899 class FakeClient:
900 def __init__(self, verify, timeout):
901 return None
902
903 async def __aenter__(self):
904 return self
905
906 async def __aexit__(self, exc_type, exc, tb):
907 return None
908
909 async def request(self, method, url, params, content, headers, follow_redirects):
910 return FakeResponse()
911
912 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
913 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
914 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
915 client = TestClient(app)
916 create(client, connector_id, user_id)
917 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
918 token = login.split("loginToken=", 1)[1]
919
920 with caplog.at_level("INFO"):
921 client.get(f"/connector-sessions/{connector_id}/login?loginToken={token}")
922 client.get(
923 f"/connector-sessions/{connector_id}/upstream/api.ibkr.com/Authenticator",
924 headers={"cookie": f"aip_ibkr_login_{connector_id.hex}=wrong-secret"},
925 )
926
927 assert "ibkr_login_context" in caplog.text
928 assert "loginTokenQueryPresent=True" in caplog.text
929 assert "loginTokenCookiePresent=True" in caplog.text
930 assert "loginTokenMatches=True" in caplog.text
931 assert "loginTokenMatches=False" in caplog.text
932 assert token not in caplog.text
933 assert "wrong-secret" not in caplog.text
934
935
936 def test_login_proxy_preserves_redirect_cookie_status_and_content_type(monkeypatch) -> None:
937 connector_id = uuid4()
938 user_id = uuid4()
939 observed = {}
940
941 async def fake_gateway_get(path, session=None):
942 return {"authenticated": False, "connected": True}
943
944 class FakeResponse:
945 status_code = 302
946 content = b""
947 encoding = "utf-8"
948 headers = httpx.Headers(
949 [
950 ("location", "https://gateway.internal/sso/Login?forward=one"),
951 ("set-cookie", "IBKR_SESSION=abc; Path=/; HttpOnly"),
952 ("content-type", "text/html;charset=utf-8"),
953 ]
954 )
955
956 @property
957 def text(self):
958 return self.content.decode("utf-8")
959
960 class FakeClient:
961 def __init__(self, verify, timeout):
962 observed["verify"] = verify
963
964 async def __aenter__(self):
965 return self
966
967 async def __aexit__(self, exc_type, exc, tb):
968 return None
969
970 async def request(self, method, url, params, content, headers, follow_redirects):
971 observed.update(
972 {
973 "method": method,
974 "url": url,
975 "params": params,
976 "cookie": headers.get("cookie"),
977 "follow_redirects": follow_redirects,
978 }
979 )
980 return FakeResponse()
981
982 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
983 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
984 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
985 client = TestClient(app)
986 create(client, connector_id, user_id)
987 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
988 token = login.split("loginToken=", 1)[1]
989
990 response = client.get(
991 f"/connector-sessions/{connector_id}/login/sso/Login?loginToken={token}&x=1",
992 headers={"cookie": f"aip_ibkr_login_{connector_id.hex}={token}; IBKR_EXISTING=old"},
993 follow_redirects=False,
994 )
995
996 assert response.status_code == 302
997 assert observed["url"] == "https://gateway.internal/sso/Login"
998 assert observed["params"] == {"x": "1"}
999 # A brand-new Gateway must not inherit a browser session belonging to the
1000 # deleted runtime; its response supplies the authoritative replacement.
1001 assert observed["cookie"] is None
1002 assert observed["follow_redirects"] is False
1003 assert response.headers["location"] == f"/connector-sessions/{connector_id}/sso/Login?forward=one"
1004 assert "gateway.internal" not in response.headers["location"]
1005 gateway_cookie = next(header for header in response.headers.get_list("set-cookie") if "IBKR_SESSION=abc" in header)
1006 assert f"Path=/connector-sessions/{connector_id}/login" in gateway_cookie
1007 assert response.headers["content-type"] == "text/html;charset=utf-8"
1008
1009
1010 def test_javascript_form_dispatcher_action_is_not_rewritten_to_script_directory() -> None:
1011 class FakeResponse:
1012 content = (
1013 b'<form class="xyzform-submit" method="post" action="Dispatcher">'
1014 b'<script src="relative-helper.js"></script>'
1015 )
1016 encoding = "utf-8"
1017 headers = httpx.Headers({"content-type": "application/javascript"})
1018
1019 @property
1020 def text(self):
1021 return self.content.decode("utf-8")
1022
1023 rewritten = _rewrite_content(
1024 FakeResponse(),
1025 "/connector-sessions/connector-id/login",
1026 "login-token",
1027 "sso/lib/xyz.bundle.min.js",
1028 ).decode("utf-8")
1029
1030 assert 'action="Dispatcher"' in rewritten
1031 assert "sso/lib/Dispatcher" not in rewritten
1032 assert (
1033 'src="/connector-sessions/connector-id/login/sso/lib/relative-helper.js?loginToken=login-token"'
1034 in rewritten
1035 )
1036
1037
1038 def test_html_relative_resource_rewriting_still_uses_current_path() -> None:
1039 class FakeResponse:
1040 content = b'<link href="theme.css"><form action="Dispatcher"></form>'
1041 encoding = "utf-8"
1042 headers = httpx.Headers({"content-type": "text/html;charset=UTF-8"})
1043
1044 @property
1045 def text(self):
1046 return self.content.decode("utf-8")
1047
1048 rewritten = _rewrite_content(
1049 FakeResponse(),
1050 "/connector-sessions/connector-id/login",
1051 "login-token",
1052 "sso/Login",
1053 ).decode("utf-8")
1054
1055 assert 'href="/connector-sessions/connector-id/login/sso/theme.css?loginToken=login-token"' in rewritten
1056 assert 'action="/connector-sessions/connector-id/login/sso/Dispatcher?loginToken=login-token"' in rewritten
1057
1058
1059 @pytest.mark.parametrize(
1060 ("upstream_location", "expected_path"),
1061 [
1062 ("https://api.ibkr.com/Authenticator", "/upstream/api.ibkr.com/Authenticator"),
1063 ("https://api.ibkr.com/report", "/upstream/api.ibkr.com/report"),
1064 ("/Authenticator", "/Authenticator"),
1065 ("/Authenticator?foo=bar", "/Authenticator?foo=bar"),
1066 ("http://localhost:5000/Authenticator", "/Authenticator"),
1067 ("https://api.ibkr.com/Authenticator?foo=bar#step", "/upstream/api.ibkr.com/Authenticator?foo=bar#step"),
1068 ],
1069 )
1070 def test_gateway_redirect_locations_stay_connector_scoped(monkeypatch, upstream_location, expected_path) -> None:
1071 connector_id = uuid4()
1072 user_id = uuid4()
1073 observed = {}
1074
1075 async def fake_gateway_get(path, session=None):
1076 return {"authenticated": False, "connected": True}
1077
1078 class FakeResponse:
1079 status_code = 302
1080 content = b""
1081 encoding = "utf-8"
1082 headers = httpx.Headers({"location": upstream_location})
1083
1084 @property
1085 def text(self):
1086 return self.content.decode("utf-8")
1087
1088 class FakeClient:
1089 def __init__(self, verify, timeout):
1090 return None
1091
1092 async def __aenter__(self):
1093 return self
1094
1095 async def __aexit__(self, exc_type, exc, tb):
1096 return None
1097
1098 async def request(self, method, url, params, content, headers, follow_redirects):
1099 observed["params"] = params
1100 return FakeResponse()
1101
1102 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1103 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1104 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
1105 client = TestClient(app)
1106 create(client, connector_id, user_id)
1107 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
1108 token = login.split("loginToken=", 1)[1]
1109
1110 response = client.get(
1111 f"/connector-sessions/{connector_id}/Authenticator?loginToken={token}&requestId=abc",
1112 follow_redirects=False,
1113 )
1114
1115 assert response.status_code == 302
1116 assert response.headers["location"] == f"/connector-sessions/{connector_id}{expected_path}"
1117 assert "loginToken" not in response.headers["location"]
1118 assert observed["params"] == {"requestId": "abc"}
1119
1120
1121 def test_distinct_upstream_authorities_do_not_collapse_into_redirect_loop(monkeypatch) -> None:
1122 connector_id = uuid4()
1123 user_id = uuid4()
1124 observed = []
1125
1126 async def fake_gateway_get(path, session=None):
1127 return {"authenticated": False, "connected": True}
1128
1129 class FakeResponse:
1130 status_code = 302
1131 content = b""
1132 encoding = "utf-8"
1133
1134 def __init__(self, location):
1135 self.headers = httpx.Headers({"location": location})
1136
1137 @property
1138 def text(self):
1139 return self.content.decode("utf-8")
1140
1141 class FakeClient:
1142 def __init__(self, verify, timeout):
1143 return None
1144
1145 async def __aenter__(self):
1146 return self
1147
1148 async def __aexit__(self, exc_type, exc, tb):
1149 return None
1150
1151 async def request(self, method, url, params, content, headers, follow_redirects):
1152 observed.append((method, url))
1153 if url == "https://gateway.internal/Authenticator":
1154 return FakeResponse("https://api.ibkr.com/Authenticator")
1155 if url == "https://api.ibkr.com/Authenticator":
1156 return FakeResponse("https://gateway.internal/Authenticator")
1157 raise AssertionError(url)
1158
1159 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1160 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1161 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
1162 client = TestClient(app)
1163 create(client, connector_id, user_id)
1164 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
1165 token = login.split("loginToken=", 1)[1]
1166
1167 first = client.post(
1168 f"/connector-sessions/{connector_id}/login/Authenticator?loginToken={token}",
1169 headers={"cookie": f"aip_ibkr_login_{connector_id.hex}={token}"},
1170 follow_redirects=False,
1171 )
1172
1173 assert first.status_code == 302
1174 assert first.headers["location"] == f"/connector-sessions/{connector_id}/Authenticator"
1175 assert observed == [
1176 ("POST", "https://gateway.internal/Authenticator"),
1177 ("POST", "https://api.ibkr.com/Authenticator"),
1178 ]
1179
1180
1181 def test_gateway_post_authenticator_redirect_preserves_method_body_and_content_type(monkeypatch, caplog) -> None:
1182 connector_id = uuid4()
1183 user_id = uuid4()
1184 observed = []
1185
1186 async def fake_gateway_get(path, session=None):
1187 return {"authenticated": False, "connected": True}
1188
1189 class FakeResponse:
1190 content = b"ok"
1191 encoding = "utf-8"
1192
1193 def __init__(self, status_code, headers=None):
1194 self.status_code = status_code
1195 self.headers = httpx.Headers(headers or {"content-type": "text/plain"})
1196
1197 @property
1198 def text(self):
1199 return self.content.decode("utf-8")
1200
1201 class FakeClient:
1202 def __init__(self, verify, timeout):
1203 return None
1204
1205 async def __aenter__(self):
1206 return self
1207
1208 async def __aexit__(self, exc_type, exc, tb):
1209 return None
1210
1211 async def request(self, method, url, params, content, headers, follow_redirects):
1212 observed.append(
1213 {
1214 "method": method,
1215 "url": url,
1216 "params": params,
1217 "content": content,
1218 "content_type": headers.get("content-type"),
1219 "cookie": headers.get("cookie", ""),
1220 }
1221 )
1222 if url == "https://gateway.internal/Authenticator":
1223 return FakeResponse(302, {"location": "https://api.ibkr.com/Authenticator"})
1224 if url == "https://api.ibkr.com/Authenticator":
1225 return FakeResponse(200)
1226 raise AssertionError(url)
1227
1228 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1229 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1230 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
1231 client = TestClient(app)
1232 create(client, connector_id, user_id)
1233 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
1234 token = login.split("loginToken=", 1)[1]
1235
1236 with caplog.at_level("INFO"):
1237 response = client.post(
1238 f"/connector-sessions/{connector_id}/login/Authenticator?loginToken={token}&keep=1",
1239 content=b"username=user&password=secret",
1240 headers={
1241 "content-type": "application/x-www-form-urlencoded",
1242 "cookie": f"aip_ibkr_login_{connector_id.hex}={token}; aip_ibkr_login_context={connector_id}; SBID=allowed",
1243 },
1244 )
1245
1246 assert response.status_code == 200
1247 assert observed[0]["method"] == "POST"
1248 assert observed[1]["method"] == "POST"
1249 assert observed[1]["url"] == "https://api.ibkr.com/Authenticator"
1250 assert observed[1]["content"] == b"username=user&password=secret"
1251 assert observed[1]["content_type"] == "application/x-www-form-urlencoded"
1252 assert observed[1]["params"] == {}
1253 assert observed[1]["cookie"] == "SBID=allowed"
1254 assert token not in observed[1]["cookie"]
1255 assert "password=secret" not in caplog.text
1256 assert token not in caplog.text
1257 assert "redirectHandling=SERVER_SIDE_POST_PRESERVED" in caplog.text
1258 assert "originalMethod=POST" in caplog.text
1259 assert "targetHost=api.ibkr.com" in caplog.text
1260 assert "targetPath=/Authenticator" in caplog.text
1261
1262
1263 def test_gateway_post_report_redirect_preserves_post_when_required(monkeypatch) -> None:
1264 connector_id = uuid4()
1265 user_id = uuid4()
1266 observed = []
1267
1268 async def fake_gateway_get(path, session=None):
1269 return {"authenticated": False, "connected": True}
1270
1271 class FakeResponse:
1272 content = b"ok"
1273 encoding = "utf-8"
1274
1275 def __init__(self, status_code, headers=None):
1276 self.status_code = status_code
1277 self.headers = httpx.Headers(headers or {"content-type": "text/plain"})
1278
1279 @property
1280 def text(self):
1281 return self.content.decode("utf-8")
1282
1283 class FakeClient:
1284 def __init__(self, verify, timeout):
1285 return None
1286
1287 async def __aenter__(self):
1288 return self
1289
1290 async def __aexit__(self, exc_type, exc, tb):
1291 return None
1292
1293 async def request(self, method, url, params, content, headers, follow_redirects):
1294 observed.append((method, url, content, headers.get("content-type")))
1295 if url == "https://gateway.internal/report":
1296 return FakeResponse(302, {"location": "https://api.ibkr.com/report"})
1297 if url == "https://api.ibkr.com/report":
1298 return FakeResponse(204)
1299 raise AssertionError(url)
1300
1301 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1302 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1303 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
1304 client = TestClient(app)
1305 create(client, connector_id, user_id)
1306 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
1307 token = login.split("loginToken=", 1)[1]
1308
1309 response = client.post(
1310 f"/connector-sessions/{connector_id}/report?loginToken={token}",
1311 content=b'{"event":"submit"}',
1312 headers={"content-type": "application/json"},
1313 )
1314
1315 assert response.status_code == 204
1316 assert observed == [
1317 ("POST", "https://gateway.internal/report", b'{"event":"submit"}', "application/json"),
1318 ("POST", "https://api.ibkr.com/report", b'{"event":"submit"}', "application/json"),
1319 ]
1320
1321
1322 def test_upstream_authenticator_targets_api_ibkr_not_gateway(monkeypatch) -> None:
1323 connector_id = uuid4()
1324 user_id = uuid4()
1325 observed = {}
1326
1327 async def fake_gateway_get(path, session=None):
1328 return {"authenticated": False, "connected": True}
1329
1330 class FakeResponse:
1331 status_code = 200
1332 content = b"{}"
1333 encoding = "utf-8"
1334 headers = httpx.Headers({"content-type": "application/json"})
1335
1336 @property
1337 def text(self):
1338 return self.content.decode("utf-8")
1339
1340 class FakeClient:
1341 def __init__(self, verify, timeout):
1342 return None
1343
1344 async def __aenter__(self):
1345 return self
1346
1347 async def __aexit__(self, exc_type, exc, tb):
1348 return None
1349
1350 async def request(self, method, url, params, content, headers, follow_redirects):
1351 observed["method"] = method
1352 observed["url"] = url
1353 return FakeResponse()
1354
1355 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1356 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1357 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
1358 client = TestClient(app)
1359 create(client, connector_id, user_id)
1360 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
1361 token = login.split("loginToken=", 1)[1]
1362
1363 response = client.get(
1364 f"/connector-sessions/{connector_id}/upstream/api.ibkr.com/Authenticator?loginToken={token}"
1365 )
1366
1367 assert response.status_code == 200
1368 assert observed["method"] == "GET"
1369 assert observed["url"] == "https://api.ibkr.com/Authenticator"
1370 assert observed["url"] != "https://gateway.internal/Authenticator"
1371
1372
1373 def test_escaped_gateway_authenticator_maps_to_sso_authenticator(monkeypatch, caplog) -> None:
1374 connector_id = uuid4()
1375 user_id = uuid4()
1376 observed = {}
1377
1378 async def fake_gateway_get(path, session=None):
1379 return {"authenticated": False, "connected": True}
1380
1381 class FakeResponse:
1382 status_code = 200
1383 content = b"{}"
1384 encoding = "utf-8"
1385 headers = httpx.Headers({"content-type": "application/json"})
1386
1387 @property
1388 def text(self):
1389 return self.content.decode("utf-8")
1390
1391 class FakeClient:
1392 def __init__(self, verify, timeout):
1393 return None
1394
1395 async def __aenter__(self):
1396 return self
1397
1398 async def __aexit__(self, exc_type, exc, tb):
1399 return None
1400
1401 async def request(self, method, url, params, content, headers, follow_redirects):
1402 observed["method"] = method
1403 observed["url"] = url
1404 observed["content"] = content
1405 observed["content_type"] = headers.get("content-type")
1406 observed["cookie"] = headers.get("cookie", "")
1407 observed["params"] = params
1408 return FakeResponse()
1409
1410 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1411 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1412 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
1413 client = TestClient(app)
1414 create(client, connector_id, user_id)
1415 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
1416 token = login.split("loginToken=", 1)[1]
1417 client.get(f"/connector-sessions/{connector_id}/login/sso/Login?loginToken={token}")
1418
1419 with caplog.at_level("INFO"):
1420 response = client.post(
1421 "/connector-sessions/Authenticator?loginToken=stale-token&step=1",
1422 content=b"username=user&password=secret",
1423 headers={
1424 "content-type": "application/x-www-form-urlencoded",
1425 "cookie": (
1426 f"aip_ibkr_login_{connector_id.hex}={token}; "
1427 f"aip_ibkr_login_context={connector_id}; SBID=allowed; IBKR_SESSION=gateway-session"
1428 ),
1429 },
1430 )
1431
1432 assert response.status_code == 200
1433 assert observed["method"] == "POST"
1434 assert observed["url"] == "https://gateway.internal/sso/Authenticator"
1435 assert observed["content"] == b"username=user&password=secret"
1436 assert observed["content_type"] == "application/x-www-form-urlencoded"
1437 assert observed["cookie"] == "SBID=allowed; IBKR_SESSION=gateway-session"
1438 assert observed["params"] == {"step": "1"}
1439 assert "recoveryMapping=SSO" in caplog.text
1440 assert "browserPath=/Authenticator" in caplog.text
1441 assert "gatewayPath=/sso/Authenticator" in caplog.text
1442 assert "method=POST" in caplog.text
1443 assert "password=secret" not in caplog.text
1444 assert token not in caplog.text
1445
1446
1447 def test_escaped_gateway_dispatcher_maps_to_sso_dispatcher(monkeypatch) -> None:
1448 connector_id = uuid4()
1449 user_id = uuid4()
1450 observed = {}
1451
1452 async def fake_gateway_get(path, session=None):
1453 return {"authenticated": False, "connected": True}
1454
1455 class FakeResponse:
1456 status_code = 200
1457 content = b"{}"
1458 encoding = "utf-8"
1459 headers = httpx.Headers({"content-type": "application/json"})
1460
1461 @property
1462 def text(self):
1463 return self.content.decode("utf-8")
1464
1465 class FakeClient:
1466 def __init__(self, verify, timeout):
1467 return None
1468
1469 async def __aenter__(self):
1470 return self
1471
1472 async def __aexit__(self, exc_type, exc, tb):
1473 return None
1474
1475 async def request(self, method, url, params, content, headers, follow_redirects):
1476 observed["method"] = method
1477 observed["url"] = url
1478 observed["content"] = content
1479 observed["content_type"] = headers.get("content-type")
1480 return FakeResponse()
1481
1482 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1483 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1484 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
1485 client = TestClient(app)
1486 create(client, connector_id, user_id)
1487 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
1488 token = login.split("loginToken=", 1)[1]
1489
1490 response = client.post(
1491 "/connector-sessions/Dispatcher",
1492 content=b"ACTION=NEXT",
1493 headers={
1494 "content-type": "application/x-www-form-urlencoded",
1495 "Sec-Fetch-Dest": "document",
1496 "Sec-Fetch-Mode": "navigate",
1497 "cookie": f"aip_ibkr_login_{connector_id.hex}={token}; aip_ibkr_login_context={connector_id}",
1498 },
1499 )
1500
1501 assert response.status_code == 200
1502 assert observed["method"] == "POST"
1503 assert observed["url"] == "https://gateway.internal/sso/Dispatcher"
1504 assert observed["content"] == b"ACTION=NEXT"
1505 assert observed["content_type"] == "application/x-www-form-urlencoded"
1506
1507
1508 def test_connector_scoped_login_dispatcher_targets_sso_dispatcher_and_preserves_response(monkeypatch) -> None:
1509 connector_id = uuid4()
1510 user_id = uuid4()
1511 observed = {}
1512
1513 async def fake_gateway_get(path, session=None):
1514 return {"authenticated": False, "connected": True}
1515
1516 class FakeResponse:
1517 status_code = 200
1518 content = b"<html>dispatcher ok</html>"
1519 encoding = "utf-8"
1520 headers = httpx.Headers({"content-type": "text/html;charset=UTF-8"})
1521
1522 @property
1523 def text(self):
1524 return self.content.decode("utf-8")
1525
1526 class FakeClient:
1527 def __init__(self, verify, timeout):
1528 return None
1529
1530 async def __aenter__(self):
1531 return self
1532
1533 async def __aexit__(self, exc_type, exc, tb):
1534 return None
1535
1536 async def request(self, method, url, params, content, headers, follow_redirects):
1537 observed["method"] = method
1538 observed["url"] = url
1539 observed["content"] = content
1540 observed["content_type"] = headers.get("content-type")
1541 return FakeResponse()
1542
1543 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1544 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1545 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
1546 client = TestClient(app)
1547 create(client, connector_id, user_id)
1548 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
1549 token = login.split("loginToken=", 1)[1]
1550
1551 response = client.post(
1552 f"/connector-sessions/{connector_id}/login/sso/Dispatcher?loginToken={token}",
1553 content=b"ACTION=NEXT",
1554 headers={
1555 "content-type": "application/x-www-form-urlencoded",
1556 "Sec-Fetch-Dest": "document",
1557 "Sec-Fetch-Mode": "navigate",
1558 "cookie": f"aip_ibkr_login_{connector_id.hex}={token}; aip_ibkr_login_context={connector_id}",
1559 },
1560 )
1561
1562 assert response.status_code == 200
1563 assert response.headers["content-type"] == "text/html;charset=UTF-8"
1564 assert b"<html>dispatcher ok</html>" in response.content
1565 assert b"/completion-monitor.js" in response.content
1566 assert observed["method"] == "POST"
1567 assert observed["url"] == "https://gateway.internal/sso/Dispatcher"
1568 assert observed["content"] == b"ACTION=NEXT"
1569 assert observed["content_type"] == "application/x-www-form-urlencoded"
1570
1571
1572 def test_unknown_escaped_recovery_path_is_not_sso_prefixed(monkeypatch) -> None:
1573 connector_id = uuid4()
1574 user_id = uuid4()
1575 observed = {}
1576
1577 async def fake_gateway_get(path, session=None):
1578 return {"authenticated": False, "connected": True}
1579
1580 class FakeResponse:
1581 status_code = 200
1582 content = b"{}"
1583 encoding = "utf-8"
1584 headers = httpx.Headers({"content-type": "application/json"})
1585
1586 @property
1587 def text(self):
1588 return self.content.decode("utf-8")
1589
1590 class FakeClient:
1591 def __init__(self, verify, timeout):
1592 return None
1593
1594 async def __aenter__(self):
1595 return self
1596
1597 async def __aexit__(self, exc_type, exc, tb):
1598 return None
1599
1600 async def request(self, method, url, params, content, headers, follow_redirects):
1601 observed["url"] = url
1602 return FakeResponse()
1603
1604 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1605 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1606 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
1607 client = TestClient(app)
1608 create(client, connector_id, user_id)
1609 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
1610 token = login.split("loginToken=", 1)[1]
1611
1612 response = client.post(
1613 "/connector-sessions/UnknownEndpoint",
1614 headers={"cookie": f"aip_ibkr_login_{connector_id.hex}={token}; aip_ibkr_login_context={connector_id}"},
1615 )
1616
1617 assert response.status_code == 200
1618 assert observed["url"] == "https://gateway.internal/UnknownEndpoint"
1619
1620
1621 def test_upstream_report_targets_api_ibkr(monkeypatch) -> None:
1622 connector_id = uuid4()
1623 user_id = uuid4()
1624 observed = {}
1625
1626 async def fake_gateway_get(path, session=None):
1627 return {"authenticated": False, "connected": True}
1628
1629 class FakeResponse:
1630 status_code = 204
1631 content = b""
1632 encoding = "utf-8"
1633 headers = httpx.Headers()
1634
1635 @property
1636 def text(self):
1637 return self.content.decode("utf-8")
1638
1639 class FakeClient:
1640 def __init__(self, verify, timeout):
1641 return None
1642
1643 async def __aenter__(self):
1644 return self
1645
1646 async def __aexit__(self, exc_type, exc, tb):
1647 return None
1648
1649 async def request(self, method, url, params, content, headers, follow_redirects):
1650 observed["url"] = url
1651 return FakeResponse()
1652
1653 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1654 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1655 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
1656 client = TestClient(app)
1657 create(client, connector_id, user_id)
1658 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
1659 token = login.split("loginToken=", 1)[1]
1660
1661 response = client.get(f"/connector-sessions/{connector_id}/upstream/api.ibkr.com/report?loginToken={token}")
1662
1663 assert response.status_code == 204
1664 assert observed["url"] == "https://api.ibkr.com/report"
1665
1666
1667 def test_upstream_query_is_preserved_without_login_token(monkeypatch) -> None:
1668 connector_id = uuid4()
1669 user_id = uuid4()
1670 observed = {}
1671
1672 async def fake_gateway_get(path, session=None):
1673 return {"authenticated": False, "connected": True}
1674
1675 class FakeResponse:
1676 status_code = 200
1677 content = b"{}"
1678 encoding = "utf-8"
1679 headers = httpx.Headers({"content-type": "application/json"})
1680
1681 @property
1682 def text(self):
1683 return self.content.decode("utf-8")
1684
1685 class FakeClient:
1686 def __init__(self, verify, timeout):
1687 return None
1688
1689 async def __aenter__(self):
1690 return self
1691
1692 async def __aexit__(self, exc_type, exc, tb):
1693 return None
1694
1695 async def request(self, method, url, params, content, headers, follow_redirects):
1696 observed["url"] = url
1697 observed["params"] = params
1698 return FakeResponse()
1699
1700 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1701 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1702 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
1703 client = TestClient(app)
1704 create(client, connector_id, user_id)
1705 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
1706 token = login.split("loginToken=", 1)[1]
1707
1708 response = client.get(
1709 f"/connector-sessions/{connector_id}/upstream/api.ibkr.com/Authenticator?loginToken={token}&a=1"
1710 )
1711
1712 assert response.status_code == 200
1713 assert observed["url"] == "https://api.ibkr.com/Authenticator"
1714 assert observed["params"] == {"a": "1"}
1715
1716
1717 @pytest.mark.parametrize(
1718 "host",
1719 [
1720 "example.com",
1721 "localhost",
1722 "127.0.0.1",
1723 "169.254.169.254",
1724 "api.ibkr.com.",
1725 "api.ibkr.com:443",
1726 "user@api.ibkr.com",
1727 "https:%2f%2fapi.ibkr.com",
1728 ],
1729 )
1730 def test_disallowed_upstream_hosts_are_rejected(monkeypatch, host) -> None:
1731 connector_id = uuid4()
1732 user_id = uuid4()
1733 observed = {"proxied": False}
1734
1735 async def fake_gateway_get(path, session=None):
1736 return {"authenticated": False, "connected": True}
1737
1738 class FakeClient:
1739 def __init__(self, verify, timeout):
1740 return None
1741
1742 async def __aenter__(self):
1743 return self
1744
1745 async def __aexit__(self, exc_type, exc, tb):
1746 return None
1747
1748 async def request(self, method, url, params, content, headers, follow_redirects):
1749 observed["proxied"] = True
1750 raise AssertionError("Disallowed upstream host should not be proxied.")
1751
1752 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1753 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1754 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
1755 client = TestClient(app)
1756 create(client, connector_id, user_id)
1757 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
1758 token = login.split("loginToken=", 1)[1]
1759
1760 response = client.get(f"/connector-sessions/{connector_id}/upstream/{host}/test?loginToken={token}")
1761
1762 assert response.status_code == 404
1763 assert observed["proxied"] is False
1764
1765
1766 def test_upstream_route_preserves_connector_user_isolation(monkeypatch) -> None:
1767 connector_id = uuid4()
1768 user_id = uuid4()
1769 other_id = uuid4()
1770
1771 async def fake_gateway_get(path, session=None):
1772 return {"authenticated": False, "connected": True}
1773
1774 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1775 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1776 client = TestClient(app)
1777 create(client, connector_id, user_id)
1778
1779 response = client.get(
1780 f"/connector-sessions/{connector_id}/upstream/api.ibkr.com/Authenticator",
1781 headers={"cookie": f"aip_ibkr_login_{connector_id.hex}=invalid"},
1782 )
1783 other = client.get(f"/internal/connectors/{connector_id}/status", headers=headers(other_id))
1784
1785 assert response.status_code == 409
1786 assert other.status_code == 403
1787
1788
1789 def test_upstream_does_not_send_login_token_or_gateway_cookies(monkeypatch) -> None:
1790 connector_id = uuid4()
1791 user_id = uuid4()
1792 observed = {}
1793
1794 async def fake_gateway_get(path, session=None):
1795 return {"authenticated": False, "connected": True}
1796
1797 class FakeResponse:
1798 status_code = 200
1799 content = b"{}"
1800 encoding = "utf-8"
1801 headers = httpx.Headers({"content-type": "application/json"})
1802
1803 @property
1804 def text(self):
1805 return self.content.decode("utf-8")
1806
1807 class FakeClient:
1808 def __init__(self, verify, timeout):
1809 return None
1810
1811 async def __aenter__(self):
1812 return self
1813
1814 async def __aexit__(self, exc_type, exc, tb):
1815 return None
1816
1817 async def request(self, method, url, params, content, headers, follow_redirects):
1818 observed["params"] = params
1819 observed["cookie"] = headers.get("cookie", "")
1820 return FakeResponse()
1821
1822 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1823 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1824 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
1825 client = TestClient(app)
1826 create(client, connector_id, user_id)
1827 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
1828 token = login.split("loginToken=", 1)[1]
1829 session = manager.sessions[connector_id]
1830 session.gateway_cookies["IBKR_GATEWAY"] = "gateway-secret"
1831
1832 response = client.get(
1833 f"/connector-sessions/{connector_id}/upstream/api.ibkr.com/Authenticator?loginToken={token}&a=1",
1834 headers={"cookie": f"aip_ibkr_login_{connector_id.hex}={token}; IBKR_GATEWAY=gateway-secret; API_COOKIE=api"},
1835 )
1836
1837 assert response.status_code == 200
1838 assert observed["params"] == {"a": "1"}
1839 assert observed["cookie"] == "API_COOKIE=api"
1840 assert token not in observed["cookie"]
1841 assert "gateway-secret" not in observed["cookie"]
1842
1843
1844 def test_upstream_keeps_same_named_sso_cookie_separate_from_gateway_cookie(monkeypatch) -> None:
1845 """An SSO JSESSIONID must not be discarded because Gateway has one too."""
1846 connector_id = uuid4()
1847 user_id = uuid4()
1848 observed = []
1849
1850 async def fake_gateway_get(path, session=None):
1851 return {"authenticated": False, "connected": True}
1852
1853 class FakeResponse:
1854 status_code = 200
1855 content = b"<html></html>"
1856 encoding = "utf-8"
1857
1858 def __init__(self, headers):
1859 self.headers = httpx.Headers(headers)
1860
1861 @property
1862 def text(self):
1863 return self.content.decode("utf-8")
1864
1865 class FakeClient:
1866 def __init__(self, verify, timeout):
1867 return None
1868
1869 async def __aenter__(self):
1870 return self
1871
1872 async def __aexit__(self, exc_type, exc, tb):
1873 return None
1874
1875 async def request(self, method, url, params, content, headers, follow_redirects):
1876 observed.append((url, headers.get("cookie", "")))
1877 if "api.ibkr.com" in url:
1878 return FakeResponse(
1879 [("set-cookie", "JSESSIONID=sso-new; Path=/; HttpOnly"), ("content-type", "text/html")]
1880 )
1881 return FakeResponse(
1882 [("set-cookie", "JSESSIONID=gateway-old; Path=/; HttpOnly"), ("content-type", "text/html")]
1883 )
1884
1885 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1886 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1887 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
1888 client = TestClient(app)
1889 create(client, connector_id, user_id)
1890 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
1891 token = login.split("loginToken=", 1)[1]
1892
1893 gateway = client.get(f"/connector-sessions/{connector_id}/login/sso/Login?loginToken={token}")
1894 sso = client.get(f"/connector-sessions/{connector_id}/upstream/api.ibkr.com/Authenticator?loginToken={token}")
1895 followup = client.get(f"/connector-sessions/{connector_id}/upstream/api.ibkr.com/report?loginToken={token}")
1896
1897 assert gateway.status_code == sso.status_code == followup.status_code == 200
1898 assert f"Path=/connector-sessions/{connector_id}/login" in gateway.headers.get_list("set-cookie")[0]
1899 assert f"Path=/connector-sessions/{connector_id}/upstream/api.ibkr.com" in sso.headers.get_list("set-cookie")[0]
1900 assert observed[-1][1] == "JSESSIONID=sso-new"
1901
1902
1903 def test_upstream_trading_paths_are_blocked(monkeypatch) -> None:
1904 connector_id = uuid4()
1905 user_id = uuid4()
1906 observed = {"proxied": False}
1907
1908 async def fake_gateway_get(path, session=None):
1909 return {"authenticated": False, "connected": True}
1910
1911 class FakeClient:
1912 def __init__(self, verify, timeout):
1913 return None
1914
1915 async def __aenter__(self):
1916 return self
1917
1918 async def __aexit__(self, exc_type, exc, tb):
1919 return None
1920
1921 async def request(self, method, url, params, content, headers, follow_redirects):
1922 observed["proxied"] = True
1923 raise AssertionError("Trading path should not be proxied.")
1924
1925 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1926 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1927 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
1928 client = TestClient(app)
1929 create(client, connector_id, user_id)
1930 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
1931 token = login.split("loginToken=", 1)[1]
1932
1933 response = client.post(
1934 f"/connector-sessions/{connector_id}/upstream/api.ibkr.com/iserver/account/orders?loginToken={token}"
1935 )
1936
1937 assert response.status_code == 404
1938 assert observed["proxied"] is False
1939
1940
1941 def test_gateway_redirect_to_upstream_then_targets_api_ibkr(monkeypatch) -> None:
1942 connector_id = uuid4()
1943 user_id = uuid4()
1944 observed = []
1945
1946 async def fake_gateway_get(path, session=None):
1947 return {"authenticated": False, "connected": True}
1948
1949 class FakeResponse:
1950 status_code = 302
1951 content = b""
1952 encoding = "utf-8"
1953
1954 def __init__(self, location):
1955 self.headers = httpx.Headers({"location": location})
1956
1957 @property
1958 def text(self):
1959 return self.content.decode("utf-8")
1960
1961 class FakeClient:
1962 def __init__(self, verify, timeout):
1963 return None
1964
1965 async def __aenter__(self):
1966 return self
1967
1968 async def __aexit__(self, exc_type, exc, tb):
1969 return None
1970
1971 async def request(self, method, url, params, content, headers, follow_redirects):
1972 observed.append((method, url))
1973 if url == "https://gateway.internal/Authenticator":
1974 return FakeResponse("https://api.ibkr.com/Authenticator")
1975 if url == "https://api.ibkr.com/Authenticator":
1976 return FakeResponse("/next")
1977 raise AssertionError(url)
1978
1979 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
1980 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
1981 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
1982 client = TestClient(app)
1983 create(client, connector_id, user_id)
1984 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
1985 token = login.split("loginToken=", 1)[1]
1986
1987 first = client.post(
1988 f"/connector-sessions/{connector_id}/login/Authenticator?loginToken={token}",
1989 headers={"cookie": f"aip_ibkr_login_context={connector_id}; aip_ibkr_login_{connector_id.hex}={token}"},
1990 follow_redirects=False,
1991 )
1992
1993 assert first.status_code == 302
1994 assert first.headers["location"] == f"/connector-sessions/{connector_id}/next"
1995 assert observed == [
1996 ("POST", "https://gateway.internal/Authenticator"),
1997 ("POST", "https://api.ibkr.com/Authenticator"),
1998 ]
1999
2000
2001 def test_unrelated_external_redirect_location_is_not_rewritten(monkeypatch) -> None:
2002 connector_id = uuid4()
2003 user_id = uuid4()
2004
2005 async def fake_gateway_get(path, session=None):
2006 return {"authenticated": False, "connected": True}
2007
2008 class FakeResponse:
2009 status_code = 302
2010 content = b""
2011 encoding = "utf-8"
2012 headers = httpx.Headers({"location": "https://example.com/help"})
2013
2014 @property
2015 def text(self):
2016 return self.content.decode("utf-8")
2017
2018 class FakeClient:
2019 def __init__(self, verify, timeout):
2020 return None
2021
2022 async def __aenter__(self):
2023 return self
2024
2025 async def __aexit__(self, exc_type, exc, tb):
2026 return None
2027
2028 async def request(self, method, url, params, content, headers, follow_redirects):
2029 return FakeResponse()
2030
2031 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
2032 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
2033 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
2034 client = TestClient(app)
2035 create(client, connector_id, user_id)
2036 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
2037 token = login.split("loginToken=", 1)[1]
2038
2039 response = client.get(f"/connector-sessions/{connector_id}/login/sso/Login?loginToken={token}", follow_redirects=False)
2040
2041 assert response.status_code == 302
2042 assert response.headers["location"] == "https://example.com/help"
2043
2044
2045 def test_login_proxy_captures_gateway_cookies_for_internal_api_calls(monkeypatch) -> None:
2046 connector_id = uuid4()
2047 user_id = uuid4()
2048 observed = {}
2049
2050 async def fake_gateway_get(path, session=None):
2051 observed["status_cookie_names"] = sorted((session.gateway_cookies or {}).keys()) if session else []
2052 return {"authenticated": True, "connected": True, "established": True}
2053
2054 class FakeResponse:
2055 status_code = 200
2056 content = b"<html></html>"
2057 encoding = "utf-8"
2058 headers = httpx.Headers(
2059 [
2060 ("set-cookie", "JSESSIONID=gateway-session; Path=/; HttpOnly; Secure; SameSite=None"),
2061 ("set-cookie", "x-sess-uuid=gateway-uuid; Path=/; HttpOnly; Secure"),
2062 ("content-type", "text/html;charset=utf-8"),
2063 ]
2064 )
2065
2066 @property
2067 def text(self):
2068 return self.content.decode("utf-8")
2069
2070 class FakeClient:
2071 def __init__(self, verify, timeout):
2072 return None
2073
2074 async def __aenter__(self):
2075 return self
2076
2077 async def __aexit__(self, exc_type, exc, tb):
2078 return None
2079
2080 async def request(self, method, url, params, content, headers, follow_redirects):
2081 observed["proxied_cookie_names"] = sorted(
2082 name.strip().split("=", 1)[0] for name in headers.get("cookie", "").split(";") if name
2083 )
2084 return FakeResponse()
2085
2086 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
2087 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
2088 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
2089 client = TestClient(app)
2090 create(client, connector_id, user_id)
2091 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
2092 token = login.split("loginToken=", 1)[1]
2093
2094 client.get(
2095 f"/connector-sessions/{connector_id}/login/sso/Login?loginToken={token}",
2096 headers={"cookie": f"aip_ibkr_login_{connector_id.hex}={token}; IBKR_EXISTING=old"},
2097 )
2098 status = client.get(f"/internal/connectors/{connector_id}/status", headers=headers(user_id))
2099
2100 assert status.json()["authStatus"] == ConnectorState.CONNECTED
2101 assert observed["proxied_cookie_names"] == []
2102 assert observed["status_cookie_names"] == ["JSESSIONID", "x-sess-uuid"]
2103
2104
2105 def test_escaped_root_relative_login_request_uses_connector_context_and_gateway_cookie_jar(monkeypatch) -> None:
2106 connector_id = uuid4()
2107 user_id = uuid4()
2108 observed = {}
2109 calls = []
2110
2111 async def fake_gateway_get(path, session=None):
2112 return {"authenticated": False, "connected": True}
2113
2114 class FakeResponse:
2115 status_code = 200
2116 encoding = "utf-8"
2117
2118 def __init__(self, content: bytes, headers):
2119 self.content = content
2120 self.headers = httpx.Headers(headers)
2121
2122 @property
2123 def text(self):
2124 return self.content.decode("utf-8")
2125
2126 class FakeClient:
2127 def __init__(self, verify, timeout):
2128 return None
2129
2130 async def __aenter__(self):
2131 return self
2132
2133 async def __aexit__(self, exc_type, exc, tb):
2134 return None
2135
2136 async def request(self, method, url, params, content, headers, follow_redirects):
2137 calls.append(url)
2138 if url.endswith("/sso/Login"):
2139 return FakeResponse(
2140 b"<html></html>",
2141 [
2142 ("set-cookie", "IBKR_SESSION=gateway-session; Path=/; HttpOnly"),
2143 ("content-type", "text/html"),
2144 ],
2145 )
2146 observed.update(
2147 {
2148 "method": method,
2149 "url": url,
2150 "params": params,
2151 "content": content,
2152 "cookie": headers.get("cookie"),
2153 "origin": headers.get("origin"),
2154 "referer": headers.get("referer"),
2155 }
2156 )
2157 return FakeResponse(b"{}", {"content-type": "application/json"})
2158
2159 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
2160 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
2161 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
2162 client = TestClient(app)
2163 create(client, connector_id, user_id)
2164 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
2165 token = login.split("loginToken=", 1)[1]
2166
2167 client.get(f"/connector-sessions/{connector_id}/login/sso/Login?loginToken={token}")
2168 response = client.post(
2169 "/connector-sessions/Authenticator?step=1",
2170 content=b"username=user&password=secret",
2171 headers={
2172 "content-type": "application/x-www-form-urlencoded",
2173 "origin": "http://testserver",
2174 "referer": f"http://testserver/connector-sessions/{connector_id}/login/sso/Login?loginToken={token}",
2175 },
2176 )
2177
2178 assert response.status_code == 200
2179 assert observed["method"] == "POST"
2180 assert observed["url"] == "https://gateway.internal/sso/Authenticator"
2181 assert observed["params"] == {"step": "1"}
2182 assert observed["content"] == b"username=user&password=secret"
2183 assert observed["cookie"] == "IBKR_SESSION=gateway-session"
2184 assert observed["origin"] == "https://gateway.internal"
2185 assert observed["referer"] == "https://gateway.internal/sso/Login"
2186
2187
2188 def test_escaped_connector_relative_login_request_preserves_connector_isolation(monkeypatch) -> None:
2189 connector_id = uuid4()
2190 user_id = uuid4()
2191 observed = {}
2192
2193 async def fake_gateway_get(path, session=None):
2194 return {"authenticated": False, "connected": True}
2195
2196 class FakeResponse:
2197 status_code = 200
2198 content = b"{}"
2199 encoding = "utf-8"
2200 headers = httpx.Headers({"content-type": "application/json"})
2201
2202 @property
2203 def text(self):
2204 return self.content.decode("utf-8")
2205
2206 class FakeClient:
2207 def __init__(self, verify, timeout):
2208 return None
2209
2210 async def __aenter__(self):
2211 return self
2212
2213 async def __aexit__(self, exc_type, exc, tb):
2214 return None
2215
2216 async def request(self, method, url, params, content, headers, follow_redirects):
2217 observed["url"] = url
2218 return FakeResponse()
2219
2220 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
2221 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
2222 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
2223 client = TestClient(app)
2224 create(client, connector_id, user_id)
2225 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
2226 token = login.split("loginToken=", 1)[1]
2227
2228 invalid = client.post(f"/connector-sessions/{uuid4()}/Authenticator")
2229 valid = client.post(
2230 f"/connector-sessions/{connector_id}/Authenticator",
2231 headers={"cookie": f"aip_ibkr_login_{connector_id.hex}={token}"},
2232 )
2233
2234 assert invalid.status_code == 409
2235 assert valid.status_code == 200
2236 assert observed["url"] == "https://gateway.internal/sso/Authenticator"
2237
2238
2239 def test_escaped_login_request_with_invalid_context_cookie_is_denied(monkeypatch) -> None:
2240 connector_id = uuid4()
2241 user_id = uuid4()
2242
2243 async def fake_gateway_get(path, session=None):
2244 return {"authenticated": False, "connected": True}
2245
2246 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
2247 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
2248 client = TestClient(app)
2249 create(client, connector_id, user_id)
2250
2251 response = client.post(
2252 "/connector-sessions/Authenticator",
2253 headers={"cookie": f"aip_ibkr_login_context={connector_id}; aip_ibkr_login_{connector_id.hex}=expired-token"},
2254 )
2255
2256 assert response.status_code == 409
2257 assert response.json()["code"] == "LOGIN_TOKEN_INVALID"
2258
2259
2260 def test_connector_scoped_trading_paths_are_blocked_before_proxy(monkeypatch) -> None:
2261 connector_id = uuid4()
2262 user_id = uuid4()
2263 observed = {"proxied": False}
2264
2265 async def fake_gateway_get(path, session=None):
2266 return {"authenticated": False, "connected": True}
2267
2268 class FakeClient:
2269 def __init__(self, verify, timeout):
2270 return None
2271
2272 async def __aenter__(self):
2273 return self
2274
2275 async def __aexit__(self, exc_type, exc, tb):
2276 return None
2277
2278 async def request(self, method, url, params, content, headers, follow_redirects):
2279 observed["proxied"] = True
2280 raise AssertionError("Trading path should not be proxied.")
2281
2282 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
2283 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
2284 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
2285 client = TestClient(app)
2286 create(client, connector_id, user_id)
2287 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
2288 token = login.split("loginToken=", 1)[1]
2289
2290 response = client.post(
2291 f"/connector-sessions/{connector_id}/iserver/account/orders",
2292 headers={"cookie": f"aip_ibkr_login_{connector_id.hex}={token}"},
2293 )
2294
2295 assert response.status_code == 404
2296 assert observed["proxied"] is False
2297
2298
2299 @pytest.mark.parametrize(
2300 ("method", "content_type", "body"),
2301 [
2302 ("POST", "application/x-www-form-urlencoded", b"username=user&password=secret"),
2303 ("POST", "application/json", b'{"username":"user","password":"secret"}'),
2304 (
2305 "POST",
2306 "multipart/form-data; boundary=AIP",
2307 b"--AIP\r\nContent-Disposition: form-data; name=\"password\"\r\n\r\nsecret\r\n--AIP--\r\n",
2308 ),
2309 ("PUT", "application/json", b'{"mfa":"123456"}'),
2310 ],
2311 )
2312 def test_login_proxy_forwards_supported_request_bodies_without_inspection(
2313 monkeypatch, caplog, method, content_type, body
2314 ) -> None:
2315 connector_id = uuid4()
2316 user_id = uuid4()
2317 observed = {}
2318
2319 async def fake_gateway_get(path, session=None):
2320 return {"authenticated": False, "connected": True}
2321
2322 class FakeResponse:
2323 status_code = 200
2324 content = b"{}"
2325 encoding = "utf-8"
2326 headers = httpx.Headers({"content-type": "application/json"})
2327
2328 @property
2329 def text(self):
2330 return self.content.decode("utf-8")
2331
2332 class FakeClient:
2333 def __init__(self, verify, timeout):
2334 return None
2335
2336 async def __aenter__(self):
2337 return self
2338
2339 async def __aexit__(self, exc_type, exc, tb):
2340 return None
2341
2342 async def request(self, method, url, params, content, headers, follow_redirects):
2343 observed["method"] = method
2344 observed["content"] = content
2345 observed["content_type"] = headers.get("content-type")
2346 return FakeResponse()
2347
2348 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
2349 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
2350 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
2351 client = TestClient(app)
2352 create(client, connector_id, user_id)
2353 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
2354 token = login.split("loginToken=", 1)[1]
2355
2356 with caplog.at_level("INFO"):
2357 response = client.request(
2358 method,
2359 f"/connector-sessions/{connector_id}/login/sso/Validate?loginToken={token}",
2360 content=body,
2361 headers={"content-type": content_type},
2362 )
2363
2364 assert response.status_code == 200
2365 assert observed["method"] == method
2366 assert observed["content"] == body
2367 assert observed["content_type"] == content_type
2368 assert "ibkr_login_trace" in caplog.text
2369 assert "secret" not in caplog.text
2370 assert "123456" not in caplog.text
2371 assert token not in caplog.text
2372
2373
2374 def test_login_trace_logs_cookie_names_only(monkeypatch, caplog) -> None:
2375 connector_id = uuid4()
2376 user_id = uuid4()
2377
2378 async def fake_gateway_get(path, session=None):
2379 return {"authenticated": False, "connected": True}
2380
2381 class FakeResponse:
2382 status_code = 302
2383 content = b""
2384 encoding = "utf-8"
2385 headers = httpx.Headers(
2386 [
2387 ("location", "/sso/Next"),
2388 ("set-cookie", "JSESSIONID=response-secret; Path=/; HttpOnly"),
2389 ("content-type", "text/html"),
2390 ]
2391 )
2392
2393 @property
2394 def text(self):
2395 return self.content.decode("utf-8")
2396
2397 class FakeClient:
2398 def __init__(self, verify, timeout):
2399 return None
2400
2401 async def __aenter__(self):
2402 return self
2403
2404 async def __aexit__(self, exc_type, exc, tb):
2405 return None
2406
2407 async def request(self, method, url, params, content, headers, follow_redirects):
2408 return FakeResponse()
2409
2410 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
2411 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
2412 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
2413 client = TestClient(app)
2414 create(client, connector_id, user_id)
2415 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
2416 token = login.split("loginToken=", 1)[1]
2417
2418 with caplog.at_level("INFO"):
2419 client.get(
2420 f"/connector-sessions/{connector_id}/login/sso/Login?loginToken={token}",
2421 headers={"cookie": f"aip_ibkr_login_{connector_id.hex}={token}; IBKR_EXISTING=request-secret"},
2422 follow_redirects=False,
2423 )
2424
2425 assert "setCookieNames=JSESSIONID" in caplog.text
2426 assert "cookieRequestNames=IBKR_EXISTING" in caplog.text
2427 assert "response-secret" not in caplog.text
2428 assert "request-secret" not in caplog.text
2429 assert token not in caplog.text
2430
2431
2432 def test_login_proxy_rewrites_login_resources_and_relative_forms(monkeypatch) -> None:
2433 connector_id = uuid4()
2434 user_id = uuid4()
2435
2436 async def fake_gateway_get(path, session=None):
2437 return {"authenticated": False, "connected": True}
2438
2439 class FakeResponse:
2440 status_code = 200
2441 encoding = "utf-8"
2442 headers = httpx.Headers({"content-type": "text/html"})
2443 content = (
2444 b'<form action="../submit"><input name="username"><input name="password"></form>'
2445 b'<script src="/sso/js/login.js"></script>'
2446 b"<script>var config = { PASSWORD_RESET_URL: '/credential.recovery/login-help' };</script>"
2447 b'<link href="https://gateway.internal/sso/css/login.css">'
2448 )
2449
2450 @property
2451 def text(self):
2452 return self.content.decode("utf-8")
2453
2454 class FakeClient:
2455 def __init__(self, verify, timeout):
2456 return None
2457
2458 async def __aenter__(self):
2459 return self
2460
2461 async def __aexit__(self, exc_type, exc, tb):
2462 return None
2463
2464 async def request(self, method, url, params, content, headers, follow_redirects):
2465 return FakeResponse()
2466
2467 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
2468 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
2469 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
2470 client = TestClient(app)
2471 create(client, connector_id, user_id)
2472 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
2473 token = login.split("loginToken=", 1)[1]
2474
2475 response = client.get(f"/connector-sessions/{connector_id}/login/sso/Login?loginToken={token}")
2476 body = response.text
2477
2478 assert f'action="/connector-sessions/{connector_id}/login/submit?loginToken=' in body
2479 assert f'src="/connector-sessions/{connector_id}/login/sso/js/login.js?loginToken=' in body
2480 assert f"PASSWORD_RESET_URL: '/connector-sessions/{connector_id}/login/credential.recovery/login-help?loginToken=" in body
2481 assert "https://gateway.internal" not in body
2482 assert f'href="/connector-sessions/{connector_id}/login/sso/css/login.css' in body
2483
2484
2485 def test_login_bundle_scopes_its_dynamic_authenticator_post_to_the_connector(monkeypatch) -> None:
2486 connector_id = uuid4()
2487 user_id = uuid4()
2488 observed = {}
2489 bundle_prefix = (
2490 'document.location.protocol+"//"+document.location.host+"/"+'
2491 'document.location.pathname.split("/")[1]+"/"'
2492 )
2493
2494 async def fake_gateway_get(path, session=None):
2495 return {"authenticated": False, "connected": True}
2496
2497 class FakeResponse:
2498 status_code = 200
2499 encoding = "utf-8"
2500
2501 def __init__(self, content, content_type):
2502 self.content = content
2503 self.headers = httpx.Headers({"content-type": content_type})
2504
2505 @property
2506 def text(self):
2507 return self.content.decode("utf-8")
2508
2509 class FakeClient:
2510 def __init__(self, verify, timeout):
2511 return None
2512
2513 async def __aenter__(self):
2514 return self
2515
2516 async def __aexit__(self, exc_type, exc, tb):
2517 return None
2518
2519 async def request(self, method, url, params, content, headers, follow_redirects):
2520 if url == "https://gateway.internal/sso/Login":
2521 return FakeResponse(b'<script src="lib/xyz.bundle.min.js"></script>', "text/html")
2522 if url == "https://gateway.internal/sso/lib/xyz.bundle.min.js":
2523 return FakeResponse(
2524 (
2525 "let o=" + bundle_prefix + ",n=o+\"Authenticator\";"
2526 "return ajaxQuery(n,{ACTION:\"INIT\",USER:user,A:A},\"POST\")"
2527 ).encode(),
2528 "application/javascript",
2529 )
2530 observed.update(
2531 {"method": method, "url": url, "params": params, "body": content, "content_type": headers.get("content-type")}
2532 )
2533 return FakeResponse(b"{}", "application/json")
2534
2535 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
2536 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
2537 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
2538 client = TestClient(app)
2539 create(client, connector_id, user_id)
2540 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
2541 token = login.split("loginToken=", 1)[1]
2542
2543 page = client.get(f"/connector-sessions/{connector_id}/login/sso/Login?loginToken={token}")
2544 bundle = client.get(f"/connector-sessions/{connector_id}/login/sso/lib/xyz.bundle.min.js")
2545 body = b"ACTION=INIT&USER=alice&A=client-proof"
2546 response = client.post(
2547 f"/connector-sessions/{connector_id}/login/sso/Authenticator?locale=en_US",
2548 content=body,
2549 headers={"content-type": "application/x-www-form-urlencoded"},
2550 )
2551
2552 assert f'/connector-sessions/{connector_id}/login/sso/lib/xyz.bundle.min.js?loginToken=' in page.text
2553 assert f'let o="/connector-sessions/{connector_id}/login/sso/",n=o+"Authenticator"' in bundle.text
2554 assert response.status_code == 200
2555 assert observed == {
2556 "method": "POST",
2557 "url": "https://gateway.internal/sso/Authenticator",
2558 "params": {"locale": "en_US"},
2559 "body": body,
2560 "content_type": "application/x-www-form-urlencoded",
2561 }
2562
2563
2564 def test_gateway_tls_verification_is_connector_scoped(monkeypatch) -> None:
2565 settings.aip_ibkr_gateway_tls_verify = False
2566 created = {}
2567
2568 class FakeResponse:
2569 status_code = 200
2570
2571 def raise_for_status(self):
2572 return None
2573
2574 def json(self):
2575 return {"authenticated": True, "connected": True}
2576
2577 class FakeClient:
2578 def __init__(self, verify, timeout):
2579 created["verify"] = verify
2580 created["timeout"] = timeout
2581
2582 async def __aenter__(self):
2583 return self
2584
2585 async def __aexit__(self, exc_type, exc, tb):
2586 return None
2587
2588 async def get(self, url, headers=None):
2589 created["url"] = url
2590 created["headers"] = headers or {}
2591 return FakeResponse()
2592
2593 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
2594
2595 import anyio
2596
2597 result = anyio.run(manager._gateway_get, "/iserver/auth/status")
2598
2599 assert result["authenticated"] is True
2600 assert created["verify"] is False
2601 assert created["url"] == "https://gateway.internal/v1/api/iserver/auth/status"
2602
2603
2604 def test_gateway_tls_verify_environment_false_disables_httpx_verification(monkeypatch) -> None:
2605 monkeypatch.setenv("AIP_IBKR_GATEWAY_TLS_VERIFY", "false")
2606 configured = Settings()
2607 created = {}
2608
2609 class FakeClient:
2610 def __init__(self, verify, timeout):
2611 created["verify"] = verify
2612
2613 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
2614 httpx.AsyncClient(verify=configured.aip_ibkr_gateway_tls_verify, timeout=30)
2615
2616 assert configured.aip_ibkr_gateway_tls_verify is False
2617 assert created["verify"] is False
2618
2619
2620 def test_credentials_are_not_logged(monkeypatch, caplog) -> None:
2621 connector_id = uuid4()
2622 user_id = uuid4()
2623 secret = "super-secret-password"
2624
2625 async def fake_gateway_get(path, session=None):
2626 return {"authenticated": False, "connected": True, "password": secret}
2627
2628 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
2629 monkeypatch.setattr(manager, "_gateway_get", fake_gateway_get)
2630 client = TestClient(app)
2631
2632 with caplog.at_level("INFO"):
2633 create(client, connector_id, user_id)
2634
2635 assert secret not in caplog.text
2636
2637
2638 def test_auth_res_classifier_uses_only_allowlisted_value_free_categories() -> None:
2639 from app.main import _classify_auth_res
2640
2641 assert _classify_auth_res("true") == "TRUE_STRING"
2642 assert _classify_auth_res("false") == "FALSE_STRING"
2643 assert _classify_auth_res("Approved") == "SUCCESS_WORD"
2644 assert _classify_auth_res("waiting") == "PENDING_WORD"
2645 assert _classify_auth_res("denied") == "FAILURE_WORD"
2646 assert _classify_auth_res("") == "EMPTY_STRING"
2647 assert _classify_auth_res("AUTH_COMPLETE_V2") == "OTHER_PROTOCOL"
2648 assert _classify_auth_res("opaque-token-value-that-must-not-be-retained-" * 3) == "OPAQUE"
2649 assert _classify_auth_res(True) == "OPAQUE"
2650
2651
2652 def test_authenticator_state_trace_logs_only_safe_structural_flags(caplog) -> None:
2653 connector_id = uuid4()
2654 response = httpx.Response(
2655 200,
2656 json={
2657 "challenge": True,
2658 "completed": False,
2659 "dispatcher": False,
2660 "auth_res": True,
2661 "isIBKey": True,
2662 "pushSent": False,
2663 "status": "sensitive-state-value",
2664 "token": "secret-token",
2665 },
2666 )
2667
2668 with caplog.at_level("INFO"):
2669 _trace_authenticator_state(connector_id, response)
2670
2671 assert "ibkr_authenticator_state" in caplog.text
2672 assert "challenge=True" in caplog.text
2673 assert "completed=False" in caplog.text
2674 assert "dispatcher=False" in caplog.text
2675 assert "authres=OPAQUE" in caplog.text
2676 assert "isibkey=True" in caplog.text
2677 assert "pushsent=False" in caplog.text
2678 assert "status" in caplog.text
2679 assert "sensitive-state-value" not in caplog.text
2680 assert "secret-token" not in caplog.text
2681
2682
2683 def test_no_proprietary_gateway_package_is_committed() -> None:
2684 forbidden_suffixes = {".zip", ".war", ".jar"}
2685 test_file = Path(__file__).resolve()
2686 service_tree = next(
2687 (
2688 candidate / "services" / "ibkr-connector"
2689 for candidate in test_file.parents
2690 if (candidate / "services" / "ibkr-connector" / "pyproject.toml").is_file()
2691 ),
2692 test_file.parents[1],
2693 )
2694 assert (service_tree / "pyproject.toml").is_file(), "Unable to locate the ibkr-connector source tree"
2695 committed_like_files = [
2696 path for path in service_tree.rglob("*")
2697 if path.is_file()
2698 and path.suffix.lower() in forbidden_suffixes
2699 and not any(part in {".venv", "build", "target"} for part in path.parts)
2700 ]
2701
2702 assert committed_like_files == []
2703
2704
2705 def test_authentication_completion_monitor_notifies_opener_only_after_live_status() -> None:
2706 connector_id = uuid4()
2707 page = _inject_auth_completion_monitor(
2708 b"<html><body>IBKR MFA remains visible</body></html>",
2709 "text/html",
2710 connector_id,
2711 top_level_document=True,
2712 ).decode("utf-8")
2713
2714 assert f"/connector-sessions/{connector_id}/completion-monitor.js" in page
2715 assert "opaque-test-token" not in page
2716 assert "IBKR MFA remains visible" in page
2717 monitor = _completion_monitor_javascript()
2718 assert "aip:ibkr-authenticated" in monitor
2719 assert "if(s.authenticated)" in monitor
2720 assert "window.opener.postMessage" in monitor
2721 assert "window.location.origin" in monitor
2722 assert "window.close()" in monitor
2723 assert "Authentication completed. You may close this window." in monitor
2724
2725
2726 def test_top_level_dispatcher_without_content_type_loads_completion_monitor(monkeypatch) -> None:
2727 connector_id = uuid4()
2728 user_id = uuid4()
2729
2730 async def authenticated_gateway(path, current_session=None):
2731 assert path == "/iserver/auth/status"
2732 return {"authenticated": True, "connected": True, "established": True}
2733
2734 class FakeResponse:
2735 status_code = 200
2736 content = b"Client login succeeds"
2737 encoding = "utf-8"
2738 headers = httpx.Headers({"location": "https://www.interactivebrokers.com/portal/"})
2739
2740 @property
2741 def text(self):
2742 return self.content.decode("utf-8")
2743
2744 class FakeClient:
2745 def __init__(self, verify, timeout):
2746 pass
2747
2748 async def __aenter__(self):
2749 return self
2750
2751 async def __aexit__(self, exc_type, exc, tb):
2752 return None
2753
2754 async def request(self, method, url, params, content, headers, follow_redirects):
2755 assert method == "POST"
2756 assert url == "https://gateway.internal/sso/Dispatcher"
2757 return FakeResponse()
2758
2759 monkeypatch.setattr(manager, "_start_gateway", lambda session: None)
2760 monkeypatch.setattr(manager, "_gateway_get", authenticated_gateway)
2761 monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
2762 client = TestClient(app)
2763 create(client, connector_id, user_id)
2764 login = client.get(f"/internal/connectors/{connector_id}/login", headers=headers(user_id)).json()["loginUrl"]
2765 token = login.split("loginToken=", 1)[1]
2766
2767 dispatcher = client.post(
2768 f"/connector-sessions/{connector_id}/login/sso/Dispatcher?loginToken={token}",
2769 headers={"Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate"},
2770 follow_redirects=False,
2771 )
2772
2773 assert dispatcher.status_code == 200
2774 assert dispatcher.headers["content-type"] == "text/html; charset=utf-8"
2775 assert "Client login succeeds" in dispatcher.text
2776 assert f'/connector-sessions/{connector_id}/completion-monitor.js' in dispatcher.text
2777 monitor = client.get(f"/connector-sessions/{connector_id}/completion-monitor.js")
2778 assert monitor.status_code == 200
2779 assert "aip:ibkr-authenticated" in monitor.text
2780
2781
2782 def test_html_xhr_response_is_not_replaced_with_completion_document() -> None:
2783 connector_id = uuid4()
2784 payload = b"<html><body>MFA polling response</body></html>"
2785
2786 assert _inject_auth_completion_monitor(
2787 payload, "text/html", connector_id, top_level_document=False
2788 ) == payload
2789
2790
2791 def test_authentication_completion_monitor_does_not_modify_mfa_xhr_json() -> None:
2792 connector_id = uuid4()
2793 payload = b'{"status":"MFA_PENDING"}'
2794
2795 assert _inject_auth_completion_monitor(
2796 payload, "application/json", connector_id, top_level_document=False
2797 ) == payload
2798
2799
2800 def test_browser_auth_status_keeps_popup_pending_before_mfa_approval(monkeypatch) -> None:
2801 connector_id = uuid4()
2802 session = runtime.ConnectorSession(connector_id=connector_id, user_id=uuid4(), login_token="opaque")
2803 manager.sessions[connector_id] = session
2804
2805 async def pending(path, current_session=None):
2806 assert path == "/iserver/auth/status"
2807 return {"authenticated": False, "connected": True}
2808
2809 monkeypatch.setattr(manager, "_gateway_get", pending)
2810 response = TestClient(app).get(
2811 f"/connector-sessions/{connector_id}/auth-status?loginToken={session.login_token}"
2812 )
2813
2814 assert response.status_code == 200
2815 assert response.json() == {"authenticated": False}
2816 assert session.auth_status == ConnectorState.AUTHENTICATION_REQUIRED
2817
2818
2819 def test_browser_auth_status_reports_connected_only_after_mfa_approval(monkeypatch) -> None:
2820 connector_id = uuid4()
2821 session = runtime.ConnectorSession(connector_id=connector_id, user_id=uuid4(), login_token="opaque")
2822 manager.sessions[connector_id] = session
2823
2824 async def approved(path, current_session=None):
2825 assert path == "/iserver/auth/status"
2826 return {"authenticated": True, "connected": True, "established": True}
2827
2828 monkeypatch.setattr(manager, "_gateway_get", approved)
2829 response = TestClient(app).get(
2830 f"/connector-sessions/{connector_id}/auth-status?loginToken={session.login_token}"
2831 )
2832
2833 assert response.status_code == 200
2834 assert response.json() == {"authenticated": True}
2835 assert session.auth_status == ConnectorState.CONNECTED
2836
2837
2838 def test_live_authenticated_flag_completes_second_mfa_before_popup_transport_flags_converge(monkeypatch) -> None:
2839 connector_id = uuid4()
2840 previous_auth = datetime(2026, 8, 29, tzinfo=UTC)
2841 session = runtime.ConnectorSession(
2842 connector_id=connector_id,
2843 user_id=uuid4(),
2844 login_token="opaque",
2845 authenticated_at=previous_auth,
2846 )
2847 manager.sessions[connector_id] = session
2848
2849 async def second_mfa_approved(path, current_session=None):
2850 assert path == "/iserver/auth/status"
2851 return {"authenticated": True, "connected": False, "established": False, "competing": False}
2852
2853 monkeypatch.setattr(manager, "_gateway_get", second_mfa_approved)
2854 response = TestClient(app).get(
2855 f"/connector-sessions/{connector_id}/auth-status?loginToken={session.login_token}"
2856 )
2857
2858 assert response.json() == {"authenticated": True}
2859 assert session.auth_status == ConnectorState.CONNECTED
2860 assert session.authenticated_at > previous_auth