xtb-api-python 0.5.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,543 @@
1
+ """CAS (Central Authentication Service) client for XTB xStation5 WebSocket authentication.
2
+
3
+ Handles the complete auth flow: login → TGT → Service Ticket → WebSocket login.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import contextlib
10
+ import hashlib
11
+ import json
12
+ import logging
13
+ import os
14
+ import re
15
+ import stat
16
+ import time
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING
20
+
21
+ import httpx
22
+ from pydantic import BaseModel
23
+
24
+ from xtb_api.types.websocket import (
25
+ CASError,
26
+ CASLoginResult,
27
+ CASLoginSuccess,
28
+ CASLoginTwoFactorRequired,
29
+ )
30
+
31
+ if TYPE_CHECKING:
32
+ from xtb_api.auth.browser_auth import BrowserCASAuth
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class CASServiceTicketResult(BaseModel):
38
+ """Result from service ticket request."""
39
+
40
+ service_ticket: str
41
+ service: str
42
+
43
+
44
+ class CASClientConfig(BaseModel):
45
+ """CAS client configuration."""
46
+
47
+ base_url: str = "https://xstation.xtb.com/signon/"
48
+ timezone_offset: str | None = None
49
+ user_agent: str = "xStation5/2.94.1 (Linux x86_64)"
50
+ cookies_file: Path | str | None = None
51
+ """Path to persist HTTP cookies (CASTGC, device fingerprint, etc.) as JSON.
52
+
53
+ When set, cookies are loaded on client creation and saved after each
54
+ successful login or service-ticket request. This avoids XTB "new device"
55
+ emails on every restart. File is written with ``chmod 0600``.
56
+ """
57
+
58
+
59
+ class CASClient:
60
+ """CAS authentication client for XTB xStation5.
61
+
62
+ Flow:
63
+ 1. login(email, password) → TGT (Ticket Granting Ticket)
64
+ 2. get_service_ticket(tgt, 'xapi5') → ST (Service Ticket)
65
+ 3. Use ST with WebSocket loginWithServiceTicket
66
+
67
+ Critical: Use service='xapi5' for WebSocket, NOT 'abigail' (that's for REST API)
68
+ """
69
+
70
+ _browser_auth: BrowserCASAuth | None = None
71
+
72
+ def __init__(self, config: CASClientConfig | None = None) -> None:
73
+ if config is not None:
74
+ # Avoid mutating caller's config — fill in timezone if missing
75
+ tz = config.timezone_offset if config.timezone_offset is not None else self._get_timezone_offset()
76
+ self._config = config.model_copy(update={"timezone_offset": tz})
77
+ else:
78
+ self._config = CASClientConfig(timezone_offset=self._get_timezone_offset())
79
+ self._http: httpx.AsyncClient | None = None
80
+ self._loop: asyncio.AbstractEventLoop | None = None
81
+ self._cookies_path: Path | None = (
82
+ Path(self._config.cookies_file).expanduser() if self._config.cookies_file else None
83
+ )
84
+
85
+ async def _ensure_http(self) -> httpx.AsyncClient:
86
+ """Get or create the long-lived httpx client, loading persisted cookies.
87
+
88
+ Detects event-loop changes (e.g. after ``asyncio.run()`` in
89
+ ``get_tgt_sync``) and replaces the stale client so we never
90
+ reuse an ``httpx.AsyncClient`` bound to a closed loop.
91
+ """
92
+ current_loop = asyncio.get_running_loop()
93
+ if self._http is None or self._http.is_closed or self._loop is not current_loop:
94
+ if self._http and not self._http.is_closed:
95
+ with contextlib.suppress(Exception):
96
+ await self._http.aclose()
97
+ cookies = self._load_cookies()
98
+ self._http = httpx.AsyncClient(timeout=30.0, cookies=cookies)
99
+ self._loop = current_loop
100
+ return self._http
101
+
102
+ async def aclose(self) -> None:
103
+ """Close the underlying httpx client."""
104
+ if self._http and not self._http.is_closed:
105
+ await self._http.aclose()
106
+ self._http = None
107
+
108
+ async def login(self, email: str, password: str) -> CASLoginResult:
109
+ """Login with email/password using CAS v2 with v1 fallback.
110
+
111
+ Tries CAS v2 first (supports 2FA), falls back to CAS v1 if v2 unavailable.
112
+
113
+ Args:
114
+ email: XTB account email
115
+ password: XTB account password
116
+
117
+ Returns:
118
+ Either success with TGT or 2FA challenge requiring OTP code
119
+
120
+ Raises:
121
+ CASError: If credentials invalid, account blocked, or service unavailable
122
+ """
123
+ try:
124
+ return await self._login_v2(email, password)
125
+ except CASError as e:
126
+ if "UNAUTHORIZED" not in e.code:
127
+ try:
128
+ return await self._login_v1(email, password)
129
+ except CASError:
130
+ raise e from None
131
+ raise
132
+
133
+ async def _login_v2(self, email: str, password: str) -> CASLoginResult:
134
+ """Login using CAS v2 (supports 2FA)."""
135
+ url = f"{self._config.base_url}v2/tickets"
136
+ fingerprint = self._generate_fingerprint(self._config.user_agent)
137
+
138
+ payload = {
139
+ "username": email,
140
+ "password": password,
141
+ "fingerprint": fingerprint,
142
+ "rememberMe": True,
143
+ }
144
+
145
+ headers = {
146
+ "Content-Type": "application/json",
147
+ "Time-Zone": self._config.timezone_offset or "+0000",
148
+ "User-Agent": self._config.user_agent,
149
+ }
150
+
151
+ client = await self._ensure_http()
152
+ resp = await client.post(url, json=payload, headers=headers)
153
+
154
+ if resp.status_code == 401:
155
+ raise CASError("CAS_GET_TGT_UNAUTHORIZED", "Invalid credentials")
156
+
157
+ if not resp.is_success:
158
+ raise CASError(
159
+ "CAS_LOGIN_FAILED",
160
+ f"CAS v2 login failed: {resp.status_code} {resp.text}",
161
+ )
162
+
163
+ result = resp.json()
164
+
165
+ # Handle success (no 2FA)
166
+ if result.get("loginPhase") == "TGT_CREATED" and result.get("ticket"):
167
+ self._save_cookies(client)
168
+ return CASLoginSuccess(
169
+ tgt=result["ticket"],
170
+ expires_at=time.time() + 8 * 3600, # 8 hours
171
+ )
172
+
173
+ # Handle 2FA required
174
+ login_ticket = result.get("loginTicket") or result.get("sessionId") or ""
175
+ if result.get("loginPhase") == "TWO_FACTOR_REQUIRED" and login_ticket:
176
+ return CASLoginTwoFactorRequired(
177
+ login_ticket=login_ticket,
178
+ session_id=result.get("sessionId", login_ticket),
179
+ two_factor_auth_type=result.get("twoFactorAuthType", "SMS"),
180
+ methods=result.get("methods", ["TOTP"]),
181
+ expires_at=time.time() + 5 * 60, # 5 minutes
182
+ )
183
+
184
+ # Handle specific error codes
185
+ code = result.get("code")
186
+ if code:
187
+ match code:
188
+ case "CAS_GET_TGT_UNAUTHORIZED":
189
+ raise CASError(code, "Invalid email or password")
190
+ case "CAS_GET_TGT_TOO_MANY_OTP_ERROR":
191
+ wait = result.get("data", {}).get("otpThrottleTimeRemaining", 60)
192
+ raise CASError(code, f"Too many OTP attempts. Wait {wait}s")
193
+ case "CAS_GET_TGT_OTP_LIMIT_REACHED_ERROR":
194
+ raise CASError(code, "OTP attempt limit reached. Try again later")
195
+ case "CAS_GET_TGT_OTP_ACCESS_BLOCKED_ERROR":
196
+ raise CASError(code, "Account temporarily blocked due to too many failed OTP attempts")
197
+ case _:
198
+ raise CASError(code, result.get("message", "CAS login failed"))
199
+
200
+ raise CASError("CAS_UNEXPECTED_RESPONSE", f"Unexpected login response: {result}")
201
+
202
+ async def _login_v1(self, email: str, password: str) -> CASLoginResult:
203
+ """Login using CAS v1 (fallback, no 2FA support)."""
204
+ url = f"{self._config.base_url}v1/tickets"
205
+
206
+ form_data = {"username": email, "password": password}
207
+
208
+ headers = {
209
+ "User-Agent": self._config.user_agent,
210
+ }
211
+
212
+ client = await self._ensure_http()
213
+ resp = await client.post(url, data=form_data, headers=headers)
214
+
215
+ if resp.status_code == 201:
216
+ location = resp.headers.get("location", "")
217
+ if not location:
218
+ raise CASError(
219
+ "CAS_V1_NO_LOCATION",
220
+ "CAS v1 login succeeded but no Location header found",
221
+ )
222
+
223
+ match = re.search(r"/tickets/([^/]+)$", location)
224
+ if not match:
225
+ raise CASError(
226
+ "CAS_V1_INVALID_LOCATION",
227
+ f"CAS v1 Location header format invalid: {location}",
228
+ )
229
+
230
+ self._save_cookies(client)
231
+ return CASLoginSuccess(
232
+ tgt=match.group(1),
233
+ expires_at=time.time() + 8 * 3600,
234
+ )
235
+
236
+ if resp.status_code == 401:
237
+ raise CASError("CAS_GET_TGT_UNAUTHORIZED", "Invalid credentials")
238
+
239
+ raise CASError(
240
+ "CAS_V1_LOGIN_FAILED",
241
+ f"CAS v1 login failed: {resp.status_code} {resp.text}",
242
+ )
243
+
244
+ async def login_with_two_factor(
245
+ self,
246
+ login_ticket: str,
247
+ code: str,
248
+ two_factor_auth_type: str = "SMS",
249
+ *,
250
+ session_id: str | None = None,
251
+ ) -> CASLoginResult:
252
+ """Submit two-factor authentication code to complete login.
253
+
254
+ Uses the same ``v2/tickets`` endpoint as initial login, with a
255
+ ``loginTicket`` + ``token`` payload — matching the real browser flow.
256
+
257
+ Args:
258
+ login_ticket: Login ticket from initial login (MID-xxx format).
259
+ For backward compat, ``session_id`` kwarg is also accepted
260
+ and used as login_ticket if this arg is empty.
261
+ code: OTP code (6 digits from TOTP/SMS/EMAIL)
262
+ two_factor_auth_type: Auth method, default ``"SMS"``
263
+ session_id: **Deprecated** — alias for ``login_ticket``, kept for
264
+ backward compatibility.
265
+
266
+ Returns:
267
+ TGT if successful, or new 2FA challenge
268
+
269
+ Raises:
270
+ CASError: If code is invalid, rate limited, or account blocked
271
+ """
272
+ # Backward compat: accept session_id as login_ticket
273
+ ticket = login_ticket or session_id or ""
274
+ if not ticket:
275
+ raise CASError("CAS_2FA_MISSING_TICKET", "No login ticket provided")
276
+
277
+ url = f"{self._config.base_url}v2/tickets"
278
+
279
+ payload = {
280
+ "loginTicket": ticket,
281
+ "token": code,
282
+ "fingerprint": self._generate_fingerprint(self._config.user_agent),
283
+ "twoFactorAuthType": two_factor_auth_type,
284
+ }
285
+
286
+ headers = {
287
+ "Content-Type": "application/json;charset=UTF-8",
288
+ "Time-Zone": self._config.timezone_offset or "0",
289
+ "User-Agent": self._config.user_agent,
290
+ }
291
+
292
+ client = await self._ensure_http()
293
+ resp = await client.post(url, json=payload, headers=headers)
294
+
295
+ if not resp.is_success:
296
+ raise CASError(
297
+ "CAS_2FA_REQUEST_FAILED",
298
+ f"2FA request failed: {resp.status_code} {resp.text}",
299
+ )
300
+
301
+ result = resp.json()
302
+
303
+ # Extract TGT from response body or cookies
304
+ tgt = result.get("ticket") or result.get("tgt")
305
+ if not tgt:
306
+ tgt = resp.cookies.get("CASTGT") or resp.cookies.get("CASTGC")
307
+
308
+ if result.get("loginPhase") == "TGT_CREATED" and tgt:
309
+ self._save_cookies(client)
310
+ return CASLoginSuccess(
311
+ tgt=tgt,
312
+ expires_at=time.time() + 8 * 3600,
313
+ )
314
+
315
+ # Some responses return TGT without explicit loginPhase
316
+ if tgt and tgt.startswith("TGT-"):
317
+ self._save_cookies(client)
318
+ return CASLoginSuccess(
319
+ tgt=tgt,
320
+ expires_at=time.time() + 8 * 3600,
321
+ )
322
+
323
+ code_field = result.get("code")
324
+ if code_field:
325
+ raise CASError(code_field, result.get("message", "Two-factor authentication failed"))
326
+
327
+ raise CASError(
328
+ "CAS_2FA_UNEXPECTED_RESPONSE",
329
+ f"Unexpected 2FA response: {result}",
330
+ )
331
+
332
+ async def get_service_ticket(self, tgt: str, service: str = "xapi5") -> CASServiceTicketResult:
333
+ """Get Service Ticket using TGT via CAS v1 endpoint.
334
+
335
+ Args:
336
+ tgt: Ticket Granting Ticket from login()
337
+ service: Service name. Use 'xapi5' for WebSocket, 'abigail' for REST API.
338
+
339
+ Returns:
340
+ Service ticket for the specified service
341
+ """
342
+ return await self._get_service_ticket_v1(tgt, service)
343
+
344
+ async def _get_service_ticket_v1(self, tgt: str, service: str) -> CASServiceTicketResult:
345
+ """Get Service Ticket via CAS v1 endpoint."""
346
+ url = f"{self._config.base_url}v1/tickets/{tgt}"
347
+
348
+ form_data = {"service": service}
349
+
350
+ headers = {
351
+ "User-Agent": self._config.user_agent,
352
+ "Cookie": f"CASTGC={tgt}",
353
+ }
354
+
355
+ client = await self._ensure_http()
356
+ resp = await client.post(url, data=form_data, headers=headers)
357
+
358
+ if resp.status_code == 401:
359
+ raise CASError("CAS_TGT_EXPIRED", "TGT has expired or is invalid")
360
+
361
+ if not resp.is_success:
362
+ raise CASError(
363
+ "CAS_SERVICE_TICKET_FAILED",
364
+ f"CAS v1 service ticket request failed: {resp.status_code} {resp.text}",
365
+ )
366
+
367
+ service_ticket = resp.text.strip()
368
+ if not service_ticket or not service_ticket.startswith("ST-"):
369
+ raise CASError(
370
+ "CAS_INVALID_SERVICE_TICKET",
371
+ f"Invalid service ticket received: {service_ticket}",
372
+ )
373
+
374
+ self._save_cookies(client)
375
+ return CASServiceTicketResult(service_ticket=service_ticket, service=service)
376
+
377
+ async def get_service_ticket_v2(self, tgt: str, service: str = "xapi5") -> CASServiceTicketResult:
378
+ """Get Service Ticket via CAS v2 endpoint (alternative method)."""
379
+ url = f"{self._config.base_url}v2/serviceTicket"
380
+
381
+ payload = {"tgt": tgt, "service": service}
382
+
383
+ headers = {
384
+ "Content-Type": "application/json",
385
+ "Time-Zone": self._config.timezone_offset or "+0000",
386
+ "User-Agent": self._config.user_agent,
387
+ "Cookie": f"CASTGC={tgt}",
388
+ }
389
+
390
+ client = await self._ensure_http()
391
+ resp = await client.post(url, json=payload, headers=headers)
392
+
393
+ if resp.status_code == 401:
394
+ raise CASError("CAS_TGT_EXPIRED", "TGT has expired or is invalid")
395
+
396
+ if not resp.is_success:
397
+ raise CASError(
398
+ "CAS_SERVICE_TICKET_FAILED",
399
+ f"CAS v2 service ticket request failed: {resp.status_code} {resp.text}",
400
+ )
401
+
402
+ result = resp.json()
403
+ service_ticket = result.get("serviceTicket") or result.get("ticket")
404
+
405
+ if not service_ticket or not service_ticket.startswith("ST-"):
406
+ raise CASError(
407
+ "CAS_INVALID_SERVICE_TICKET",
408
+ f"Invalid service ticket received: {service_ticket}",
409
+ )
410
+
411
+ self._save_cookies(client)
412
+ return CASServiceTicketResult(service_ticket=service_ticket, service=service)
413
+
414
+ async def refresh_service_ticket(self, tgt: str, service: str = "xapi5") -> str:
415
+ """Refresh service ticket using existing TGT.
416
+
417
+ Service tickets are single-use and expire after 2-5 minutes.
418
+ """
419
+ try:
420
+ result = await self.get_service_ticket(tgt, service)
421
+ return result.service_ticket
422
+ except CASError as e:
423
+ if e.code == "CAS_TGT_EXPIRED":
424
+ raise CASError("CAS_TGT_EXPIRED", "TGT has expired, please login again") from e
425
+ raise
426
+
427
+ def is_tgt_valid(self, login_result: CASLoginResult) -> bool:
428
+ """Check if TGT is still valid (local expiration check only)."""
429
+ return time.time() < login_result.expires_at
430
+
431
+ def get_tgt_from_result(self, login_result: CASLoginResult) -> str | None:
432
+ """Extract TGT from successful login result."""
433
+ if isinstance(login_result, CASLoginSuccess):
434
+ return login_result.tgt
435
+ return None
436
+
437
+ async def login_with_browser(self, email: str, password: str, *, headless: bool = True) -> CASLoginResult:
438
+ """Login using browser-based authentication (Playwright).
439
+
440
+ Bypasses Akamai WAF by using a real browser to perform the login flow.
441
+ Falls back gracefully if Playwright is not installed.
442
+
443
+ Args:
444
+ email: XTB account email
445
+ password: XTB account password
446
+ headless: Run browser in headless mode (default True, set False for debugging)
447
+
448
+ Returns:
449
+ Either success with TGT or 2FA challenge requiring OTP code
450
+
451
+ Raises:
452
+ CASError: If login fails or Playwright not installed
453
+ """
454
+ try:
455
+ from xtb_api.auth.browser_auth import BrowserCASAuth
456
+ except ImportError as e:
457
+ raise CASError(
458
+ "BROWSER_AUTH_UNAVAILABLE",
459
+ "Browser auth requires playwright. Install with: pip install playwright && playwright install chromium",
460
+ ) from e
461
+
462
+ self._browser_auth = BrowserCASAuth(headless=headless)
463
+ return await self._browser_auth.login(email, password)
464
+
465
+ async def submit_browser_otp(self, code: str) -> CASLoginResult:
466
+ """Submit OTP code via browser for 2FA completion.
467
+
468
+ Must be called after login_with_browser() returns CASLoginTwoFactorRequired.
469
+
470
+ Args:
471
+ code: 6-digit OTP code
472
+
473
+ Returns:
474
+ CASLoginSuccess with TGT
475
+
476
+ Raises:
477
+ CASError: If browser session not available or OTP fails
478
+ """
479
+ if not hasattr(self, "_browser_auth") or self._browser_auth is None:
480
+ raise CASError(
481
+ "BROWSER_AUTH_NO_SESSION",
482
+ "No browser auth session — call login_with_browser() first",
483
+ )
484
+ result = await self._browser_auth.submit_otp(code)
485
+ self._browser_auth = None
486
+ return result
487
+
488
+ def _load_cookies(self) -> dict[str, str]:
489
+ """Load persisted cookies from disk, returning an empty dict on any failure."""
490
+ if not self._cookies_path or not self._cookies_path.exists():
491
+ return {}
492
+ try:
493
+ data = json.loads(self._cookies_path.read_text())
494
+ if isinstance(data, dict):
495
+ return {k: v for k, v in data.items() if isinstance(k, str) and isinstance(v, str)}
496
+ except (json.JSONDecodeError, OSError) as exc:
497
+ logger.debug("Could not load cookies from %s: %s", self._cookies_path, exc)
498
+ return {}
499
+
500
+ def _save_cookies(self, client: httpx.AsyncClient) -> None:
501
+ """Merge the client's cookie jar to disk as JSON with 0600 permissions."""
502
+ if not self._cookies_path:
503
+ return
504
+ try:
505
+ # Merge existing persisted cookies with current jar
506
+ existing = self._load_cookies()
507
+ for cookie in client.cookies.jar:
508
+ if cookie.value is not None:
509
+ existing[cookie.name] = cookie.value
510
+ if not existing:
511
+ return
512
+
513
+ self._cookies_path.parent.mkdir(parents=True, exist_ok=True)
514
+ content = json.dumps(existing, indent=2)
515
+ fd = os.open(
516
+ str(self._cookies_path),
517
+ os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
518
+ stat.S_IRUSR | stat.S_IWUSR, # 0600
519
+ )
520
+ try:
521
+ os.write(fd, content.encode())
522
+ finally:
523
+ os.close(fd)
524
+ except OSError as exc:
525
+ logger.warning("Could not save cookies to %s: %s", self._cookies_path, exc)
526
+
527
+ @staticmethod
528
+ def _get_timezone_offset() -> str:
529
+ """Get current timezone offset in minutes (matching browser's format).
530
+
531
+ The XTB signon API expects the Time-Zone header as positive minutes
532
+ east of UTC (e.g. "60" for CET/UTC+1, "120" for CEST/UTC+2).
533
+ This matches ``new Date().getTimezoneOffset()`` negated.
534
+ """
535
+ now = datetime.now(UTC).astimezone()
536
+ offset = now.utcoffset()
537
+ offset_seconds = offset.total_seconds() if offset is not None else 0
538
+ return str(int(offset_seconds / 60))
539
+
540
+ @staticmethod
541
+ def _generate_fingerprint(user_agent: str) -> str:
542
+ """Generate SHA-256 fingerprint from user agent."""
543
+ return hashlib.sha256(user_agent.encode()).hexdigest().upper()