touchque-authenticator 4.2.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.
Files changed (48) hide show
  1. touchque/__init__.py +148 -0
  2. touchque/agent.py +314 -0
  3. touchque/agent_card.py +190 -0
  4. touchque/agent_frameworks.py +215 -0
  5. touchque/approval_proof.py +104 -0
  6. touchque/approval_token.py +134 -0
  7. touchque/client.py +105 -0
  8. touchque/code_reply.py +75 -0
  9. touchque/config.py +56 -0
  10. touchque/contrib/__init__.py +4 -0
  11. touchque/contrib/django.py +175 -0
  12. touchque/contrib/fastapi.py +208 -0
  13. touchque/contrib/flask.py +154 -0
  14. touchque/cross_app_access.py +222 -0
  15. touchque/exceptions.py +170 -0
  16. touchque/guard.py +290 -0
  17. touchque/hosted_login.py +474 -0
  18. touchque/http_client.py +156 -0
  19. touchque/mcp_auth.py +256 -0
  20. touchque/my_account.py +173 -0
  21. touchque/org_agent.py +262 -0
  22. touchque/rate_limiter.py +56 -0
  23. touchque/relay.py +643 -0
  24. touchque/resources/__init__.py +1 -0
  25. touchque/resources/action_types.py +52 -0
  26. touchque/resources/actions.py +234 -0
  27. touchque/resources/agents.py +236 -0
  28. touchque/resources/assist.py +115 -0
  29. touchque/resources/auth.py +65 -0
  30. touchque/resources/device_risk.py +174 -0
  31. touchque/resources/identity.py +268 -0
  32. touchque/resources/login.py +296 -0
  33. touchque/resources/mobile.py +130 -0
  34. touchque/resources/offline.py +138 -0
  35. touchque/resources/qr_pairings.py +93 -0
  36. touchque/resources/recovery.py +118 -0
  37. touchque/resources/risk.py +141 -0
  38. touchque/resources/safe_guard.py +99 -0
  39. touchque/resources/webauthn.py +115 -0
  40. touchque/resources/webhook.py +258 -0
  41. touchque/safe_guard.py +308 -0
  42. touchque/security_events.py +205 -0
  43. touchque/steps.py +239 -0
  44. touchque_authenticator-4.2.0.dist-info/METADATA +630 -0
  45. touchque_authenticator-4.2.0.dist-info/RECORD +48 -0
  46. touchque_authenticator-4.2.0.dist-info/WHEEL +5 -0
  47. touchque_authenticator-4.2.0.dist-info/licenses/LICENSE +21 -0
  48. touchque_authenticator-4.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,474 @@
1
+ """TouchQue Login (hosted sign-in) for your app: an OpenID Connect client for
2
+ your tenant (acme.login.touchque.com or your own domain).
3
+
4
+ login = HostedLogin(domain="acme.login.touchque.com", client_id=..., client_secret=..., redirect_uri=...)
5
+ req = login.authorization_url() # keep req.state, req.nonce, req.code_verifier; redirect to req.url
6
+ tokens = login.handle_callback(request_url, state=..., nonce=..., code_verifier=...)
7
+ tokens.claims["sub"], tokens.access_token, tokens.refresh_token
8
+
9
+ PKCE (S256) is always used. ID tokens are checked against the tenant's JWKS
10
+ (RS256 signature, issuer, audience, expiry, nonce, at_hash). RS256 is
11
+ verified in pure Python, so the core needs only ``requests``. Your API checks
12
+ access tokens with ``verify_access_token(token, audience=...)``.
13
+
14
+ An app acting for a user (an AI agent; a confidential app whose grantTypes
15
+ list the grant, and an API that grants it scopes)::
16
+
17
+ for_ada = login.exchange_token(adas_access_token, audience="https://api.acme.com") # RFC 8693
18
+ ask = login.ask_user(login_hint="ada@acme.com", scope="openid payments:create", binding_message="Pay invoice 42")
19
+ reply(f"Type {ask.binding_code} in the TouchQue app")
20
+ approved = login.wait_for_user(ask) # CIBA, poll mode
21
+ """
22
+
23
+ import base64
24
+ import hashlib
25
+ import hmac
26
+ import json
27
+ import os
28
+ import threading
29
+ import time
30
+ from dataclasses import dataclass, field
31
+ from typing import Any, Dict, Iterable, Mapping, Optional, Union
32
+ from urllib.parse import parse_qsl, urlencode, urlsplit, quote
33
+
34
+ import requests
35
+
36
+ from .exceptions import TouchQueException
37
+ from .my_account import MyAccount, _issuer_of
38
+
39
+ __all__ = ["HostedLogin", "HostedLoginError", "AuthorizationRequest", "TokenSet", "UserApprovalRequest"]
40
+
41
+
42
+ class HostedLoginError(TouchQueException):
43
+ """A sign-in or token check failed. ``code`` is machine-readable."""
44
+
45
+ def __init__(self, code: str, description: Optional[str] = None, status: Optional[int] = None):
46
+ super().__init__(f"{code}: {description}" if description else code)
47
+ self.code = code
48
+ self.description = description
49
+ self.status = status
50
+
51
+
52
+ @dataclass
53
+ class AuthorizationRequest:
54
+ url: str
55
+ state: str
56
+ nonce: str
57
+ code_verifier: str
58
+
59
+
60
+ # requested_token_type for the token vault (get_connection_token).
61
+ CONNECTION_TOKEN_TYPE = "urn:touchque:params:oauth:token-type:connection-access-token"
62
+
63
+
64
+ @dataclass
65
+ class TokenSet:
66
+ access_token: str
67
+ token_type: str
68
+ expires_at: int
69
+ scope: str
70
+ id_token: Optional[str] = None
71
+ refresh_token: Optional[str] = None
72
+ claims: Dict[str, Any] = field(default_factory=dict)
73
+ # What the user approved on the phone (CIBA with authorization_details).
74
+ authorization_details: Optional[list] = None
75
+ # Token exchange: always the access token type.
76
+ issued_token_type: Optional[str] = None
77
+ # get_connection_token: the connection the provider token is for.
78
+ connection: Optional[str] = None
79
+
80
+
81
+ @dataclass
82
+ class UserApprovalRequest:
83
+ """A CIBA request: show ``binding_code`` (the number the user picks on the phone), then ``wait_for_user``."""
84
+ auth_req_id: str
85
+ expires_at: int
86
+ interval: int
87
+ binding_code: Optional[str] = None
88
+
89
+
90
+ def _b64u(data: bytes) -> str:
91
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
92
+
93
+
94
+ def _b64u_decode(text: str) -> bytes:
95
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
96
+
97
+
98
+ def _random(n: int = 32) -> str:
99
+ return _b64u(os.urandom(n))
100
+
101
+
102
+ # DER prefix of DigestInfo for SHA-256 (RFC 8017 9.2, note 1).
103
+ _SHA256_PREFIX = bytes.fromhex("3031300d060960864801650304020105000420")
104
+
105
+
106
+ def _rs256_verify(n: int, e: int, signing_input: bytes, signature: bytes) -> bool:
107
+ """RSASSA-PKCS1-v1_5 with SHA-256, public-key operation only."""
108
+ k = (n.bit_length() + 7) // 8
109
+ if len(signature) != k:
110
+ return False
111
+ s = int.from_bytes(signature, "big")
112
+ if s >= n:
113
+ return False
114
+ em = pow(s, e, n).to_bytes(k, "big")
115
+ t = _SHA256_PREFIX + hashlib.sha256(signing_input).digest()
116
+ expected = b"\x00\x01" + b"\xff" * (k - len(t) - 3) + b"\x00" + t
117
+ return hmac.compare_digest(em, expected)
118
+
119
+
120
+ class HostedLogin:
121
+ def __init__(
122
+ self,
123
+ domain: str,
124
+ client_id: str,
125
+ client_secret: Optional[str] = None,
126
+ redirect_uri: Optional[str] = None,
127
+ scope: str = "openid profile email",
128
+ audience: Optional[str] = None,
129
+ clock_tolerance: int = 60,
130
+ session: Optional[requests.Session] = None,
131
+ timeout: float = 10.0,
132
+ ):
133
+ if not domain:
134
+ raise HostedLoginError("config", "domain is required")
135
+ if not client_id:
136
+ raise HostedLoginError("config", "client_id is required")
137
+ try:
138
+ self.issuer = _issuer_of(domain)
139
+ except ValueError as err:
140
+ raise HostedLoginError("config", str(err))
141
+ self.client_id = client_id
142
+ self.client_secret = client_secret
143
+ self.redirect_uri = redirect_uri
144
+ self.scope = scope
145
+ self.audience = audience
146
+ self.clock_tolerance = clock_tolerance
147
+ self.timeout = timeout
148
+ self._http = session or requests.Session()
149
+ self._keys: Dict[str, tuple] = {}
150
+ self._keys_at = 0.0
151
+ self._lock = threading.Lock()
152
+
153
+ # ── Sign-in ──────────────────────────────────────────────────────────
154
+
155
+ def authorization_url(
156
+ self,
157
+ state: Optional[str] = None,
158
+ nonce: Optional[str] = None,
159
+ code_verifier: Optional[str] = None,
160
+ redirect_uri: Optional[str] = None,
161
+ scope: Optional[str] = None,
162
+ audience: Optional[str] = None,
163
+ prompt: Optional[str] = None,
164
+ login_hint: Optional[str] = None,
165
+ screen_hint: Optional[str] = None,
166
+ ui_locales: Optional[str] = None,
167
+ max_age: Optional[int] = None,
168
+ connection: Optional[str] = None,
169
+ organization: Optional[str] = None,
170
+ invitation: Optional[str] = None,
171
+ acr_values: Optional[str] = None,
172
+ response_mode: Optional[str] = None,
173
+ extra_params: Optional[Mapping[str, str]] = None,
174
+ ) -> AuthorizationRequest:
175
+ """The URL to send the browser to, and the values to keep until the callback."""
176
+ redirect = redirect_uri or self.redirect_uri
177
+ if not redirect:
178
+ raise HostedLoginError("config", "redirect_uri is required")
179
+ state = state or _random()
180
+ nonce = nonce or _random()
181
+ verifier = code_verifier or _random(48)
182
+ params = {
183
+ "response_type": "code",
184
+ "client_id": self.client_id,
185
+ "redirect_uri": redirect,
186
+ "scope": scope or self.scope,
187
+ "state": state,
188
+ "nonce": nonce,
189
+ "code_challenge": _b64u(hashlib.sha256(verifier.encode("ascii")).digest()),
190
+ "code_challenge_method": "S256",
191
+ }
192
+ optional = {
193
+ "audience": audience if audience is not None else self.audience,
194
+ "prompt": prompt, "login_hint": login_hint, "screen_hint": screen_hint, "ui_locales": ui_locales,
195
+ "max_age": max_age, "connection": connection, "organization": organization, "invitation": invitation,
196
+ "acr_values": acr_values, "response_mode": response_mode,
197
+ }
198
+ params.update({k: str(v) for k, v in optional.items() if v not in (None, "")})
199
+ params.update(dict(extra_params or {}))
200
+ return AuthorizationRequest(f"{self.issuer}/authorize?{urlencode(params, quote_via=quote)}", state, nonce, verifier)
201
+
202
+ def handle_callback(
203
+ self,
204
+ callback: Union[str, Mapping[str, str]],
205
+ state: str,
206
+ nonce: str,
207
+ code_verifier: str,
208
+ redirect_uri: Optional[str] = None,
209
+ max_age: Optional[int] = None,
210
+ ) -> TokenSet:
211
+ """Checks state (and iss), exchanges the code, verifies the ID token.
212
+
213
+ ``callback`` is the full callback URL or its query/form fields.
214
+ """
215
+ params = self._params(callback)
216
+ got_state = params.get("state")
217
+ if not got_state or not state or not hmac.compare_digest(got_state, state):
218
+ raise HostedLoginError("state_mismatch", "The state does not match this sign-in")
219
+ # RFC 9207: a response that carries iss (even an empty one) must name this issuer,
220
+ # the one the ID token is checked against. Servers that do not send iss yet are accepted.
221
+ if "iss" in params and params["iss"] != self.issuer:
222
+ iss = params["iss"]
223
+ raise HostedLoginError("issuer_mismatch", f"The response came from {iss}" if iss else "The response has an empty iss")
224
+ if params.get("error"):
225
+ raise HostedLoginError(params["error"], params.get("error_description"))
226
+ code = params.get("code")
227
+ if not code:
228
+ raise HostedLoginError("invalid_request", "The callback has no code")
229
+ redirect = redirect_uri or self.redirect_uri
230
+ if not redirect:
231
+ raise HostedLoginError("config", "redirect_uri is required")
232
+ tokens = self._token({"grant_type": "authorization_code", "code": code, "redirect_uri": redirect, "code_verifier": code_verifier})
233
+ if not tokens.id_token:
234
+ raise HostedLoginError("invalid_token", "No ID token: ask for the openid scope")
235
+ tokens.claims = self.verify_id_token(tokens.id_token, nonce=nonce, access_token=tokens.access_token, max_age=max_age)
236
+ return tokens
237
+
238
+ def refresh(self, refresh_token: str, scope: Optional[str] = None) -> TokenSet:
239
+ """A new access token and a rotated refresh token: keep the new one."""
240
+ fields = {"grant_type": "refresh_token", "refresh_token": refresh_token}
241
+ if scope:
242
+ fields["scope"] = scope
243
+ tokens = self._token(fields)
244
+ if tokens.id_token:
245
+ tokens.claims = self.verify_id_token(tokens.id_token, access_token=tokens.access_token)
246
+ return tokens
247
+
248
+ def my_account_token(self, refresh_token: str, scope: str = "read:me update:me") -> TokenSet:
249
+ """A token for the My Account API (audience https://<domain>/me/) from the
250
+ refresh token a first-party app holds. The refresh token rotates: keep the
251
+ returned one. Scopes: read:me, update:me, manage:me:security, delete:me."""
252
+ return self._token({"grant_type": "refresh_token", "refresh_token": refresh_token, "audience": f"{self.issuer}/me/", "scope": scope})
253
+
254
+ def my_account(self, access_token: str) -> "MyAccount":
255
+ """The My Account API for the person holding access_token."""
256
+ return MyAccount(self.issuer, access_token, session=self._http, timeout=self.timeout)
257
+
258
+ def account_url(self, section: Optional[str] = None) -> str:
259
+ """The hosted account page (profile; 'security', 'devices' or 'privacy')."""
260
+ return f"{self.issuer}/account" + (f"/{section}" if section else "")
261
+
262
+ def client_credentials(self, audience: str, scope: Optional[str] = None) -> TokenSet:
263
+ """Machine to machine: an access token for your API with the app's own credentials."""
264
+ fields = {"grant_type": "client_credentials", "audience": audience}
265
+ if scope:
266
+ fields["scope"] = scope
267
+ return self._token(fields)
268
+
269
+ def exchange_token(self, subject_token: str, audience: str, scope: Optional[str] = None) -> TokenSet:
270
+ """RFC 8693: a signed-in user's access token, traded for one to call ``audience`` on their
271
+ behalf (sub = the user, act.sub = this app). For a confidential app the API grants scopes to;
272
+ never longer than the user's token."""
273
+ fields = {
274
+ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", "subject_token": subject_token,
275
+ "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", "audience": audience,
276
+ }
277
+ if scope:
278
+ fields["scope"] = scope
279
+ return self._token(fields)
280
+
281
+ def get_connection_token(self, user_access_token: str, connection: str, scope: Optional[str] = None) -> TokenSet:
282
+ """Token vault: the provider's own access token (Google, Microsoft, GitHub...) for the signed-in
283
+ user, from a connection that stores tokens. TouchQue refreshes it; the refresh token never
284
+ leaves. ``user_access_token`` is the user's token for this app (or for an app the connection
285
+ lists). HostedLoginError invalid_grant: the user has not signed in with the connection since it
286
+ started storing tokens, or they stopped app access."""
287
+ fields = {
288
+ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", "subject_token": user_access_token,
289
+ "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
290
+ "requested_token_type": CONNECTION_TOKEN_TYPE, "connection": connection,
291
+ }
292
+ if scope:
293
+ fields["scope"] = scope
294
+ return self._token(fields)
295
+
296
+ def ask_user(self, login_hint: str, scope: str, audience: Optional[str] = None, binding_message: Optional[str] = None,
297
+ authorization_details: Optional[list] = None, requested_expiry: Optional[int] = None) -> UserApprovalRequest:
298
+ """CIBA: asks the user (e-mail or usr_ id) to approve on their phone. At least one of
299
+ ``binding_message`` (<= 64 characters) or ``authorization_details`` (RFC 9396) is needed."""
300
+ fields = {"login_hint": login_hint, "scope": scope}
301
+ if audience:
302
+ fields["audience"] = audience
303
+ if binding_message:
304
+ fields["binding_message"] = binding_message
305
+ if authorization_details:
306
+ fields["authorization_details"] = json.dumps(authorization_details)
307
+ if requested_expiry:
308
+ fields["requested_expiry"] = str(requested_expiry)
309
+ res = self._post("/bc-authorize", fields)
310
+ if res.status_code >= 400:
311
+ raise self._error(res)
312
+ d = res.json()
313
+ return UserApprovalRequest(auth_req_id=str(d["auth_req_id"]), expires_at=int(time.time()) + int(d.get("expires_in") or 0),
314
+ interval=int(d.get("interval", 5)), binding_code=d.get("binding_code"))
315
+
316
+ def wait_for_user(self, request: UserApprovalRequest) -> TokenSet:
317
+ """Polls until the user answers: the tokens, or HostedLoginError access_denied / expired_token."""
318
+ interval = request.interval
319
+ while True:
320
+ if time.time() > request.expires_at:
321
+ raise HostedLoginError("expired_token", "The user did not answer in time")
322
+ time.sleep(interval)
323
+ try:
324
+ return self._token({"grant_type": "urn:openid:params:grant-type:ciba", "auth_req_id": request.auth_req_id})
325
+ except HostedLoginError as e:
326
+ if e.code == "slow_down":
327
+ interval += 5
328
+ elif e.code != "authorization_pending":
329
+ raise
330
+
331
+ def revoke(self, token: str) -> None:
332
+ res = self._post("/oauth/revoke", {"token": token})
333
+ if res.status_code >= 400:
334
+ raise self._error(res)
335
+
336
+ def userinfo(self, access_token: str) -> Dict[str, Any]:
337
+ res = self._http.get(f"{self.issuer}/userinfo", headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"}, timeout=self.timeout)
338
+ if res.status_code >= 400:
339
+ raise self._error(res)
340
+ return res.json()
341
+
342
+ def logout_url(self, id_token_hint: Optional[str] = None, post_logout_redirect_uri: Optional[str] = None, state: Optional[str] = None) -> str:
343
+ params = {"client_id": self.client_id}
344
+ if id_token_hint:
345
+ params["id_token_hint"] = id_token_hint
346
+ if post_logout_redirect_uri:
347
+ params["post_logout_redirect_uri"] = post_logout_redirect_uri
348
+ if state:
349
+ params["state"] = state
350
+ return f"{self.issuer}/oidc/logout?{urlencode(params, quote_via=quote)}"
351
+
352
+ # ── Token checks ─────────────────────────────────────────────────────
353
+
354
+ def verify_access_token(self, token: str, audience: str, required_scopes: Iterable[str] = ()) -> Dict[str, Any]:
355
+ """For your API: signature, issuer, audience, expiry, typ at+jwt, scopes."""
356
+ payload = self._verify_jwt(token, typ="at+jwt")
357
+ auds = payload.get("aud") if isinstance(payload.get("aud"), list) else [payload.get("aud")]
358
+ if audience not in auds:
359
+ raise HostedLoginError("invalid_token", "The token is for another audience")
360
+ scopes = str(payload.get("scope") or "").split(" ")
361
+ missing = [s for s in required_scopes if s not in scopes]
362
+ if missing:
363
+ raise HostedLoginError("insufficient_scope", "Missing scope: " + " ".join(missing), 403)
364
+ return payload
365
+
366
+ def verify_id_token(self, id_token: str, nonce: Optional[str] = None, access_token: Optional[str] = None, max_age: Optional[int] = None) -> Dict[str, Any]:
367
+ payload = self._verify_jwt(id_token)
368
+ auds = payload.get("aud") if isinstance(payload.get("aud"), list) else [payload.get("aud")]
369
+ if self.client_id not in auds:
370
+ raise HostedLoginError("invalid_token", "The ID token is for another app")
371
+ if len(auds) > 1 and payload.get("azp") != self.client_id:
372
+ raise HostedLoginError("invalid_token", "azp does not name this app")
373
+ if nonce is not None and not (isinstance(payload.get("nonce"), str) and hmac.compare_digest(payload["nonce"], nonce)):
374
+ raise HostedLoginError("nonce_mismatch", "The nonce does not match this sign-in")
375
+ if access_token and isinstance(payload.get("at_hash"), str):
376
+ expected = _b64u(hashlib.sha256(access_token.encode("ascii")).digest()[:16])
377
+ if not hmac.compare_digest(payload["at_hash"], expected):
378
+ raise HostedLoginError("invalid_token", "at_hash does not match the access token")
379
+ if max_age is not None and isinstance(payload.get("auth_time"), int) and time.time() - payload["auth_time"] > max_age + self.clock_tolerance:
380
+ raise HostedLoginError("login_required", "The sign-in is older than max_age")
381
+ return payload
382
+
383
+ # ── Internals ────────────────────────────────────────────────────────
384
+
385
+ @staticmethod
386
+ def _params(callback: Union[str, Mapping[str, str]]) -> Dict[str, str]:
387
+ if isinstance(callback, str):
388
+ query = urlsplit(callback).query if "?" in callback or "://" in callback else callback
389
+ return dict(parse_qsl(query, keep_blank_values=True))
390
+ return {k: v for k, v in dict(callback).items() if isinstance(v, str)}
391
+
392
+ def _post(self, path: str, fields: Dict[str, str]) -> requests.Response:
393
+ headers = {"Accept": "application/json"}
394
+ auth = None
395
+ data = dict(fields)
396
+ if self.client_secret:
397
+ auth = (quote(self.client_id, safe=""), quote(self.client_secret, safe=""))
398
+ else:
399
+ data["client_id"] = self.client_id
400
+ return self._http.post(f"{self.issuer}{path}", data=data, headers=headers, auth=auth, timeout=self.timeout)
401
+
402
+ @staticmethod
403
+ def _error(res: requests.Response) -> HostedLoginError:
404
+ try:
405
+ data = res.json()
406
+ except ValueError:
407
+ data = {}
408
+ return HostedLoginError(data.get("error") or f"http_{res.status_code}", data.get("error_description"), res.status_code)
409
+
410
+ def _token(self, fields: Dict[str, str]) -> TokenSet:
411
+ res = self._post("/oauth/token", fields)
412
+ if res.status_code >= 400:
413
+ raise self._error(res)
414
+ d = res.json()
415
+ return TokenSet(
416
+ access_token=str(d.get("access_token")),
417
+ token_type=str(d.get("token_type") or "Bearer"),
418
+ expires_at=int(time.time()) + int(d.get("expires_in") or 0),
419
+ scope=str(d.get("scope") or ""),
420
+ id_token=d.get("id_token"),
421
+ refresh_token=d.get("refresh_token"),
422
+ authorization_details=d.get("authorization_details") if isinstance(d.get("authorization_details"), list) else None,
423
+ issued_token_type=d.get("issued_token_type"),
424
+ connection=d.get("connection") if isinstance(d.get("connection"), str) else None,
425
+ )
426
+
427
+ def _load_keys(self, force: bool = False) -> None:
428
+ with self._lock:
429
+ age = time.time() - self._keys_at
430
+ if not force and self._keys and age < 3600:
431
+ return
432
+ if force and age < 30:
433
+ return
434
+ res = self._http.get(f"{self.issuer}/.well-known/jwks.json", headers={"Accept": "application/json"}, timeout=self.timeout)
435
+ if res.status_code >= 400:
436
+ raise HostedLoginError("jwks_unavailable", f"JWKS answered {res.status_code}")
437
+ keys = {}
438
+ for k in res.json().get("keys", []):
439
+ if k.get("kty") == "RSA" and k.get("kid") and k.get("n") and k.get("e") and k.get("use", "sig") == "sig":
440
+ keys[k["kid"]] = (int.from_bytes(_b64u_decode(k["n"]), "big"), int.from_bytes(_b64u_decode(k["e"]), "big"))
441
+ self._keys = keys
442
+ self._keys_at = time.time()
443
+
444
+ def _verify_jwt(self, token: str, typ: Optional[str] = None) -> Dict[str, Any]:
445
+ parts = str(token).split(".")
446
+ if len(parts) != 3:
447
+ raise HostedLoginError("invalid_token", "not a JWT")
448
+ try:
449
+ header = json.loads(_b64u_decode(parts[0]))
450
+ payload = json.loads(_b64u_decode(parts[1]))
451
+ signature = _b64u_decode(parts[2])
452
+ except (ValueError, TypeError):
453
+ raise HostedLoginError("invalid_token", "malformed JWT")
454
+ if header.get("alg") != "RS256":
455
+ raise HostedLoginError("invalid_token", f"Unexpected alg {header.get('alg')}")
456
+ if typ and str(header.get("typ") or "").lower() != typ:
457
+ raise HostedLoginError("invalid_token", "Wrong token type")
458
+ kid = str(header.get("kid") or "")
459
+ self._load_keys()
460
+ if kid not in self._keys:
461
+ self._load_keys(force=True)
462
+ if kid not in self._keys:
463
+ raise HostedLoginError("invalid_token", "Unknown signing key")
464
+ n, e = self._keys[kid]
465
+ if not _rs256_verify(n, e, f"{parts[0]}.{parts[1]}".encode("ascii"), signature):
466
+ raise HostedLoginError("invalid_token", "Bad signature")
467
+ now = time.time()
468
+ if payload.get("iss") != self.issuer:
469
+ raise HostedLoginError("invalid_token", "Wrong issuer")
470
+ if not isinstance(payload.get("exp"), (int, float)) or now > payload["exp"] + self.clock_tolerance:
471
+ raise HostedLoginError("invalid_token", "The token expired")
472
+ if isinstance(payload.get("iat"), (int, float)) and payload["iat"] > now + self.clock_tolerance:
473
+ raise HostedLoginError("invalid_token", "The token is from the future")
474
+ return payload
@@ -0,0 +1,156 @@
1
+ import os
2
+ import json
3
+ import time
4
+ import hmac
5
+ import hashlib
6
+ import requests
7
+ from typing import Dict, Any, Optional
8
+ from urllib.parse import urlencode
9
+
10
+ from .config import Config
11
+ from .exceptions import TouchQueAPIException, TouchQueNetworkException, api_exception
12
+
13
+ SDK_VERSION = '4.2.0'
14
+
15
+ def _query_value(value: Any) -> Any:
16
+ # The API reads only 'true' / 'false'; urlencode would send Python's 'True' / 'False'.
17
+ if isinstance(value, bool):
18
+ return 'true' if value else 'false'
19
+ return value
20
+
21
+
22
+ def _build_query(params: Optional[Dict[str, Any]]) -> str:
23
+ """Deterministic query string: keys sorted, `?`-prefixed (or '')."""
24
+ if not params:
25
+ return ''
26
+ items = sorted((k, _query_value(v)) for k, v in params.items() if v is not None)
27
+ if not items:
28
+ return ''
29
+ return '?' + urlencode(items)
30
+
31
+
32
+ class HttpClient:
33
+ def __init__(self, config: Config):
34
+ self.config = config
35
+
36
+ def post(self, endpoint: str, body: Dict[str, Any]) -> Dict[str, Any]:
37
+ body_str = json.dumps(body, separators=(',', ':'))
38
+ headers = self._auth_headers('POST', endpoint, body_str)
39
+ try:
40
+ timeout_sec = self.config.timeout / 1000.0
41
+ response = requests.post(
42
+ f"{self.config.base_url}{endpoint}", data=body_str, headers=headers,
43
+ timeout=timeout_sec, allow_redirects=False,
44
+ )
45
+ except requests.exceptions.RequestException as e:
46
+ raise TouchQueNetworkException(f"Network Error: {e}") from e
47
+ return self._handle_response(response)
48
+
49
+ def put(self, endpoint: str, body: Dict[str, Any]) -> Dict[str, Any]:
50
+ return self._send('PUT', endpoint, body)
51
+
52
+ def patch(self, endpoint: str, body: Dict[str, Any]) -> Dict[str, Any]:
53
+ return self._send('PATCH', endpoint, body)
54
+
55
+ def _send(self, method: str, endpoint: str, body: Dict[str, Any]) -> Dict[str, Any]:
56
+ body_str = json.dumps(body, separators=(',', ':'))
57
+ headers = self._auth_headers(method, endpoint, body_str)
58
+ try:
59
+ response = requests.request(
60
+ method, f"{self.config.base_url}{endpoint}", data=body_str, headers=headers,
61
+ timeout=self.config.timeout / 1000.0, allow_redirects=False,
62
+ )
63
+ except requests.exceptions.RequestException as e:
64
+ raise TouchQueNetworkException(f"Network Error: {e}") from e
65
+ return self._handle_response(response)
66
+
67
+ def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
68
+ query = _build_query(params)
69
+ path_with_query = endpoint + query
70
+ headers = self._auth_headers('GET', path_with_query, '')
71
+ try:
72
+ timeout_sec = self.config.timeout / 1000.0
73
+ # The query string is sent as part of the URL (not `requests`'
74
+ # `params=`) so the bytes on the wire match what was signed.
75
+ response = requests.get(
76
+ f"{self.config.base_url}{path_with_query}", headers=headers,
77
+ timeout=timeout_sec, allow_redirects=False,
78
+ )
79
+ except requests.exceptions.RequestException as e:
80
+ raise TouchQueNetworkException(f"Network Error: {e}") from e
81
+ return self._handle_response(response)
82
+
83
+ def get_text(self, endpoint: str) -> str:
84
+ """A GET whose answer is a file (CSV or JSON lines), returned as text."""
85
+ headers = self._auth_headers('GET', endpoint, '')
86
+ try:
87
+ response = requests.get(
88
+ f"{self.config.base_url}{endpoint}", headers=headers,
89
+ timeout=self.config.timeout / 1000.0, allow_redirects=False,
90
+ )
91
+ except requests.exceptions.RequestException as e:
92
+ raise TouchQueNetworkException(f"Network Error: {e}") from e
93
+ if response.status_code >= 400:
94
+ self._handle_response(response)
95
+ return response.text
96
+
97
+ def delete(self, endpoint: str) -> Dict[str, Any]:
98
+ headers = self._auth_headers('DELETE', endpoint, '')
99
+ try:
100
+ timeout_sec = self.config.timeout / 1000.0
101
+ response = requests.delete(
102
+ f"{self.config.base_url}{endpoint}", headers=headers,
103
+ timeout=timeout_sec, allow_redirects=False,
104
+ )
105
+ except requests.exceptions.RequestException as e:
106
+ raise TouchQueNetworkException(f"Network Error: {e}") from e
107
+ return self._handle_response(response)
108
+
109
+ def _auth_headers(self, method: str, path_with_query: str, body: str) -> Dict[str, str]:
110
+ timestamp = str(int(time.time() * 1000))
111
+ # 16 bytes / 32 hex chars — the API requires at least 16 hex chars.
112
+ nonce = os.urandom(16).hex()
113
+ signature = self._sign_request(method, path_with_query, body, timestamp, nonce)
114
+ return {
115
+ 'x-api-key': self.config.api_key,
116
+ 'x-signature': signature,
117
+ 'x-timestamp': timestamp,
118
+ 'x-nonce': nonce,
119
+ 'Content-Type': 'application/json',
120
+ 'Accept': 'application/json',
121
+ 'User-Agent': f'touchque-python-sdk/{SDK_VERSION}',
122
+ }
123
+
124
+ def _handle_response(self, response: 'requests.Response') -> Dict[str, Any]:
125
+ try:
126
+ response_data = response.json()
127
+ except ValueError:
128
+ response_data = {}
129
+
130
+ if response.status_code >= 400:
131
+ error_message = response_data.get('error') or response_data.get('message') or 'Unknown API Error'
132
+ retry_after = response_data.get('retryAfter')
133
+ if retry_after is None:
134
+ header_val = response.headers.get('Retry-After')
135
+ try:
136
+ retry_after = int(header_val) if header_val else None
137
+ except ValueError:
138
+ retry_after = None
139
+ raise api_exception(
140
+ error_message,
141
+ status=response.status_code,
142
+ code=response_data.get('code'),
143
+ reason=response_data.get('reason'),
144
+ attempts_left=response_data.get('attemptsLeft'),
145
+ retry_after=retry_after,
146
+ data=response_data,
147
+ case_code=response_data.get('caseCode') if isinstance(response_data.get('caseCode'), str) else None,
148
+ )
149
+
150
+ return response_data
151
+
152
+ def _sign_request(self, method: str, path: str, body: str, timestamp: str, nonce: str) -> str:
153
+ body_hash = hashlib.sha256((body or '').encode('utf-8')).hexdigest()
154
+ message = f"{method.upper()}:{path}:{timestamp}:{nonce}:{body_hash}"
155
+ mac = hmac.new(self.config.api_secret.encode('utf-8'), message.encode('utf-8'), digestmod=hashlib.sha256)
156
+ return mac.hexdigest()