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.
- xtb_api/__init__.py +70 -0
- xtb_api/__main__.py +154 -0
- xtb_api/auth/__init__.py +5 -0
- xtb_api/auth/auth_manager.py +321 -0
- xtb_api/auth/browser_auth.py +316 -0
- xtb_api/auth/cas_client.py +543 -0
- xtb_api/client.py +444 -0
- xtb_api/exceptions.py +56 -0
- xtb_api/grpc/__init__.py +25 -0
- xtb_api/grpc/client.py +329 -0
- xtb_api/grpc/proto.py +239 -0
- xtb_api/grpc/types.py +14 -0
- xtb_api/instruments.py +132 -0
- xtb_api/py.typed +0 -0
- xtb_api/types/__init__.py +6 -0
- xtb_api/types/enums.py +92 -0
- xtb_api/types/instrument.py +45 -0
- xtb_api/types/trading.py +139 -0
- xtb_api/types/websocket.py +164 -0
- xtb_api/utils.py +62 -0
- xtb_api/ws/__init__.py +3 -0
- xtb_api/ws/parsers.py +161 -0
- xtb_api/ws/ws_client.py +905 -0
- xtb_api_python-0.5.2.dist-info/METADATA +257 -0
- xtb_api_python-0.5.2.dist-info/RECORD +28 -0
- xtb_api_python-0.5.2.dist-info/WHEEL +4 -0
- xtb_api_python-0.5.2.dist-info/entry_points.txt +2 -0
- xtb_api_python-0.5.2.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|