tlnt-cli 1.5.1__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.
- tlnt/__init__.py +3 -0
- tlnt/__main__.py +4 -0
- tlnt/browser/__init__.py +0 -0
- tlnt/browser/login.py +414 -0
- tlnt/browser/oauth.py +50 -0
- tlnt/browser/scripted.py +183 -0
- tlnt/cli/__init__.py +0 -0
- tlnt/cli/avatars.py +51 -0
- tlnt/cli/commands/__init__.py +0 -0
- tlnt/cli/commands/approvals.py +98 -0
- tlnt/cli/commands/attendance.py +283 -0
- tlnt/cli/commands/calendar.py +108 -0
- tlnt/cli/commands/login.py +225 -0
- tlnt/cli/commands/logout.py +12 -0
- tlnt/cli/commands/mcp.py +8 -0
- tlnt/cli/commands/notifications.py +102 -0
- tlnt/cli/commands/profile.py +13 -0
- tlnt/cli/commands/setup.py +12 -0
- tlnt/cli/commands/status.py +12 -0
- tlnt/cli/commands/timeoff.py +491 -0
- tlnt/cli/commands/update.py +86 -0
- tlnt/cli/commands/version.py +15 -0
- tlnt/cli/main.py +163 -0
- tlnt/cli/options.py +56 -0
- tlnt/cli/output.py +321 -0
- tlnt/cli/prompts.py +76 -0
- tlnt/core/__init__.py +0 -0
- tlnt/core/clients/__init__.py +0 -0
- tlnt/core/clients/mekari/__init__.py +0 -0
- tlnt/core/clients/mekari/client.py +826 -0
- tlnt/core/clients/mekari/constants.py +174 -0
- tlnt/core/config.py +53 -0
- tlnt/core/http_client.py +110 -0
- tlnt/core/logs.py +34 -0
- tlnt/core/models.py +84 -0
- tlnt/core/play_store.py +85 -0
- tlnt/core/services/__init__.py +46 -0
- tlnt/core/services/attendance.py +213 -0
- tlnt/core/services/calendar.py +54 -0
- tlnt/core/services/login.py +100 -0
- tlnt/core/services/logout.py +38 -0
- tlnt/core/services/notifications.py +97 -0
- tlnt/core/services/profile.py +11 -0
- tlnt/core/services/setup.py +85 -0
- tlnt/core/services/status.py +35 -0
- tlnt/core/services/timeoff.py +146 -0
- tlnt/core/session.py +189 -0
- tlnt/core/user_agent.py +71 -0
- tlnt/mcp/__init__.py +0 -0
- tlnt/mcp/main.py +176 -0
- tlnt_cli-1.5.1.dist-info/METADATA +315 -0
- tlnt_cli-1.5.1.dist-info/RECORD +55 -0
- tlnt_cli-1.5.1.dist-info/WHEEL +4 -0
- tlnt_cli-1.5.1.dist-info/entry_points.txt +3 -0
- tlnt_cli-1.5.1.dist-info/licenses/LICENSE +21 -0
tlnt/__init__.py
ADDED
tlnt/__main__.py
ADDED
tlnt/browser/__init__.py
ADDED
|
File without changes
|
tlnt/browser/login.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Visible browser login and mobile OAuth capture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from time import monotonic
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
15
|
+
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
|
16
|
+
from playwright.sync_api import sync_playwright
|
|
17
|
+
|
|
18
|
+
from tlnt.browser.oauth import authorize_url, code_from_url, pkce_pair
|
|
19
|
+
from tlnt.core.clients.mekari.client import MekariClient
|
|
20
|
+
from tlnt.core.clients.mekari.constants import (
|
|
21
|
+
DASHBOARD_TALENTA_URL,
|
|
22
|
+
MEKARI_DOMAIN,
|
|
23
|
+
STATIC_ASSET_SUFFIXES,
|
|
24
|
+
TALENTA_DOMAIN,
|
|
25
|
+
)
|
|
26
|
+
from tlnt.core.models import MobileToken, Profile, SessionState
|
|
27
|
+
from tlnt.core.logs import logger
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_log = logger.bind(component="browser")
|
|
31
|
+
|
|
32
|
+
LOGIN_TIMEOUT_SECONDS = 300
|
|
33
|
+
FORM_TIMEOUT_MS = 10_000
|
|
34
|
+
SIGN_IN_TIMEOUT_MS = 60_000
|
|
35
|
+
_SIGN_IN_PATH = "/users/sign_in"
|
|
36
|
+
# 127.0.0.1, not localhost: Chrome binds IPv4 loopback while localhost may
|
|
37
|
+
# resolve to ::1 first.
|
|
38
|
+
DEFAULT_CDP_ENDPOINT = "http://127.0.0.1:9222"
|
|
39
|
+
_CDP_SCHEMES = ("http", "https", "ws", "wss")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LoginFailed(RuntimeError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Credentials:
|
|
48
|
+
email: str
|
|
49
|
+
password: str = field(repr=False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@contextmanager
|
|
53
|
+
def chrome_context(headless: bool = False) -> Iterator[Any]:
|
|
54
|
+
"""Yield a context on the installed Chrome, falling back to Edge."""
|
|
55
|
+
with sync_playwright() as playwright:
|
|
56
|
+
for channel in ("chrome", "msedge"):
|
|
57
|
+
try:
|
|
58
|
+
browser = playwright.chromium.launch(
|
|
59
|
+
channel=channel, headless=headless
|
|
60
|
+
)
|
|
61
|
+
except PlaywrightError:
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
yield browser.new_context()
|
|
65
|
+
finally:
|
|
66
|
+
browser.close()
|
|
67
|
+
return
|
|
68
|
+
raise LoginFailed(
|
|
69
|
+
"Install Google Chrome or Microsoft Edge to sign in, attach to a running "
|
|
70
|
+
"browser with --cdp, or use --headless with --email on a host without a display"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cdp_endpoint(value: str) -> str:
|
|
75
|
+
"""Normalize a port, host:port, or http(s)/ws(s) URL into a CDP endpoint."""
|
|
76
|
+
text = value.strip()
|
|
77
|
+
if text.isdigit():
|
|
78
|
+
text = f"127.0.0.1:{text}"
|
|
79
|
+
if "://" not in text:
|
|
80
|
+
text = f"http://{text}"
|
|
81
|
+
try:
|
|
82
|
+
parts = urlsplit(text)
|
|
83
|
+
valid = (
|
|
84
|
+
parts.scheme in _CDP_SCHEMES
|
|
85
|
+
and bool(parts.hostname)
|
|
86
|
+
and parts.port != 0
|
|
87
|
+
)
|
|
88
|
+
except ValueError:
|
|
89
|
+
valid = False
|
|
90
|
+
if not valid:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"Invalid CDP endpoint '{value}': "
|
|
93
|
+
"use a port, host:port, or an http(s)/ws(s) URL"
|
|
94
|
+
)
|
|
95
|
+
return text
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@contextmanager
|
|
99
|
+
def cdp_context(endpoint: str) -> Iterator[Any]:
|
|
100
|
+
"""Yield the default context of a browser that is already running (CDP).
|
|
101
|
+
|
|
102
|
+
The browser, its context and its tabs belong to the user, so nothing here
|
|
103
|
+
closes them; leaving ``sync_playwright`` only drops the connection.
|
|
104
|
+
"""
|
|
105
|
+
with sync_playwright() as playwright:
|
|
106
|
+
try:
|
|
107
|
+
browser = playwright.chromium.connect_over_cdp(endpoint)
|
|
108
|
+
except PlaywrightError as error:
|
|
109
|
+
raise LoginFailed(
|
|
110
|
+
f"Could not connect to a browser at {endpoint}; start it with "
|
|
111
|
+
"--remote-debugging-port=9222 "
|
|
112
|
+
"(Chrome 136+ also needs --user-data-dir)"
|
|
113
|
+
) from error
|
|
114
|
+
yield browser.contexts[0]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _wait_for_dashboard(
|
|
118
|
+
context: Any,
|
|
119
|
+
opened: Any,
|
|
120
|
+
existing: list[Any],
|
|
121
|
+
deadline: float,
|
|
122
|
+
) -> Any:
|
|
123
|
+
"""Wait for a tab the login created, not for tabs the browser already had."""
|
|
124
|
+
while True:
|
|
125
|
+
pages = context.pages
|
|
126
|
+
if opened not in pages:
|
|
127
|
+
raise LoginFailed("Browser was closed before login completed")
|
|
128
|
+
for page in pages:
|
|
129
|
+
if page in existing:
|
|
130
|
+
continue
|
|
131
|
+
normalized = page.url.split("?", 1)[0].split("#", 1)[0].rstrip("/")
|
|
132
|
+
if normalized == DASHBOARD_TALENTA_URL:
|
|
133
|
+
return page
|
|
134
|
+
remaining = deadline - monotonic()
|
|
135
|
+
if remaining <= 0:
|
|
136
|
+
raise PlaywrightTimeoutError("Login did not reach the Talenta dashboard")
|
|
137
|
+
try:
|
|
138
|
+
opened.wait_for_timeout(min(100, remaining * 1000))
|
|
139
|
+
except (PlaywrightError, PlaywrightTimeoutError):
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _sign_in(page: Any, credentials: Credentials) -> None:
|
|
144
|
+
"""Type the credentials into Mekari's sign-in form and submit it once."""
|
|
145
|
+
try:
|
|
146
|
+
try:
|
|
147
|
+
page.fill("#user_email", credentials.email, timeout=FORM_TIMEOUT_MS)
|
|
148
|
+
except PlaywrightTimeoutError:
|
|
149
|
+
if _SIGN_IN_PATH in page.url:
|
|
150
|
+
raise LoginFailed(
|
|
151
|
+
"Mekari's sign-in form did not appear. Run tlnt login "
|
|
152
|
+
"without --email to sign in yourself"
|
|
153
|
+
) from None
|
|
154
|
+
return # not on the sign-in page: the browser is already signed in
|
|
155
|
+
page.fill("#user_password", credentials.password, timeout=FORM_TIMEOUT_MS)
|
|
156
|
+
page.click("#new-signin-button", timeout=FORM_TIMEOUT_MS)
|
|
157
|
+
# ponytail: fixed 60 s wait; read Mekari's error text if wrong-password
|
|
158
|
+
# feedback must be faster.
|
|
159
|
+
page.wait_for_url(
|
|
160
|
+
lambda url: _SIGN_IN_PATH not in url,
|
|
161
|
+
wait_until="commit",
|
|
162
|
+
timeout=SIGN_IN_TIMEOUT_MS,
|
|
163
|
+
)
|
|
164
|
+
except PlaywrightTimeoutError:
|
|
165
|
+
# `from None` on every raise here: Playwright's call log, appended to
|
|
166
|
+
# every error class, echoes the typed value.
|
|
167
|
+
raise LoginFailed(
|
|
168
|
+
"Mekari did not accept the sign-in. Check the email and password, "
|
|
169
|
+
"or run tlnt login without --email if it asks for a code or captcha"
|
|
170
|
+
) from None
|
|
171
|
+
except PlaywrightError:
|
|
172
|
+
raise LoginFailed(
|
|
173
|
+
"The browser stopped while tlnt was signing in. Run tlnt login again"
|
|
174
|
+
) from None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _extract_profile(settings: Any) -> Profile:
|
|
178
|
+
if not isinstance(settings, dict):
|
|
179
|
+
raise LoginFailed("Talenta did not expose user profile data")
|
|
180
|
+
|
|
181
|
+
company = settings.get("company")
|
|
182
|
+
company = company if isinstance(company, dict) else {}
|
|
183
|
+
fields = {
|
|
184
|
+
"name": settings.get("name"),
|
|
185
|
+
"email": settings.get("email") or settings.get("Email"),
|
|
186
|
+
"Talenta User ID": settings.get("Talenta User ID"),
|
|
187
|
+
"Company ID": settings.get("Company ID") or company.get("id"),
|
|
188
|
+
"Company Name": settings.get("Company Name") or company.get("name"),
|
|
189
|
+
}
|
|
190
|
+
missing = next(
|
|
191
|
+
(
|
|
192
|
+
name
|
|
193
|
+
for name, value in fields.items()
|
|
194
|
+
if name != "Company Name"
|
|
195
|
+
if isinstance(value, bool)
|
|
196
|
+
or not isinstance(value, (str, int, float))
|
|
197
|
+
or not str(value).strip()
|
|
198
|
+
),
|
|
199
|
+
None,
|
|
200
|
+
)
|
|
201
|
+
if missing:
|
|
202
|
+
raise LoginFailed(f"Talenta profile missing {missing}")
|
|
203
|
+
|
|
204
|
+
company_name = fields["Company Name"]
|
|
205
|
+
if (
|
|
206
|
+
isinstance(company_name, bool)
|
|
207
|
+
or not isinstance(company_name, (str, int, float))
|
|
208
|
+
or not str(company_name).strip()
|
|
209
|
+
):
|
|
210
|
+
company_name = None
|
|
211
|
+
|
|
212
|
+
return Profile(
|
|
213
|
+
name=str(fields["name"]),
|
|
214
|
+
email=str(fields["email"]),
|
|
215
|
+
talenta_user_id=str(fields["Talenta User ID"]),
|
|
216
|
+
company_id=str(fields["Company ID"]),
|
|
217
|
+
company_name=str(company_name) if company_name is not None else None,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _is_relevant_host(url: str) -> bool:
|
|
222
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
223
|
+
return any(
|
|
224
|
+
host == domain or host.endswith(f".{domain}")
|
|
225
|
+
for domain in (MEKARI_DOMAIN, TALENTA_DOMAIN)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _is_static_asset(path: str) -> bool:
|
|
230
|
+
return (
|
|
231
|
+
path.endswith(STATIC_ASSET_SUFFIXES)
|
|
232
|
+
or "/assets/" in path
|
|
233
|
+
or "/images/" in path
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
class _OAuthSink:
|
|
237
|
+
def __init__(self) -> None:
|
|
238
|
+
self.code: str | None = None
|
|
239
|
+
self.urls: list[str] = []
|
|
240
|
+
|
|
241
|
+
def note(self, url: str) -> None:
|
|
242
|
+
if not _is_relevant_host(url):
|
|
243
|
+
return
|
|
244
|
+
self.urls.append(url)
|
|
245
|
+
if self.code is None:
|
|
246
|
+
self.code = code_from_url(url)
|
|
247
|
+
|
|
248
|
+
def on_response(self, response: Any) -> None:
|
|
249
|
+
self.note(response.url)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _redirect_chain(sink: _OAuthSink) -> str:
|
|
253
|
+
seen: list[str] = []
|
|
254
|
+
for url in sink.urls:
|
|
255
|
+
parts = urlsplit(url)
|
|
256
|
+
if _is_static_asset(parts.path):
|
|
257
|
+
continue
|
|
258
|
+
step = f"{parts.netloc}{parts.path}"
|
|
259
|
+
if not seen or seen[-1] != step:
|
|
260
|
+
seen.append(step)
|
|
261
|
+
return " -> ".join(seen) if seen else f"(no {MEKARI_DOMAIN}/{TALENTA_DOMAIN} traffic observed)"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _report_capture_miss(sink: _OAuthSink) -> None:
|
|
265
|
+
_log.bind(
|
|
266
|
+
event="failure",
|
|
267
|
+
phase="token_capture",
|
|
268
|
+
error_type="token_not_captured",
|
|
269
|
+
).warning("mobile token not captured")
|
|
270
|
+
print(
|
|
271
|
+
"Mobile token not captured.\n"
|
|
272
|
+
" checked: authorization code and PKCE token exchange\n"
|
|
273
|
+
f" redirect chain: {_redirect_chain(sink)}\n"
|
|
274
|
+
" Set TALENTA_BEARER to clock in now, or share the chain above to "
|
|
275
|
+
"close the gap.",
|
|
276
|
+
file=sys.stderr,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class BrowserLogin:
|
|
281
|
+
def __init__(
|
|
282
|
+
self,
|
|
283
|
+
*,
|
|
284
|
+
client: MekariClient | None = None,
|
|
285
|
+
context_factory: Callable[[], AbstractContextManager[Any]] = chrome_context,
|
|
286
|
+
credentials: Credentials | None = None,
|
|
287
|
+
) -> None:
|
|
288
|
+
self.client = client or MekariClient()
|
|
289
|
+
self._owns_client = client is None
|
|
290
|
+
self.context_factory = context_factory
|
|
291
|
+
self.credentials = credentials
|
|
292
|
+
|
|
293
|
+
def _capture_mobile_token(
|
|
294
|
+
self,
|
|
295
|
+
page: Any,
|
|
296
|
+
sink: _OAuthSink,
|
|
297
|
+
) -> MobileToken | None:
|
|
298
|
+
_log.bind(
|
|
299
|
+
event="phase_started",
|
|
300
|
+
phase="token_capture",
|
|
301
|
+
).debug("mobile token capture started")
|
|
302
|
+
try:
|
|
303
|
+
verifier, challenge = pkce_pair()
|
|
304
|
+
page.set_extra_http_headers({"X-Requested-With": "co.talenta"})
|
|
305
|
+
page.goto(
|
|
306
|
+
authorize_url(challenge),
|
|
307
|
+
wait_until="domcontentloaded",
|
|
308
|
+
timeout=30_000,
|
|
309
|
+
)
|
|
310
|
+
if sink.code is None:
|
|
311
|
+
sink.code = code_from_url(page.url)
|
|
312
|
+
except (PlaywrightError, PlaywrightTimeoutError) as error:
|
|
313
|
+
_log.bind(
|
|
314
|
+
event="failure",
|
|
315
|
+
phase="token_capture",
|
|
316
|
+
error_type=type(error).__name__,
|
|
317
|
+
).warning("mobile token capture failed")
|
|
318
|
+
return None
|
|
319
|
+
return self._exchange(sink, verifier)
|
|
320
|
+
|
|
321
|
+
def _exchange(self, sink: _OAuthSink, verifier: str) -> MobileToken | None:
|
|
322
|
+
token = (
|
|
323
|
+
self.client.exchange_code(sink.code, verifier)
|
|
324
|
+
if sink.code is not None
|
|
325
|
+
else None
|
|
326
|
+
)
|
|
327
|
+
if token is None:
|
|
328
|
+
_report_capture_miss(sink)
|
|
329
|
+
return None
|
|
330
|
+
_log.bind(
|
|
331
|
+
event="phase_finished",
|
|
332
|
+
phase="token_capture",
|
|
333
|
+
).debug("mobile token capture finished")
|
|
334
|
+
return token
|
|
335
|
+
|
|
336
|
+
def login(self) -> SessionState:
|
|
337
|
+
try:
|
|
338
|
+
return self._login()
|
|
339
|
+
finally:
|
|
340
|
+
if self._owns_client:
|
|
341
|
+
self.client.close()
|
|
342
|
+
|
|
343
|
+
def _capture_browser_session(
|
|
344
|
+
self,
|
|
345
|
+
sink: _OAuthSink,
|
|
346
|
+
) -> tuple[Profile | None, MobileToken | None]:
|
|
347
|
+
with self.context_factory() as context:
|
|
348
|
+
_log.bind(
|
|
349
|
+
event="phase_started",
|
|
350
|
+
phase="browser_context",
|
|
351
|
+
).debug("browser context started")
|
|
352
|
+
existing = list(context.pages)
|
|
353
|
+
opened = context.new_page()
|
|
354
|
+
context.on("response", sink.on_response)
|
|
355
|
+
try:
|
|
356
|
+
opened.goto(
|
|
357
|
+
DASHBOARD_TALENTA_URL,
|
|
358
|
+
wait_until="domcontentloaded",
|
|
359
|
+
timeout=30_000,
|
|
360
|
+
)
|
|
361
|
+
opened.bring_to_front()
|
|
362
|
+
deadline = monotonic() + LOGIN_TIMEOUT_SECONDS
|
|
363
|
+
if self.credentials is not None:
|
|
364
|
+
_sign_in(opened, self.credentials)
|
|
365
|
+
page = _wait_for_dashboard(context, opened, existing, deadline)
|
|
366
|
+
_log.bind(
|
|
367
|
+
event="phase_finished",
|
|
368
|
+
phase="dashboard",
|
|
369
|
+
).debug("dashboard reached")
|
|
370
|
+
try:
|
|
371
|
+
profile = _extract_profile(
|
|
372
|
+
page.evaluate("() => window.intercomSettings ?? null")
|
|
373
|
+
)
|
|
374
|
+
except LoginFailed:
|
|
375
|
+
profile = None
|
|
376
|
+
mobile = self._capture_mobile_token(opened, sink)
|
|
377
|
+
return profile, mobile
|
|
378
|
+
finally:
|
|
379
|
+
context.remove_listener("response", sink.on_response)
|
|
380
|
+
opened.close()
|
|
381
|
+
|
|
382
|
+
def _login(self) -> SessionState:
|
|
383
|
+
_log.bind(event="phase_started", phase="login").debug(
|
|
384
|
+
"browser login started"
|
|
385
|
+
)
|
|
386
|
+
sink = _OAuthSink()
|
|
387
|
+
try:
|
|
388
|
+
profile, mobile = self._capture_browser_session(sink)
|
|
389
|
+
except OSError as error:
|
|
390
|
+
_log.bind(
|
|
391
|
+
event="failure",
|
|
392
|
+
phase="login",
|
|
393
|
+
error_type=type(error).__name__,
|
|
394
|
+
).warning("browser login failed")
|
|
395
|
+
raise LoginFailed("Browser could not be started") from error
|
|
396
|
+
except (PlaywrightError, PlaywrightTimeoutError) as error:
|
|
397
|
+
_log.bind(
|
|
398
|
+
event="failure",
|
|
399
|
+
phase="login",
|
|
400
|
+
error_type=type(error).__name__,
|
|
401
|
+
).warning("browser login failed")
|
|
402
|
+
raise LoginFailed("Login did not complete within five minutes") from error
|
|
403
|
+
|
|
404
|
+
session = SessionState(
|
|
405
|
+
version=1,
|
|
406
|
+
saved_at=datetime.now().astimezone().isoformat(),
|
|
407
|
+
web_storage={},
|
|
408
|
+
profile=profile,
|
|
409
|
+
mobile=mobile,
|
|
410
|
+
)
|
|
411
|
+
_log.bind(event="phase_finished", phase="login").debug(
|
|
412
|
+
"browser login finished"
|
|
413
|
+
)
|
|
414
|
+
return session
|
tlnt/browser/oauth.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""PKCE helpers for the Mekari mobile authorization flow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import secrets
|
|
8
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit
|
|
9
|
+
|
|
10
|
+
from tlnt.core.clients.mekari import constants
|
|
11
|
+
from tlnt.core.clients.mekari.constants import (
|
|
12
|
+
OAUTH_AUTHORIZE_URL,
|
|
13
|
+
OAUTH_SCOPE,
|
|
14
|
+
TOKEN_REDIRECT_URL,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def pkce_pair() -> tuple[str, str]:
|
|
19
|
+
verifier = secrets.token_urlsafe(64)
|
|
20
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
21
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
22
|
+
return verifier, challenge
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def authorize_url(challenge: str) -> str:
|
|
26
|
+
params = {
|
|
27
|
+
"client_id": constants.client_id(),
|
|
28
|
+
"scope": OAUTH_SCOPE,
|
|
29
|
+
"response_type": "code",
|
|
30
|
+
"redirect_uri": TOKEN_REDIRECT_URL,
|
|
31
|
+
"code_challenge": challenge,
|
|
32
|
+
"code_challenge_method": "s256",
|
|
33
|
+
}
|
|
34
|
+
return f"{OAUTH_AUTHORIZE_URL}?{urlencode(params)}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def code_from_url(url: str) -> str | None:
|
|
38
|
+
parts = urlsplit(url)
|
|
39
|
+
redirect = urlsplit(TOKEN_REDIRECT_URL)
|
|
40
|
+
if (parts.scheme, parts.netloc, parts.path) != (
|
|
41
|
+
redirect.scheme,
|
|
42
|
+
redirect.netloc,
|
|
43
|
+
redirect.path,
|
|
44
|
+
):
|
|
45
|
+
return None
|
|
46
|
+
for raw in (parts.query, parts.fragment):
|
|
47
|
+
for key, value in parse_qsl(raw):
|
|
48
|
+
if key == "code" and value:
|
|
49
|
+
return value
|
|
50
|
+
return None
|
tlnt/browser/scripted.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Login through browsers that expose no CDP port (ego lite, Yab).
|
|
2
|
+
|
|
3
|
+
One short script runs through the vendor CLI per check. tlnt repeats it until
|
|
4
|
+
the user is signed in to Talenta in that browser, and that same run does the
|
|
5
|
+
OAuth hop. The PKCE verifier never leaves this process.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
from time import monotonic, sleep
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from tlnt.browser.login import (
|
|
16
|
+
LOGIN_TIMEOUT_SECONDS,
|
|
17
|
+
BrowserLogin,
|
|
18
|
+
LoginFailed,
|
|
19
|
+
_extract_profile,
|
|
20
|
+
_OAuthSink,
|
|
21
|
+
)
|
|
22
|
+
from tlnt.browser.oauth import authorize_url, pkce_pair
|
|
23
|
+
from tlnt.core.clients.mekari.client import MekariClient
|
|
24
|
+
from tlnt.core.clients.mekari.constants import DASHBOARD_TALENTA_URL
|
|
25
|
+
from tlnt.core.logs import logger
|
|
26
|
+
from tlnt.core.models import MobileToken, Profile
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_log = logger.bind(component="browser")
|
|
30
|
+
|
|
31
|
+
POLL_SECONDS = 5
|
|
32
|
+
RUN_TIMEOUT_SECONDS = 90
|
|
33
|
+
|
|
34
|
+
_COMMANDS = {
|
|
35
|
+
"ego": ["ego-browser", "nodejs"],
|
|
36
|
+
"yab": ["yab", "exec", "-", "--timeout", "80"],
|
|
37
|
+
}
|
|
38
|
+
LABELS = {"ego": "ego lite", "yab": "Yab"}
|
|
39
|
+
|
|
40
|
+
# Each script opens the dashboard in a throwaway agent tab. Signed out, it
|
|
41
|
+
# reports that and closes the tab. Signed in, it also reads the profile, does
|
|
42
|
+
# the OAuth hop and reports the URLs it saw. __CONFIG__ becomes a JSON object
|
|
43
|
+
# with `dashboard` and `authorize`.
|
|
44
|
+
_EGO_SCRIPT = r"""
|
|
45
|
+
const cfg = __CONFIG__;
|
|
46
|
+
const bare = (u) => u.split(/[?#]/)[0].replace(/\/$/, "");
|
|
47
|
+
const task = await taskSpace("tlnt login");
|
|
48
|
+
const page = task.page("p1");
|
|
49
|
+
const result = { signedIn: false, settings: null, urls: [] };
|
|
50
|
+
try {
|
|
51
|
+
await page.goto(cfg.dashboard, { timeout: 30000 });
|
|
52
|
+
try {
|
|
53
|
+
await page.waitForURL((u) => bare(u.href) === cfg.dashboard, { timeout: 3000 });
|
|
54
|
+
result.signedIn = true;
|
|
55
|
+
} catch (e) {}
|
|
56
|
+
if (result.signedIn) {
|
|
57
|
+
result.settings = await page.evaluate(() => window.intercomSettings ?? null);
|
|
58
|
+
await page.cdp("Network.enable");
|
|
59
|
+
await page.cdp("Network.setExtraHTTPHeaders", { headers: { "X-Requested-With": "co.talenta" } });
|
|
60
|
+
await page.events();
|
|
61
|
+
// The last redirect may abort the navigation; requests seen so far still count.
|
|
62
|
+
try { await page.goto(cfg.authorize, { timeout: 30000 }); } catch (e) {}
|
|
63
|
+
for (const e of await page.events()) {
|
|
64
|
+
if (e.method === "Network.requestWillBeSent") result.urls.push(e.params.request.url);
|
|
65
|
+
}
|
|
66
|
+
result.urls.push(await page.url());
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
await task.finish({ keep: [] });
|
|
70
|
+
}
|
|
71
|
+
console.log(JSON.stringify(result));
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
# Yab's eval runs in an isolated world, so it cannot read intercomSettings
|
|
75
|
+
# (no profile is saved) and no request header can be set on the OAuth hop.
|
|
76
|
+
_YAB_SCRIPT = r"""
|
|
77
|
+
const cfg = __CONFIG__;
|
|
78
|
+
const bare = (u) => u.split(/[?#]/)[0].replace(/\/$/, "");
|
|
79
|
+
const t = open(cfg.dashboard);
|
|
80
|
+
const here = () => {
|
|
81
|
+
const row = tabs().find((x) => x.id === t.id);
|
|
82
|
+
return row ? row.line.slice(row.line.lastIndexOf(", ") + 2) : "";
|
|
83
|
+
};
|
|
84
|
+
const result = { signedIn: false, settings: null, urls: [] };
|
|
85
|
+
try {
|
|
86
|
+
result.signedIn = bare(here()) === cfg.dashboard;
|
|
87
|
+
if (result.signedIn) {
|
|
88
|
+
try { t.navigate(cfg.authorize); } catch (e) {}
|
|
89
|
+
result.urls.push(here());
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
t.close();
|
|
93
|
+
}
|
|
94
|
+
return JSON.stringify(result);
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
_SCRIPTS = {"ego": _EGO_SCRIPT, "yab": _YAB_SCRIPT}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _script_result(stdout: str) -> dict[str, Any]:
|
|
101
|
+
"""Return the last JSON object line, skipping notices and `(N steps)`."""
|
|
102
|
+
for line in reversed(stdout.splitlines()):
|
|
103
|
+
try:
|
|
104
|
+
data = json.loads(line)
|
|
105
|
+
except ValueError:
|
|
106
|
+
continue
|
|
107
|
+
if isinstance(data, dict):
|
|
108
|
+
return data
|
|
109
|
+
raise LoginFailed("Browser script returned no result")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _run_script(backend: str, authorize: str) -> dict[str, Any]:
|
|
113
|
+
argv = _COMMANDS[backend]
|
|
114
|
+
label = LABELS[backend]
|
|
115
|
+
config = json.dumps({"dashboard": DASHBOARD_TALENTA_URL, "authorize": authorize})
|
|
116
|
+
try:
|
|
117
|
+
done = subprocess.run(
|
|
118
|
+
argv,
|
|
119
|
+
input=_SCRIPTS[backend].replace("__CONFIG__", config),
|
|
120
|
+
capture_output=True,
|
|
121
|
+
text=True,
|
|
122
|
+
timeout=RUN_TIMEOUT_SECONDS,
|
|
123
|
+
check=False,
|
|
124
|
+
)
|
|
125
|
+
except FileNotFoundError as error:
|
|
126
|
+
raise LoginFailed(f"{argv[0]} was not found on PATH; install it first") from error
|
|
127
|
+
except subprocess.TimeoutExpired as error:
|
|
128
|
+
raise LoginFailed(
|
|
129
|
+
f"{label} did not respond within {RUN_TIMEOUT_SECONDS} seconds"
|
|
130
|
+
) from error
|
|
131
|
+
if done.returncode != 0:
|
|
132
|
+
_log.bind(
|
|
133
|
+
event="failure",
|
|
134
|
+
phase="scripted_check",
|
|
135
|
+
backend=backend,
|
|
136
|
+
exit_code=done.returncode,
|
|
137
|
+
).warning("browser script failed")
|
|
138
|
+
raise LoginFailed(
|
|
139
|
+
f"{label} stopped (exit {done.returncode}); "
|
|
140
|
+
"check its window for a prompt, then retry"
|
|
141
|
+
)
|
|
142
|
+
# ego-browser prints console.log to stderr, yab prints its result to stdout.
|
|
143
|
+
return _script_result(f"{done.stdout}\n{done.stderr}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ScriptedLogin(BrowserLogin):
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
backend: Literal["ego", "yab"],
|
|
150
|
+
*,
|
|
151
|
+
client: MekariClient | None = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
super().__init__(client=client)
|
|
154
|
+
self.backend = backend
|
|
155
|
+
|
|
156
|
+
def _capture_browser_session(
|
|
157
|
+
self,
|
|
158
|
+
sink: _OAuthSink,
|
|
159
|
+
) -> tuple[Profile | None, MobileToken | None]:
|
|
160
|
+
verifier, challenge = pkce_pair()
|
|
161
|
+
authorize = authorize_url(challenge)
|
|
162
|
+
deadline = monotonic() + LOGIN_TIMEOUT_SECONDS
|
|
163
|
+
while True:
|
|
164
|
+
data = _run_script(self.backend, authorize)
|
|
165
|
+
if data.get("signedIn") is True:
|
|
166
|
+
break
|
|
167
|
+
if monotonic() >= deadline:
|
|
168
|
+
raise LoginFailed("Login did not complete within five minutes")
|
|
169
|
+
sleep(POLL_SECONDS)
|
|
170
|
+
_log.bind(
|
|
171
|
+
event="phase_finished",
|
|
172
|
+
phase="scripted_check",
|
|
173
|
+
backend=self.backend,
|
|
174
|
+
).debug("signed in to Talenta")
|
|
175
|
+
try:
|
|
176
|
+
profile = _extract_profile(data.get("settings"))
|
|
177
|
+
except LoginFailed:
|
|
178
|
+
profile = None
|
|
179
|
+
urls = data.get("urls")
|
|
180
|
+
for url in urls if isinstance(urls, list) else []:
|
|
181
|
+
if isinstance(url, str):
|
|
182
|
+
sink.note(url)
|
|
183
|
+
return profile, self._exchange(sink, verifier)
|
tlnt/cli/__init__.py
ADDED
|
File without changes
|