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/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)