extendvcc-cli 0.1.0__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.
- extendvcc/__init__.py +46 -0
- extendvcc/_exit_codes.py +24 -0
- extendvcc/_jsonl.py +35 -0
- extendvcc/_paths.py +28 -0
- extendvcc/auth.py +900 -0
- extendvcc/cards.py +761 -0
- extendvcc/cli.py +883 -0
- extendvcc/client.py +491 -0
- extendvcc/imap_otp.py +170 -0
- extendvcc/ledger.py +535 -0
- extendvcc/models.py +74 -0
- extendvcc/py.typed +0 -0
- extendvcc_cli-0.1.0.dist-info/METADATA +179 -0
- extendvcc_cli-0.1.0.dist-info/RECORD +17 -0
- extendvcc_cli-0.1.0.dist-info/WHEEL +4 -0
- extendvcc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- extendvcc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
extendvcc/auth.py
ADDED
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
"""PayWithExtend Cognito SRP authentication helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import datetime as dt
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable, Mapping
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import impit
|
|
19
|
+
|
|
20
|
+
from extendvcc._paths import state_dir
|
|
21
|
+
|
|
22
|
+
API_BASE = "https://api.paywithextend.com"
|
|
23
|
+
COGNITO_ENDPOINT = "https://cognito-idp.us-east-1.amazonaws.com/"
|
|
24
|
+
SESSION_FILENAME = "paywithextend_session.json"
|
|
25
|
+
TOKEN_SAFETY_MARGIN_SECONDS = 300
|
|
26
|
+
|
|
27
|
+
USER_AGENT = (
|
|
28
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
29
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
30
|
+
"Chrome/125.0.0.0 Safari/537.36"
|
|
31
|
+
)
|
|
32
|
+
EXTEND_ACCEPT = "application/vnd.paywithextend.v2021-03-12+json"
|
|
33
|
+
EXTEND_BRAND = os.environ.get("EXTENDVCC_BRAND_ID", "br_2F0trP1UmE59x1ZkNIAqsg")
|
|
34
|
+
|
|
35
|
+
COGNITO_HEADERS = {
|
|
36
|
+
"Content-Type": "application/x-amz-json-1.1",
|
|
37
|
+
"User-Agent": USER_AGENT,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
INFO_BITS = b"Caldera Derived Key"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PayWithExtendAuthError(RuntimeError):
|
|
44
|
+
"""Raised when PayWithExtend authentication cannot complete."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OTPRequired(PayWithExtendAuthError):
|
|
48
|
+
"""Raised when Cognito requests an email OTP but no callback is available."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class UnexpectedChallenge(PayWithExtendAuthError):
|
|
52
|
+
"""Raised when Cognito returns a challenge this module does not support."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SessionNotFound(PayWithExtendAuthError):
|
|
56
|
+
"""Raised when a token operation needs a saved session and none exists."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _session_path() -> Path:
|
|
60
|
+
return state_dir() / SESSION_FILENAME
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _assert_not_disabled() -> None:
|
|
64
|
+
from .client import assert_not_disabled
|
|
65
|
+
|
|
66
|
+
assert_not_disabled()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _secure_write_json(path: Path, payload: Mapping[str, Any]) -> None:
|
|
70
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
data = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
72
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f"{path.name}.", dir=str(path.parent))
|
|
73
|
+
try:
|
|
74
|
+
os.chmod(tmp_name, 0o600)
|
|
75
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
76
|
+
fh.write(data)
|
|
77
|
+
fh.flush()
|
|
78
|
+
os.fsync(fh.fileno())
|
|
79
|
+
os.replace(tmp_name, path)
|
|
80
|
+
os.chmod(path, 0o600)
|
|
81
|
+
except Exception:
|
|
82
|
+
try:
|
|
83
|
+
os.unlink(tmp_name)
|
|
84
|
+
except FileNotFoundError:
|
|
85
|
+
pass
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_session(path: Path | None = None) -> dict[str, Any] | None:
|
|
90
|
+
session_path = path or _session_path()
|
|
91
|
+
if not session_path.exists():
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
payload = json.loads(session_path.read_text(encoding="utf-8"))
|
|
95
|
+
except (json.JSONDecodeError, OSError):
|
|
96
|
+
return None
|
|
97
|
+
return payload if isinstance(payload, dict) else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def save_session(session: Mapping[str, Any], path: Path | None = None) -> None:
|
|
101
|
+
_secure_write_json(path or _session_path(), dict(session))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _json_bytes(payload: Mapping[str, Any]) -> bytes:
|
|
105
|
+
return json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _b64_json(payload: Mapping[str, Any]) -> str:
|
|
109
|
+
return base64.b64encode(_json_bytes(payload)).decode("ascii")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _decode_b64_json(value: str) -> dict[str, Any]:
|
|
113
|
+
normalized = value.strip().strip('"')
|
|
114
|
+
padded = normalized + "=" * (-len(normalized) % 4)
|
|
115
|
+
decoded = base64.b64decode(padded)
|
|
116
|
+
payload = json.loads(decoded.decode("utf-8"))
|
|
117
|
+
if not isinstance(payload, dict):
|
|
118
|
+
raise PayWithExtendAuthError("PayWithExtend authconfig response was not an object")
|
|
119
|
+
return payload
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _response_json(resp: Any) -> Any:
|
|
123
|
+
if hasattr(resp, "json"):
|
|
124
|
+
return resp.json()
|
|
125
|
+
text = getattr(resp, "text", "")
|
|
126
|
+
return json.loads(text)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _raise_for_status(resp: Any, *, kind: str = "auth", path: str | None = None) -> None:
|
|
130
|
+
"""Turn a non-2xx response into a typed PROJECT exception.
|
|
131
|
+
|
|
132
|
+
The impit/httpx-native ``raise_for_status`` raises library exceptions that
|
|
133
|
+
escape the project's catch chain (auth errors map to exit codes via these
|
|
134
|
+
types). So we inspect ``status_code`` ourselves and raise:
|
|
135
|
+
|
|
136
|
+
- ``kind="auth"`` (Cognito calls) -> ``PayWithExtendAuthError``
|
|
137
|
+
- ``kind="api"`` (Extend API calls) -> ``PayWithExtendAPIError`` (with status)
|
|
138
|
+
|
|
139
|
+
Fakes that lack ``status_code`` are tolerated (treated as success), preserving
|
|
140
|
+
offline test fixtures whose default status is 200.
|
|
141
|
+
"""
|
|
142
|
+
status_code = int(getattr(resp, "status_code", 0) or 0)
|
|
143
|
+
if status_code < 400:
|
|
144
|
+
return
|
|
145
|
+
if kind == "api":
|
|
146
|
+
from .client import PayWithExtendAPIError
|
|
147
|
+
|
|
148
|
+
raise PayWithExtendAPIError(
|
|
149
|
+
f"PayWithExtend API request failed: {status_code} {path or ''}".rstrip(),
|
|
150
|
+
status_code=status_code,
|
|
151
|
+
path=path or "",
|
|
152
|
+
)
|
|
153
|
+
raise PayWithExtendAuthError(f"PayWithExtend Cognito request failed with status {status_code}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _inspect_account_risk(resp: Any, path: str) -> None:
|
|
157
|
+
from .client import inspect_account_risk
|
|
158
|
+
|
|
159
|
+
inspect_account_risk(resp, path)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _post_json(client: Any, url: str, payload: Mapping[str, Any], headers: Mapping[str, str]) -> Any:
|
|
163
|
+
return client.post(url, headers=dict(headers), content=_json_bytes(payload), timeout=30)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def read_credentials() -> tuple[str, str]:
|
|
167
|
+
email = os.environ.get("EXTENDVCC_EMAIL", "")
|
|
168
|
+
password = os.environ.get("EXTENDVCC_PASSWORD", "")
|
|
169
|
+
if not email or not password:
|
|
170
|
+
raise PayWithExtendAuthError(
|
|
171
|
+
"Credentials required: set EXTENDVCC_EMAIL and EXTENDVCC_PASSWORD env vars, "
|
|
172
|
+
"or pass email/password directly to authenticate()"
|
|
173
|
+
)
|
|
174
|
+
return email, password
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _extend_headers(access_token: str | None = None) -> dict[str, str]:
|
|
178
|
+
headers = {
|
|
179
|
+
"Accept": EXTEND_ACCEPT,
|
|
180
|
+
"Content-Type": "application/json",
|
|
181
|
+
"User-Agent": USER_AGENT,
|
|
182
|
+
"x-extend-app-id": "app.paywithextend.com",
|
|
183
|
+
"x-extend-brand": EXTEND_BRAND,
|
|
184
|
+
"x-extend-platform": "web",
|
|
185
|
+
"x-extend-platform-version": USER_AGENT,
|
|
186
|
+
}
|
|
187
|
+
if access_token:
|
|
188
|
+
headers["Authorization"] = f"Bearer {access_token}"
|
|
189
|
+
return headers
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _default_extend_client() -> impit.Client:
|
|
193
|
+
return impit.Client(browser="chrome", follow_redirects=True)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _default_cognito_client() -> impit.Client:
|
|
197
|
+
return impit.Client(follow_redirects=True)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def impit_supports_async() -> bool:
|
|
201
|
+
return hasattr(impit, "AsyncClient")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def fetch_authconfig(email: str, client: Any | None = None) -> dict[str, str]:
|
|
205
|
+
_assert_not_disabled()
|
|
206
|
+
http = client or _default_extend_client()
|
|
207
|
+
resp = http.post(
|
|
208
|
+
f"{API_BASE}/authconfig",
|
|
209
|
+
headers=_extend_headers(),
|
|
210
|
+
content=_json_bytes({"email": email}),
|
|
211
|
+
timeout=30,
|
|
212
|
+
)
|
|
213
|
+
_inspect_account_risk(resp, "/authconfig")
|
|
214
|
+
_raise_for_status(resp, kind="api", path="/authconfig")
|
|
215
|
+
|
|
216
|
+
raw_payload: Any
|
|
217
|
+
try:
|
|
218
|
+
raw_payload = _response_json(resp)
|
|
219
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
220
|
+
raw_payload = getattr(resp, "text", "")
|
|
221
|
+
|
|
222
|
+
if isinstance(raw_payload, str):
|
|
223
|
+
payload = _decode_b64_json(raw_payload)
|
|
224
|
+
elif isinstance(raw_payload, dict) and isinstance(raw_payload.get("data"), str):
|
|
225
|
+
payload = _decode_b64_json(raw_payload["data"])
|
|
226
|
+
elif isinstance(raw_payload, dict):
|
|
227
|
+
payload = raw_payload
|
|
228
|
+
else:
|
|
229
|
+
raise PayWithExtendAuthError("PayWithExtend authconfig returned an unsupported payload")
|
|
230
|
+
|
|
231
|
+
user_pool_id = payload.get("userPoolId") or payload.get("user_pool_id")
|
|
232
|
+
client_id = payload.get("clientId") or payload.get("client_id")
|
|
233
|
+
if not isinstance(user_pool_id, str) or not isinstance(client_id, str):
|
|
234
|
+
raise PayWithExtendAuthError("PayWithExtend authconfig did not include pool/client IDs")
|
|
235
|
+
return {"user_pool_id": user_pool_id, "client_id": client_id}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _pool_name(user_pool_id: str) -> str:
|
|
239
|
+
if "_" not in user_pool_id:
|
|
240
|
+
raise PayWithExtendAuthError(f"Unexpected Cognito user pool ID: {user_pool_id}")
|
|
241
|
+
return user_pool_id.split("_", 1)[1]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
COGNITO_N_HEX = (
|
|
245
|
+
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1"
|
|
246
|
+
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD"
|
|
247
|
+
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245"
|
|
248
|
+
"E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED"
|
|
249
|
+
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D"
|
|
250
|
+
"C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F"
|
|
251
|
+
"83655D23DCA3AD961C62F356208552BB9ED529077096966D"
|
|
252
|
+
"670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B"
|
|
253
|
+
"E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9"
|
|
254
|
+
"DE2BCBF6955817183995497CEA956AE515D2261898FA0510"
|
|
255
|
+
"15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64"
|
|
256
|
+
"ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7"
|
|
257
|
+
"ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B"
|
|
258
|
+
"F12FFA06D98A0864D87602733EC86A64521F2B18177B200CB"
|
|
259
|
+
"BE117577A615D6C770988C0BAD946E208E24FA074E5AB3143"
|
|
260
|
+
"DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
N = int(COGNITO_N_HEX, 16)
|
|
264
|
+
G = 2
|
|
265
|
+
K = int(
|
|
266
|
+
hashlib.sha256(bytes.fromhex("00" + COGNITO_N_HEX + "0" + f"{G:x}")).hexdigest(),
|
|
267
|
+
16,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _pad_hex(value: int | str) -> str:
|
|
272
|
+
if isinstance(value, int):
|
|
273
|
+
hex_value = f"{value:x}"
|
|
274
|
+
else:
|
|
275
|
+
hex_value = value.lower().removeprefix("0x")
|
|
276
|
+
if len(hex_value) % 2 == 1:
|
|
277
|
+
hex_value = "0" + hex_value
|
|
278
|
+
if hex_value and hex_value[0] in "89abcdef":
|
|
279
|
+
hex_value = "00" + hex_value
|
|
280
|
+
return hex_value
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _hex_to_int(value: str) -> int:
|
|
284
|
+
return int(value, 16)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _int_to_bytes(value: int) -> bytes:
|
|
288
|
+
if value == 0:
|
|
289
|
+
return b"\x00"
|
|
290
|
+
return value.to_bytes((value.bit_length() + 7) // 8, "big")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _hex_to_bytes(value: int | str) -> bytes:
|
|
294
|
+
return bytes.fromhex(_pad_hex(value))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _hash_hex(data: bytes) -> str:
|
|
298
|
+
return hashlib.sha256(data).hexdigest()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _calculate_u(big_a: int, big_b: int) -> int:
|
|
302
|
+
return _hex_to_int(_hash_hex(_hex_to_bytes(big_a) + _hex_to_bytes(big_b)))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _calculate_x(salt_hex: str, username: str, password: str) -> int:
|
|
306
|
+
user_pass = f"{username}:{password}".encode("utf-8")
|
|
307
|
+
user_pass_hash = hashlib.sha256(user_pass).digest()
|
|
308
|
+
return _hex_to_int(_hash_hex(_hex_to_bytes(salt_hex) + user_pass_hash))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _hkdf(ikm: bytes, salt: bytes) -> bytes:
|
|
312
|
+
prk = hmac.new(salt, ikm, hashlib.sha256).digest()
|
|
313
|
+
info = INFO_BITS + b"\x01"
|
|
314
|
+
return hmac.new(prk, info, hashlib.sha256).digest()[:16]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _utc_cognito_timestamp(now: dt.datetime | None = None) -> str:
|
|
318
|
+
current = now or dt.datetime.now(dt.UTC)
|
|
319
|
+
return f"{current:%a %b} {current.day} {current:%H:%M:%S UTC %Y}"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class _SrpContext:
|
|
323
|
+
def __init__(self, username: str, password: str, *, bytes_a: bytes | None = None) -> None:
|
|
324
|
+
self.username = username
|
|
325
|
+
self.password = password
|
|
326
|
+
self.small_a = int.from_bytes(bytes_a or secrets.token_bytes(128), "big")
|
|
327
|
+
self.large_a = pow(G, self.small_a, N)
|
|
328
|
+
if self.large_a % N == 0:
|
|
329
|
+
raise PayWithExtendAuthError("Generated invalid SRP_A value")
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def public_a_hex(self) -> str:
|
|
333
|
+
return f"{self.large_a:x}"
|
|
334
|
+
|
|
335
|
+
def password_claim_signature(
|
|
336
|
+
self,
|
|
337
|
+
*,
|
|
338
|
+
pool_name: str,
|
|
339
|
+
username_for_srp: str,
|
|
340
|
+
username_for_signature: str,
|
|
341
|
+
password: str,
|
|
342
|
+
salt_hex: str,
|
|
343
|
+
srp_b_hex: str,
|
|
344
|
+
secret_block_b64: str,
|
|
345
|
+
timestamp: str,
|
|
346
|
+
) -> str:
|
|
347
|
+
big_b = _hex_to_int(srp_b_hex)
|
|
348
|
+
if big_b % N == 0:
|
|
349
|
+
raise PayWithExtendAuthError("Cognito returned an invalid SRP_B value")
|
|
350
|
+
u_value = _calculate_u(self.large_a, big_b)
|
|
351
|
+
if u_value == 0:
|
|
352
|
+
raise PayWithExtendAuthError("Cognito returned an invalid SRP scrambling parameter")
|
|
353
|
+
|
|
354
|
+
x_value = _calculate_x(salt_hex, f"{pool_name}{username_for_srp}", password)
|
|
355
|
+
g_mod_pow_x = pow(G, x_value, N)
|
|
356
|
+
s_value = pow(big_b - K * g_mod_pow_x, self.small_a + u_value * x_value, N)
|
|
357
|
+
key = _hkdf(_hex_to_bytes(s_value), _hex_to_bytes(u_value))
|
|
358
|
+
|
|
359
|
+
secret_block = base64.b64decode(secret_block_b64)
|
|
360
|
+
message = (
|
|
361
|
+
pool_name.encode("utf-8")
|
|
362
|
+
+ username_for_signature.encode("utf-8")
|
|
363
|
+
+ secret_block
|
|
364
|
+
+ timestamp.encode("utf-8")
|
|
365
|
+
)
|
|
366
|
+
digest = hmac.new(key, message, hashlib.sha256).digest()
|
|
367
|
+
return base64.b64encode(digest).decode("ascii")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _challenge_parameters(challenge: Mapping[str, Any]) -> dict[str, str]:
|
|
371
|
+
params = challenge.get("ChallengeParameters", {})
|
|
372
|
+
if not isinstance(params, dict):
|
|
373
|
+
raise PayWithExtendAuthError("Cognito challenge did not include parameters")
|
|
374
|
+
return {str(key): str(value) for key, value in params.items()}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _cognito_target(action: str) -> dict[str, str]:
|
|
378
|
+
return {
|
|
379
|
+
**COGNITO_HEADERS,
|
|
380
|
+
"X-Amz-Target": f"AWSCognitoIdentityProviderService.{action}",
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _initiate_auth(client: Any, payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
385
|
+
_assert_not_disabled()
|
|
386
|
+
resp = _post_json(client, COGNITO_ENDPOINT, payload, _cognito_target("InitiateAuth"))
|
|
387
|
+
_raise_for_status(resp)
|
|
388
|
+
data = _response_json(resp)
|
|
389
|
+
if not isinstance(data, dict):
|
|
390
|
+
raise PayWithExtendAuthError("Cognito InitiateAuth returned a non-object response")
|
|
391
|
+
return data
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _respond_to_auth_challenge(client: Any, payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
395
|
+
_assert_not_disabled()
|
|
396
|
+
resp = _post_json(
|
|
397
|
+
client,
|
|
398
|
+
COGNITO_ENDPOINT,
|
|
399
|
+
payload,
|
|
400
|
+
_cognito_target("RespondToAuthChallenge"),
|
|
401
|
+
)
|
|
402
|
+
_raise_for_status(resp)
|
|
403
|
+
data = _response_json(resp)
|
|
404
|
+
if not isinstance(data, dict):
|
|
405
|
+
raise PayWithExtendAuthError("Cognito challenge response returned a non-object response")
|
|
406
|
+
return data
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _call_cognito(client: Any, action: str, payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
410
|
+
_assert_not_disabled()
|
|
411
|
+
resp = _post_json(client, COGNITO_ENDPOINT, payload, _cognito_target(action))
|
|
412
|
+
_raise_for_status(resp)
|
|
413
|
+
data = _response_json(resp)
|
|
414
|
+
return data if isinstance(data, dict) else {}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _auth_result_to_session(
|
|
418
|
+
auth_result: Mapping[str, Any],
|
|
419
|
+
*,
|
|
420
|
+
email: str,
|
|
421
|
+
user_pool_id: str,
|
|
422
|
+
client_id: str,
|
|
423
|
+
existing: Mapping[str, Any] | None = None,
|
|
424
|
+
) -> dict[str, Any]:
|
|
425
|
+
access_token = auth_result.get("AccessToken")
|
|
426
|
+
id_token = auth_result.get("IdToken")
|
|
427
|
+
if not isinstance(access_token, str) or not isinstance(id_token, str):
|
|
428
|
+
raise PayWithExtendAuthError("Cognito did not return access/id tokens")
|
|
429
|
+
|
|
430
|
+
session = dict(existing or {})
|
|
431
|
+
session.update(
|
|
432
|
+
{
|
|
433
|
+
"access_token": access_token,
|
|
434
|
+
"id_token": id_token,
|
|
435
|
+
"refresh_token": auth_result.get("RefreshToken") or session.get("refresh_token"),
|
|
436
|
+
"expires_at": _jwt_exp(access_token) or (time.time() + float(auth_result.get("ExpiresIn", 3600))),
|
|
437
|
+
"email": email,
|
|
438
|
+
"user_pool_id": user_pool_id,
|
|
439
|
+
"client_id": client_id,
|
|
440
|
+
}
|
|
441
|
+
)
|
|
442
|
+
return session
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _generate_device_password() -> str:
|
|
446
|
+
return base64.urlsafe_b64encode(secrets.token_bytes(40)).decode("ascii").rstrip("=")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _generate_device_verifier(
|
|
450
|
+
device_group_key: str,
|
|
451
|
+
device_key: str,
|
|
452
|
+
device_password: str,
|
|
453
|
+
*,
|
|
454
|
+
salt: bytes | None = None,
|
|
455
|
+
) -> tuple[str, str]:
|
|
456
|
+
salt_bytes = salt or secrets.token_bytes(16)
|
|
457
|
+
device_username = f"{device_group_key}{device_key}"
|
|
458
|
+
x_value = _calculate_x(salt_bytes.hex(), device_username, device_password)
|
|
459
|
+
verifier = pow(G, x_value, N)
|
|
460
|
+
return (
|
|
461
|
+
base64.b64encode(_int_to_bytes(verifier)).decode("ascii"),
|
|
462
|
+
base64.b64encode(salt_bytes).decode("ascii"),
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _remember_device(
|
|
467
|
+
client: Any,
|
|
468
|
+
*,
|
|
469
|
+
access_token: str,
|
|
470
|
+
new_device_metadata: Mapping[str, Any],
|
|
471
|
+
session: dict[str, Any],
|
|
472
|
+
) -> dict[str, Any]:
|
|
473
|
+
device_key = new_device_metadata.get("DeviceKey")
|
|
474
|
+
device_group_key = new_device_metadata.get("DeviceGroupKey")
|
|
475
|
+
if not isinstance(device_key, str) or not isinstance(device_group_key, str):
|
|
476
|
+
return session
|
|
477
|
+
|
|
478
|
+
device_password = _generate_device_password()
|
|
479
|
+
verifier, salt = _generate_device_verifier(device_group_key, device_key, device_password)
|
|
480
|
+
confirm_payload = {
|
|
481
|
+
"AccessToken": access_token,
|
|
482
|
+
"DeviceKey": device_key,
|
|
483
|
+
"DeviceName": "extendvcc",
|
|
484
|
+
"DeviceSecretVerifierConfig": {
|
|
485
|
+
"PasswordVerifier": verifier,
|
|
486
|
+
"Salt": salt,
|
|
487
|
+
},
|
|
488
|
+
}
|
|
489
|
+
_call_cognito(client, "ConfirmDevice", confirm_payload)
|
|
490
|
+
_call_cognito(
|
|
491
|
+
client,
|
|
492
|
+
"UpdateDeviceStatus",
|
|
493
|
+
{
|
|
494
|
+
"AccessToken": access_token,
|
|
495
|
+
"DeviceKey": device_key,
|
|
496
|
+
"DeviceRememberedStatus": "remembered",
|
|
497
|
+
},
|
|
498
|
+
)
|
|
499
|
+
session.update(
|
|
500
|
+
{
|
|
501
|
+
"device_key": device_key,
|
|
502
|
+
"device_group_key": device_group_key,
|
|
503
|
+
"device_password": device_password,
|
|
504
|
+
}
|
|
505
|
+
)
|
|
506
|
+
return session
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _password_verifier_response(
|
|
510
|
+
*,
|
|
511
|
+
challenge: Mapping[str, Any],
|
|
512
|
+
srp_context: _SrpContext,
|
|
513
|
+
client_id: str,
|
|
514
|
+
user_pool_id: str,
|
|
515
|
+
password: str,
|
|
516
|
+
device_key: str | None = None,
|
|
517
|
+
session: str | None = None,
|
|
518
|
+
) -> dict[str, Any]:
|
|
519
|
+
params = _challenge_parameters(challenge)
|
|
520
|
+
timestamp = _utc_cognito_timestamp()
|
|
521
|
+
username_for_srp = params["USER_ID_FOR_SRP"]
|
|
522
|
+
signature = srp_context.password_claim_signature(
|
|
523
|
+
pool_name=_pool_name(user_pool_id),
|
|
524
|
+
username_for_srp=username_for_srp,
|
|
525
|
+
username_for_signature=username_for_srp,
|
|
526
|
+
password=password,
|
|
527
|
+
salt_hex=params["SALT"],
|
|
528
|
+
srp_b_hex=params["SRP_B"],
|
|
529
|
+
secret_block_b64=params["SECRET_BLOCK"],
|
|
530
|
+
timestamp=timestamp,
|
|
531
|
+
)
|
|
532
|
+
responses = {
|
|
533
|
+
"USERNAME": username_for_srp,
|
|
534
|
+
"PASSWORD_CLAIM_SECRET_BLOCK": params["SECRET_BLOCK"],
|
|
535
|
+
"PASSWORD_CLAIM_SIGNATURE": signature,
|
|
536
|
+
"TIMESTAMP": timestamp,
|
|
537
|
+
}
|
|
538
|
+
if device_key:
|
|
539
|
+
responses["DEVICE_KEY"] = device_key
|
|
540
|
+
payload: dict[str, Any] = {
|
|
541
|
+
"ChallengeName": "PASSWORD_VERIFIER",
|
|
542
|
+
"ClientId": client_id,
|
|
543
|
+
"ChallengeResponses": responses,
|
|
544
|
+
}
|
|
545
|
+
if session:
|
|
546
|
+
payload["Session"] = session
|
|
547
|
+
return payload
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _device_password_verifier_response(
|
|
551
|
+
*,
|
|
552
|
+
challenge: Mapping[str, Any],
|
|
553
|
+
srp_context: _SrpContext,
|
|
554
|
+
client_id: str,
|
|
555
|
+
device_group_key: str,
|
|
556
|
+
device_key: str,
|
|
557
|
+
device_password: str,
|
|
558
|
+
session: str | None = None,
|
|
559
|
+
) -> dict[str, Any]:
|
|
560
|
+
params = _challenge_parameters(challenge)
|
|
561
|
+
timestamp = _utc_cognito_timestamp()
|
|
562
|
+
signature = srp_context.password_claim_signature(
|
|
563
|
+
pool_name=device_group_key,
|
|
564
|
+
username_for_srp=device_key,
|
|
565
|
+
username_for_signature=device_key,
|
|
566
|
+
password=device_password,
|
|
567
|
+
salt_hex=params["SALT"],
|
|
568
|
+
srp_b_hex=params["SRP_B"],
|
|
569
|
+
secret_block_b64=params["SECRET_BLOCK"],
|
|
570
|
+
timestamp=timestamp,
|
|
571
|
+
)
|
|
572
|
+
payload: dict[str, Any] = {
|
|
573
|
+
"ChallengeName": "DEVICE_PASSWORD_VERIFIER",
|
|
574
|
+
"ClientId": client_id,
|
|
575
|
+
"ChallengeResponses": {
|
|
576
|
+
"USERNAME": params.get("USERNAME", ""),
|
|
577
|
+
"DEVICE_KEY": device_key,
|
|
578
|
+
"PASSWORD_CLAIM_SECRET_BLOCK": params["SECRET_BLOCK"],
|
|
579
|
+
"PASSWORD_CLAIM_SIGNATURE": signature,
|
|
580
|
+
"TIMESTAMP": timestamp,
|
|
581
|
+
},
|
|
582
|
+
}
|
|
583
|
+
if session:
|
|
584
|
+
payload["Session"] = session
|
|
585
|
+
return payload
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _email_otp_response(
|
|
589
|
+
*,
|
|
590
|
+
challenge: Mapping[str, Any],
|
|
591
|
+
client_id: str,
|
|
592
|
+
username: str,
|
|
593
|
+
otp_callback: Callable[[str], str] | None,
|
|
594
|
+
session: str | None = None,
|
|
595
|
+
) -> dict[str, Any]:
|
|
596
|
+
if otp_callback is None:
|
|
597
|
+
raise OTPRequired("PayWithExtend requires an email OTP. Run setup interactively.")
|
|
598
|
+
code = otp_callback("Enter the PayWithExtend email OTP: ").strip()
|
|
599
|
+
payload: dict[str, Any] = {
|
|
600
|
+
"ChallengeName": "EMAIL_OTP",
|
|
601
|
+
"ClientId": client_id,
|
|
602
|
+
"ChallengeResponses": {
|
|
603
|
+
"USERNAME": username,
|
|
604
|
+
"EMAIL_OTP_CODE": code,
|
|
605
|
+
},
|
|
606
|
+
}
|
|
607
|
+
if session:
|
|
608
|
+
payload["Session"] = session
|
|
609
|
+
return payload
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _extract_auth_result(response: Mapping[str, Any]) -> Mapping[str, Any] | None:
|
|
613
|
+
auth_result = response.get("AuthenticationResult")
|
|
614
|
+
return auth_result if isinstance(auth_result, Mapping) else None
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def authenticate(
|
|
618
|
+
*,
|
|
619
|
+
email: str | None = None,
|
|
620
|
+
password: str | None = None,
|
|
621
|
+
otp_callback: Callable[[str], str] | None = None,
|
|
622
|
+
extend_client: Any | None = None,
|
|
623
|
+
cognito_client: Any | None = None,
|
|
624
|
+
save: bool = True,
|
|
625
|
+
) -> dict[str, Any]:
|
|
626
|
+
"""Run Cognito SRP auth and return the saved session payload."""
|
|
627
|
+
|
|
628
|
+
if email is None or password is None:
|
|
629
|
+
stored_email, stored_password = read_credentials()
|
|
630
|
+
email = email or stored_email
|
|
631
|
+
password = password or stored_password
|
|
632
|
+
|
|
633
|
+
authconfig = fetch_authconfig(email, client=extend_client)
|
|
634
|
+
user_pool_id = authconfig["user_pool_id"]
|
|
635
|
+
client_id = authconfig["client_id"]
|
|
636
|
+
existing = load_session() or {}
|
|
637
|
+
device_key = existing.get("device_key")
|
|
638
|
+
cognito = cognito_client or _default_cognito_client()
|
|
639
|
+
user_srp = _SrpContext(email, password)
|
|
640
|
+
|
|
641
|
+
auth_parameters = {"USERNAME": email, "SRP_A": user_srp.public_a_hex}
|
|
642
|
+
if isinstance(device_key, str):
|
|
643
|
+
auth_parameters["DEVICE_KEY"] = device_key
|
|
644
|
+
response = _initiate_auth(
|
|
645
|
+
cognito,
|
|
646
|
+
{
|
|
647
|
+
"AuthFlow": "USER_SRP_AUTH",
|
|
648
|
+
"ClientId": client_id,
|
|
649
|
+
"AuthParameters": auth_parameters,
|
|
650
|
+
},
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
cognito_username = email
|
|
654
|
+
while True:
|
|
655
|
+
auth_result = _extract_auth_result(response)
|
|
656
|
+
if auth_result is not None:
|
|
657
|
+
session = _auth_result_to_session(
|
|
658
|
+
auth_result,
|
|
659
|
+
email=email,
|
|
660
|
+
user_pool_id=user_pool_id,
|
|
661
|
+
client_id=client_id,
|
|
662
|
+
existing=existing,
|
|
663
|
+
)
|
|
664
|
+
new_device = auth_result.get("NewDeviceMetadata")
|
|
665
|
+
if isinstance(new_device, Mapping):
|
|
666
|
+
session = _remember_device(
|
|
667
|
+
cognito,
|
|
668
|
+
access_token=session["access_token"],
|
|
669
|
+
new_device_metadata=new_device,
|
|
670
|
+
session=session,
|
|
671
|
+
)
|
|
672
|
+
if save:
|
|
673
|
+
save_session(session)
|
|
674
|
+
return session
|
|
675
|
+
|
|
676
|
+
challenge_name = response.get("ChallengeName")
|
|
677
|
+
session_token = response.get("Session") if isinstance(response.get("Session"), str) else None
|
|
678
|
+
params = _challenge_parameters(response)
|
|
679
|
+
srp_username = params.get("USER_ID_FOR_SRP") or params.get("USERNAME")
|
|
680
|
+
if srp_username:
|
|
681
|
+
cognito_username = srp_username
|
|
682
|
+
if challenge_name == "PASSWORD_VERIFIER":
|
|
683
|
+
response = _respond_to_auth_challenge(
|
|
684
|
+
cognito,
|
|
685
|
+
_password_verifier_response(
|
|
686
|
+
challenge=response,
|
|
687
|
+
srp_context=user_srp,
|
|
688
|
+
client_id=client_id,
|
|
689
|
+
user_pool_id=user_pool_id,
|
|
690
|
+
password=password,
|
|
691
|
+
device_key=device_key if isinstance(device_key, str) else None,
|
|
692
|
+
session=session_token,
|
|
693
|
+
),
|
|
694
|
+
)
|
|
695
|
+
elif challenge_name == "EMAIL_OTP":
|
|
696
|
+
response = _respond_to_auth_challenge(
|
|
697
|
+
cognito,
|
|
698
|
+
_email_otp_response(
|
|
699
|
+
challenge=response,
|
|
700
|
+
client_id=client_id,
|
|
701
|
+
username=cognito_username,
|
|
702
|
+
otp_callback=otp_callback,
|
|
703
|
+
session=session_token,
|
|
704
|
+
),
|
|
705
|
+
)
|
|
706
|
+
elif challenge_name == "DEVICE_SRP_AUTH":
|
|
707
|
+
device_group_key = existing.get("device_group_key")
|
|
708
|
+
device_password = existing.get("device_password")
|
|
709
|
+
if not all(isinstance(v, str) for v in (device_key, device_group_key, device_password)):
|
|
710
|
+
raise PayWithExtendAuthError("Cognito requested device SRP without saved device credentials")
|
|
711
|
+
device_srp = _SrpContext(f"{device_group_key}{device_key}", device_password)
|
|
712
|
+
response = _respond_to_auth_challenge(
|
|
713
|
+
cognito,
|
|
714
|
+
{
|
|
715
|
+
"ChallengeName": "DEVICE_SRP_AUTH",
|
|
716
|
+
"ClientId": client_id,
|
|
717
|
+
"ChallengeResponses": {
|
|
718
|
+
"USERNAME": _challenge_parameters(response).get("USERNAME", email),
|
|
719
|
+
"DEVICE_KEY": device_key,
|
|
720
|
+
"SRP_A": device_srp.public_a_hex,
|
|
721
|
+
},
|
|
722
|
+
**({"Session": session_token} if session_token else {}),
|
|
723
|
+
},
|
|
724
|
+
)
|
|
725
|
+
user_srp = device_srp
|
|
726
|
+
elif challenge_name == "DEVICE_PASSWORD_VERIFIER":
|
|
727
|
+
device_group_key = existing.get("device_group_key")
|
|
728
|
+
device_password = existing.get("device_password")
|
|
729
|
+
if not all(isinstance(v, str) for v in (device_key, device_group_key, device_password)):
|
|
730
|
+
raise PayWithExtendAuthError("Cognito requested device verifier without saved device credentials")
|
|
731
|
+
response = _respond_to_auth_challenge(
|
|
732
|
+
cognito,
|
|
733
|
+
_device_password_verifier_response(
|
|
734
|
+
challenge=response,
|
|
735
|
+
srp_context=user_srp,
|
|
736
|
+
client_id=client_id,
|
|
737
|
+
device_group_key=device_group_key,
|
|
738
|
+
device_key=device_key,
|
|
739
|
+
device_password=device_password,
|
|
740
|
+
session=session_token,
|
|
741
|
+
),
|
|
742
|
+
)
|
|
743
|
+
else:
|
|
744
|
+
raise UnexpectedChallenge(f"Unsupported PayWithExtend Cognito challenge: {challenge_name}")
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def refresh_tokens(
|
|
748
|
+
session: Mapping[str, Any] | None = None,
|
|
749
|
+
*,
|
|
750
|
+
cognito_client: Any | None = None,
|
|
751
|
+
save: bool = True,
|
|
752
|
+
) -> dict[str, Any]:
|
|
753
|
+
_assert_not_disabled()
|
|
754
|
+
current = dict(session or load_session() or {})
|
|
755
|
+
refresh_token = current.get("refresh_token")
|
|
756
|
+
client_id = current.get("client_id")
|
|
757
|
+
if not isinstance(refresh_token, str) or not isinstance(client_id, str):
|
|
758
|
+
raise SessionNotFound("PayWithExtend refresh needs a saved refresh token and client ID")
|
|
759
|
+
|
|
760
|
+
auth_parameters = {"REFRESH_TOKEN": refresh_token}
|
|
761
|
+
device_key = current.get("device_key")
|
|
762
|
+
if isinstance(device_key, str):
|
|
763
|
+
auth_parameters["DEVICE_KEY"] = device_key
|
|
764
|
+
|
|
765
|
+
cognito = cognito_client or _default_cognito_client()
|
|
766
|
+
response = _initiate_auth(
|
|
767
|
+
cognito,
|
|
768
|
+
{
|
|
769
|
+
"AuthFlow": "REFRESH_TOKEN_AUTH",
|
|
770
|
+
"ClientId": client_id,
|
|
771
|
+
"AuthParameters": auth_parameters,
|
|
772
|
+
},
|
|
773
|
+
)
|
|
774
|
+
auth_result = _extract_auth_result(response)
|
|
775
|
+
if auth_result is None:
|
|
776
|
+
raise PayWithExtendAuthError("Cognito refresh did not return AuthenticationResult")
|
|
777
|
+
|
|
778
|
+
refreshed = _auth_result_to_session(
|
|
779
|
+
auth_result,
|
|
780
|
+
email=str(current.get("email", "")),
|
|
781
|
+
user_pool_id=str(current.get("user_pool_id", "")),
|
|
782
|
+
client_id=client_id,
|
|
783
|
+
existing=current,
|
|
784
|
+
)
|
|
785
|
+
if save:
|
|
786
|
+
save_session(refreshed)
|
|
787
|
+
return refreshed
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _jwt_exp(token: str) -> float | None:
|
|
791
|
+
try:
|
|
792
|
+
payload_b64 = token.split(".")[1]
|
|
793
|
+
except IndexError:
|
|
794
|
+
return None
|
|
795
|
+
padded = payload_b64 + "=" * (-len(payload_b64) % 4)
|
|
796
|
+
try:
|
|
797
|
+
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
|
|
798
|
+
except (json.JSONDecodeError, ValueError):
|
|
799
|
+
return None
|
|
800
|
+
exp = payload.get("exp") if isinstance(payload, dict) else None
|
|
801
|
+
return float(exp) if isinstance(exp, (int, float)) else None
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def ensure_valid_token(
|
|
805
|
+
*,
|
|
806
|
+
margin_seconds: int = TOKEN_SAFETY_MARGIN_SECONDS,
|
|
807
|
+
cognito_client: Any | None = None,
|
|
808
|
+
) -> str:
|
|
809
|
+
_assert_not_disabled()
|
|
810
|
+
session = load_session()
|
|
811
|
+
if session is None:
|
|
812
|
+
session = authenticate()
|
|
813
|
+
access_token = session.get("access_token")
|
|
814
|
+
expires_at = _jwt_exp(access_token) if isinstance(access_token, str) else None
|
|
815
|
+
if expires_at is None:
|
|
816
|
+
# Fall back to the persisted expiry when the JWT carries no usable exp,
|
|
817
|
+
# so an opaque token does not force a refresh on every single call.
|
|
818
|
+
stored = session.get("expires_at")
|
|
819
|
+
expires_at = float(stored) if isinstance(stored, (int, float)) else None
|
|
820
|
+
if isinstance(access_token, str) and expires_at and time.time() + margin_seconds < expires_at:
|
|
821
|
+
return access_token
|
|
822
|
+
refreshed = refresh_tokens(session, cognito_client=cognito_client)
|
|
823
|
+
return str(refreshed["access_token"])
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def fetch_current_user(access_token: str, client: Any | None = None) -> tuple[dict[str, Any], dict[str, str]]:
|
|
827
|
+
_assert_not_disabled()
|
|
828
|
+
http = client or _default_extend_client()
|
|
829
|
+
resp = http.get(
|
|
830
|
+
f"{API_BASE}/users/me",
|
|
831
|
+
headers=_extend_headers(access_token),
|
|
832
|
+
timeout=30,
|
|
833
|
+
)
|
|
834
|
+
_inspect_account_risk(resp, "/users/me")
|
|
835
|
+
_raise_for_status(resp, kind="api", path="/users/me")
|
|
836
|
+
payload = _response_json(resp)
|
|
837
|
+
if not isinstance(payload, dict):
|
|
838
|
+
raise PayWithExtendAuthError("PayWithExtend /users/me returned a non-object response")
|
|
839
|
+
headers = {str(key).lower(): str(value) for key, value in getattr(resp, "headers", {}).items()}
|
|
840
|
+
return payload, headers
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def extract_org_id(payload: Mapping[str, Any]) -> str | None:
|
|
844
|
+
for key in ("org_id", "orgId", "organization_id", "organizationId"):
|
|
845
|
+
value = payload.get(key)
|
|
846
|
+
if isinstance(value, str) and value:
|
|
847
|
+
return value
|
|
848
|
+
for key in ("organization", "org"):
|
|
849
|
+
value = payload.get(key)
|
|
850
|
+
if isinstance(value, Mapping):
|
|
851
|
+
found = extract_org_id(value)
|
|
852
|
+
if found:
|
|
853
|
+
return found
|
|
854
|
+
for key in ("organizations", "orgs"):
|
|
855
|
+
value = payload.get(key)
|
|
856
|
+
if isinstance(value, list):
|
|
857
|
+
for item in value:
|
|
858
|
+
if isinstance(item, Mapping):
|
|
859
|
+
found = extract_org_id(item)
|
|
860
|
+
if found:
|
|
861
|
+
return found
|
|
862
|
+
return None
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _redact_email(email: str | None) -> str | None:
|
|
866
|
+
if not email or "@" not in email:
|
|
867
|
+
return email
|
|
868
|
+
name, domain = email.split("@", 1)
|
|
869
|
+
visible = name[:2] if len(name) > 2 else name[:1]
|
|
870
|
+
return f"{visible}***@{domain}"
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _setup_report(session: Mapping[str, Any], user: Mapping[str, Any], headers: Mapping[str, str]) -> dict[str, Any]:
|
|
874
|
+
rate_limit_headers = {
|
|
875
|
+
key: value for key, value in headers.items() if key.startswith("x-rate") or key.startswith("ratelimit")
|
|
876
|
+
}
|
|
877
|
+
return {
|
|
878
|
+
"success": True,
|
|
879
|
+
"email": _redact_email(session.get("email") if isinstance(session.get("email"), str) else None),
|
|
880
|
+
"org_id": session.get("org_id"),
|
|
881
|
+
"user_id": user.get("id") or user.get("userId"),
|
|
882
|
+
"rate_limits": rate_limit_headers,
|
|
883
|
+
"impit_async_supported": impit_supports_async(),
|
|
884
|
+
"session_path": str(_session_path()),
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def setup(
|
|
889
|
+
*,
|
|
890
|
+
email: str | None = None,
|
|
891
|
+
password: str | None = None,
|
|
892
|
+
otp_callback: Callable[[str], str] | None = None,
|
|
893
|
+
) -> dict[str, Any]:
|
|
894
|
+
session = authenticate(email=email, password=password, otp_callback=otp_callback, save=False)
|
|
895
|
+
user, headers = fetch_current_user(session["access_token"])
|
|
896
|
+
org_id = extract_org_id(user)
|
|
897
|
+
if org_id:
|
|
898
|
+
session["org_id"] = org_id
|
|
899
|
+
save_session(session)
|
|
900
|
+
return _setup_report(session, user, headers)
|