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.
- touchque/__init__.py +148 -0
- touchque/agent.py +314 -0
- touchque/agent_card.py +190 -0
- touchque/agent_frameworks.py +215 -0
- touchque/approval_proof.py +104 -0
- touchque/approval_token.py +134 -0
- touchque/client.py +105 -0
- touchque/code_reply.py +75 -0
- touchque/config.py +56 -0
- touchque/contrib/__init__.py +4 -0
- touchque/contrib/django.py +175 -0
- touchque/contrib/fastapi.py +208 -0
- touchque/contrib/flask.py +154 -0
- touchque/cross_app_access.py +222 -0
- touchque/exceptions.py +170 -0
- touchque/guard.py +290 -0
- touchque/hosted_login.py +474 -0
- touchque/http_client.py +156 -0
- touchque/mcp_auth.py +256 -0
- touchque/my_account.py +173 -0
- touchque/org_agent.py +262 -0
- touchque/rate_limiter.py +56 -0
- touchque/relay.py +643 -0
- touchque/resources/__init__.py +1 -0
- touchque/resources/action_types.py +52 -0
- touchque/resources/actions.py +234 -0
- touchque/resources/agents.py +236 -0
- touchque/resources/assist.py +115 -0
- touchque/resources/auth.py +65 -0
- touchque/resources/device_risk.py +174 -0
- touchque/resources/identity.py +268 -0
- touchque/resources/login.py +296 -0
- touchque/resources/mobile.py +130 -0
- touchque/resources/offline.py +138 -0
- touchque/resources/qr_pairings.py +93 -0
- touchque/resources/recovery.py +118 -0
- touchque/resources/risk.py +141 -0
- touchque/resources/safe_guard.py +99 -0
- touchque/resources/webauthn.py +115 -0
- touchque/resources/webhook.py +258 -0
- touchque/safe_guard.py +308 -0
- touchque/security_events.py +205 -0
- touchque/steps.py +239 -0
- touchque_authenticator-4.2.0.dist-info/METADATA +630 -0
- touchque_authenticator-4.2.0.dist-info/RECORD +48 -0
- touchque_authenticator-4.2.0.dist-info/WHEEL +5 -0
- touchque_authenticator-4.2.0.dist-info/licenses/LICENSE +21 -0
- touchque_authenticator-4.2.0.dist-info/top_level.txt +1 -0
touchque/hosted_login.py
ADDED
|
@@ -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
|
touchque/http_client.py
ADDED
|
@@ -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()
|