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
touchque/guard.py ADDED
@@ -0,0 +1,290 @@
1
+ """
2
+ Framework-agnostic step-up guard. The Django, FastAPI and Flask adapters in
3
+ ``touchque.contrib`` are thin wrappers over ``run_guard()``.
4
+
5
+ Contract (same in every TouchQue server SDK):
6
+ 1st request -> 202 {"touchque": step, "token": ...} show the step in your UI
7
+ repeat with the token -> 202 while waiting, then the protected handler runs once
8
+ refused -> 403 / 408 / 423 / 429 {"touchque": {"state": ..., ...}}
9
+
10
+ Request headers the browser sends back:
11
+ X-TouchQue-Token the token from the last response
12
+ X-TouchQue-Offline: 1 switch to offline approval (phone has no internet)
13
+ X-TouchQue-Code the code from the phone (offline QR code, or with
14
+ X-TouchQue-Code-Type: totp the rolling time-based code)
15
+ X-TouchQue-Browser the browser's device-bound key proof (silent sign-ins,
16
+ read only when the adapter's ``silent_sign_in`` is on)
17
+ """
18
+ import logging
19
+ import time
20
+ from typing import Any, Callable, Dict, Optional, Union
21
+
22
+ from .exceptions import TouchQueAPIException, TouchQueConfigException, TouchQueNetworkException
23
+ from .steps import (
24
+ LoginDetails, check, complete, details_digest, normalize_details, sign_guard_token, start,
25
+ verify_guard_token,
26
+ )
27
+ from typing import TYPE_CHECKING
28
+ if TYPE_CHECKING:
29
+ from .client import TouchQue
30
+
31
+ logger = logging.getLogger('touchque')
32
+
33
+ TOKEN_TTL_MS = 10 * 60 * 1000
34
+
35
+ TOKEN_HEADER = 'x-touchque-token'
36
+ OFFLINE_HEADER = 'x-touchque-offline'
37
+ CODE_HEADER = 'x-touchque-code'
38
+ CODE_TYPE_HEADER = 'x-touchque-code-type'
39
+ BROWSER_HEADER = 'x-touchque-browser'
40
+
41
+ #: A real browser proof is a few hundred characters; anything far larger is not one.
42
+ MAX_BROWSER_PROOF = 4096
43
+
44
+ #: ``silent_sign_in``: ``True``, or ``{'max_age_seconds': n}`` for your own limit
45
+ #: on the age of the last phone approval in the browser (``0`` = always ask;
46
+ #: TouchQue never goes beyond its own limit, 7 days).
47
+ SilentSignIn = Union[bool, Dict[str, Any], None]
48
+
49
+
50
+ def _silent_on(option: SilentSignIn) -> bool:
51
+ # A dict turns it on even when empty (``{}``: TouchQue's own limit only).
52
+ return isinstance(option, dict) or bool(option)
53
+
54
+
55
+ _STATUS = {
56
+ 'waiting': 202, 'enroll': 202, 'passkey_required': 202, 'offline': 202,
57
+ 'approved': 200, 'rejected': 403, 'blocked': 403, 'expired': 408, 'frozen': 423, 'rate_limited': 429,
58
+ }
59
+
60
+
61
+ def guard_input_from_headers(get: Callable[[str], Optional[str]]) -> Dict[str, Any]:
62
+ """Reads the guard headers from any case-insensitive header getter."""
63
+ offline = get(OFFLINE_HEADER)
64
+ return {
65
+ 'token': get(TOKEN_HEADER) or None,
66
+ 'offline': offline in ('1', 'true'),
67
+ 'code': get(CODE_HEADER) or None,
68
+ 'code_type': get(CODE_TYPE_HEADER) or None,
69
+ }
70
+
71
+
72
+ def silent_input_from_headers(option: SilentSignIn, get: Callable[[str], Optional[str]]) -> Dict[str, Any]:
73
+ """The ``run_guard`` keyword arguments an adapter adds for silent sign-ins:
74
+ the ``X-TouchQue-Browser`` proof, read only when ``option`` is on."""
75
+ if not _silent_on(option):
76
+ return {}
77
+ return {'silent_sign_in': option, 'browser_proof': get(BROWSER_HEADER) or None}
78
+
79
+
80
+ def is_status_poll(client: 'TouchQue', user: Optional[str], action: str, details: Optional[LoginDetails] = None,
81
+ reference_id: Optional[str] = None, token: Optional[str] = None, offline: bool = False,
82
+ code: Optional[str] = None, **_: Any) -> bool:
83
+ """True when this request only polls an approval that is already on its way:
84
+ a valid token bound to this user/action/details, still waiting, and no
85
+ offline switch or typed code. It can only read the status, never start a
86
+ push, so relays do not count it against the per-user push limit (with a
87
+ 1.5 s poll the 6th poll was refused after 7.5 s)."""
88
+ if not user or not token or offline or code:
89
+ return False
90
+ claims = verify_guard_token(client._api_secret, token)
91
+ if not claims or not claims.get('rid'):
92
+ return False
93
+ return claims.get('u') == user and claims.get('a') == action \
94
+ and claims.get('d') == details_digest(details) and claims.get('r') == (reference_id or None) \
95
+ and claims.get('st') in ('waiting', 'passkey_required', 'offline')
96
+
97
+
98
+ def run_guard(
99
+ client: 'TouchQue',
100
+ user: Optional[str],
101
+ action: str,
102
+ details: Optional[LoginDetails] = None,
103
+ reference_id: Optional[str] = None,
104
+ ip: Optional[str] = None,
105
+ user_agent: Optional[str] = None,
106
+ token: Optional[str] = None,
107
+ offline: bool = False,
108
+ code: Optional[str] = None,
109
+ code_type: Optional[str] = None,
110
+ enroll: bool = True,
111
+ browser_proof: Optional[str] = None,
112
+ silent_sign_in: SilentSignIn = False,
113
+ context: Optional[Dict[str, Any]] = None,
114
+ ) -> Dict[str, Any]:
115
+ """Runs one step of the guard. Returns either
116
+ ``{"approved": {...}, "status": 200}`` (run your protected action) or
117
+ ``{"status": <code>, "body": {"touchque": step, "token": ...}}``
118
+ (send that JSON body with that status code).
119
+
120
+ ``browser_proof``: the browser's device-bound key proof
121
+ (``X-TouchQue-Browser``), passed through untouched and attached to the
122
+ push, so its approval makes the browser known.
123
+
124
+ ``silent_sign_in`` (default off): a new attempt that carries
125
+ ``browser_proof`` first asks TouchQue (``risk.check``); when it answers
126
+ exactly ``prompt: False`` the guard approves at once, with no push
127
+ (``assurance['method'] == 'silent'``, ``confirmedVia == 'SILENT'``,
128
+ ``requestId == 'silent'``, ``reasons``). Otherwise, and on any error in
129
+ the check, the usual push starts. ``{'max_age_seconds': n}`` sets your own
130
+ limit on how long ago the last phone approval in that browser may be.
131
+
132
+ ``context``: for your TouchQue Actions only (``event.context``).
133
+ """
134
+ if not user:
135
+ return {'status': 401, 'body': {
136
+ 'touchque': {'state': 'blocked', 'reason': 'unauthenticated'},
137
+ 'error': {'code': 'unauthenticated', 'message': 'Sign in first.'},
138
+ }}
139
+
140
+ norm = normalize_details(details)
141
+ base = {'u': user, 'a': action, 'd': details_digest(details), 'r': reference_id or None}
142
+
143
+ def issue(step: Dict[str, Any], extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
144
+ if norm and not step.get('details') and step.get('state') != 'blocked':
145
+ step['details'] = norm
146
+ claims = {**base, 'st': step['state'], 'rid': step.get('requestId'), 'n': step.get('number'),
147
+ 'oc': (step.get('offline') or {}).get('challengeId'), 'exp': int(time.time() * 1000) + TOKEN_TTL_MS}
148
+ if step.get('caseCode'):
149
+ claims['cc'] = step['caseCode'] # "Why?": still shown while it waits
150
+ if extra:
151
+ claims.update(extra)
152
+ signed = sign_guard_token(client._api_secret, claims)
153
+ return {'status': _STATUS[step['state']], 'body': {'touchque': step, 'token': signed}}
154
+
155
+ try:
156
+ claims = verify_guard_token(client._api_secret, token)
157
+ bound = claims if claims and claims.get('u') == base['u'] and claims.get('a') == base['a'] \
158
+ and claims.get('d') == base['d'] and claims.get('r') == base['r'] else None
159
+
160
+ # Offline: the user typed the code from the phone.
161
+ if bound and code and (bound.get('st') == 'offline' or code_type == 'totp'):
162
+ is_totp = code_type == 'totp'
163
+ if is_totp:
164
+ res = client.offline.verify_totp(user, code, type=action, client_ip=ip, request_id=bound.get('rid'))
165
+ else:
166
+ res = client.offline.verify(str(bound.get('oc')), code)
167
+ for_this = (not res.get('externalUsername') or res['externalUsername'].lower() == user.lower()) \
168
+ and (not res.get('type') or res.get('type') == action)
169
+ if res.get('approved') and for_this:
170
+ method = 'offline_totp' if is_totp else 'offline_code'
171
+ return {'status': 200, 'approved': {
172
+ 'requestId': bound.get('oc') or 'offline-totp', 'user': user, 'action': action,
173
+ 'assurance': {'phishingResistant': False, 'method': method},
174
+ 'confirmedVia': 'OFFLINE_TOTP' if is_totp else 'OFFLINE_CODE', 'approvalProof': None,
175
+ }}
176
+ if res.get('reason') == 'invalid_code' and not is_totp:
177
+ return issue({'state': 'offline', 'offline': {'challengeId': str(bound.get('oc')),
178
+ 'attemptsLeft': res.get('attemptsLeft')}},
179
+ {'oc': bound.get('oc'), 'rid': bound.get('rid'), 'n': bound.get('n')})
180
+ if res.get('reason') == 'invalid_code':
181
+ return issue({'state': 'offline', 'reason': 'invalid_code'},
182
+ {'oc': bound.get('oc'), 'rid': bound.get('rid'), 'n': bound.get('n')})
183
+ # The phone rejected the push this QR belongs to: the whole sign-in is over.
184
+ if res.get('reason') == 'request_rejected':
185
+ return issue({'state': 'rejected', 'requestId': bound.get('rid'), 'reason': 'request_rejected'})
186
+ # The push now waits for a passkey (Vishing Shield): no code may finish it.
187
+ if res.get('reason') == 'passkey_required' and bound.get('rid'):
188
+ return issue({'state': 'passkey_required', 'requestId': bound['rid'], 'number': bound.get('n')}, {'n': bound.get('n')})
189
+ return issue({'state': 'expired' if res.get('reason') == 'expired' else 'blocked',
190
+ 'reason': res.get('reason') or 'offline_failed'})
191
+
192
+ if offline:
193
+ try:
194
+ # Link the QR to the push it follows: a phone-side rejection kills it, and a push that
195
+ # asked for number matching makes the QR ask for the same number.
196
+ rid = (bound or {}).get('rid')
197
+ ch = client.offline.challenge(user, type=action, details=norm or None, client_ip=ip,
198
+ user_agent=user_agent, request_id=rid)
199
+ offline_step = {
200
+ 'challengeId': ch.get('challengeId'), 'qrDataUrl': ch.get('qrDataUrl'),
201
+ 'expiresAt': ch.get('expiresAt'), 'totpAvailable': ch.get('totpAvailable'),
202
+ }
203
+ if ch.get('challengeCode'):
204
+ offline_step['challengeCode'] = ch['challengeCode']
205
+ return issue({'state': 'offline', 'offline': offline_step},
206
+ {'rid': rid, 'n': ch.get('challengeCode') or (bound or {}).get('n')})
207
+ except TouchQueAPIException as err:
208
+ if err.status == 409 and (err.code or (err.data or {}).get('error')) == 'request_rejected':
209
+ return issue({'state': 'rejected', 'requestId': (bound or {}).get('rid'), 'reason': 'request_rejected'})
210
+ # The push it would follow now waits for a passkey (Vishing Shield): no QR, run the passkey.
211
+ if (bound or {}).get('rid') and (err.code or (err.data or {}).get('error')) == 'passkey_required':
212
+ return issue({'state': 'passkey_required', 'requestId': bound['rid'], 'number': bound.get('n')}, {'n': bound.get('n')})
213
+ if err.status is not None and err.status < 500:
214
+ return issue({'state': 'blocked', 'reason': err.code or (err.data or {}).get('error') or 'offline_unavailable'})
215
+ raise
216
+
217
+ # Waiting on a push / passkey — or showing the offline QR next to a push that is still open: poll, and
218
+ # run the action once approved. While the QR is up a phone-side REJECT ends the attempt at once.
219
+ if bound and bound.get('rid') and bound.get('st') in ('waiting', 'passkey_required', 'offline'):
220
+ now = check(client, bound['rid'])
221
+ if now['state'] == 'approved':
222
+ try:
223
+ approved = complete(client, bound['rid'], user, action, details, reference_id)
224
+ return {'status': 200, 'approved': approved}
225
+ except TouchQueAPIException as err:
226
+ if err.status == 409:
227
+ return issue({'state': 'expired', 'reason': err.code or 'already_used'})
228
+ raise
229
+ # The push behind the QR was moved to a passkey after a tap during a call (Vishing Shield):
230
+ # the QR's code would be refused now, so switch to the passkey.
231
+ if bound.get('st') == 'offline' and now['state'] == 'passkey_required':
232
+ return issue({'state': 'passkey_required', 'requestId': bound['rid'], 'number': bound.get('n')}, {'n': bound.get('n')})
233
+ if bound.get('st') == 'offline' and now['state'] in ('waiting', 'expired'):
234
+ # Still (or no longer) pending: nothing changed for the user — keep showing the QR they have.
235
+ return issue({'state': 'offline', 'offline': {'challengeId': str(bound.get('oc'))}},
236
+ {'oc': bound.get('oc'), 'rid': bound['rid'], 'n': bound.get('n')})
237
+ if now['state'] in ('waiting', 'passkey_required'):
238
+ waiting = {'state': now['state'], 'requestId': bound['rid'], 'number': bound.get('n')}
239
+ if bound.get('cc'):
240
+ waiting['caseCode'] = bound['cc']
241
+ return issue(waiting, {'n': bound.get('n')})
242
+ return issue({'state': now['state'], 'requestId': bound['rid']})
243
+
244
+ # Anything else (no/foreign token, enrollment finished, new attempt): start.
245
+ proof = browser_proof if isinstance(browser_proof, str) and browser_proof \
246
+ and len(browser_proof) <= MAX_BROWSER_PROOF else None
247
+ if proof and _silent_on(silent_sign_in):
248
+ silent = _silent_approval(client, user, action, proof, ip, user_agent, silent_sign_in, context)
249
+ if silent:
250
+ return {'status': 200, 'approved': silent}
251
+ return issue(start(client, action, user, details=norm or None, reference_id=reference_id, ip=ip,
252
+ user_agent=user_agent, enroll=enroll, browser_proof=proof, context=context))
253
+ except TouchQueConfigException as err:
254
+ logger.error('[TouchQue] %s', err)
255
+ return {'status': 500, 'body': {
256
+ 'touchque': {'state': 'blocked', 'reason': 'misconfigured'},
257
+ 'error': {'code': 'misconfigured', 'message': 'Two-factor approval is not configured correctly.'},
258
+ }}
259
+ except (TouchQueNetworkException, TouchQueAPIException) as err:
260
+ logger.error('[TouchQue] approval failed: %s: %s', type(err).__name__, err)
261
+ return {'status': 503, 'body': {
262
+ 'touchque': {'state': 'blocked', 'reason': 'unavailable'},
263
+ 'error': {'code': 'unavailable', 'message': 'Two-factor approval is temporarily unavailable.'},
264
+ }}
265
+
266
+
267
+ def _silent_approval(client: 'TouchQue', user: str, action: str, browser_proof: str, ip: Optional[str],
268
+ user_agent: Optional[str], silent_sign_in: SilentSignIn,
269
+ context: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
270
+ """Asks TouchQue whether this known browser may sign in without the phone.
271
+ Fails closed: only an answer of exactly ``prompt: False`` approves; any
272
+ error (network, 4xx, 5xx, a server without silent sign-ins) means "ask as
273
+ usual" and returns ``None``."""
274
+ try:
275
+ max_age = silent_sign_in.get('max_age_seconds') if isinstance(silent_sign_in, dict) else None
276
+ res = client.risk.check(user, type=action, client_ip=ip, user_agent=user_agent,
277
+ browser_proof=browser_proof, max_age_seconds=max_age, context=context)
278
+ if not isinstance(res, dict) or res.get('prompt') is not False:
279
+ return None
280
+ reasons = res.get('reasons')
281
+ return {
282
+ 'requestId': 'silent', 'user': user, 'action': action,
283
+ 'assurance': {'phishingResistant': False, 'method': 'silent'},
284
+ 'confirmedVia': 'SILENT', 'approvalProof': None,
285
+ # Why TouchQue let this known browser in; nothing was sent to the phone.
286
+ 'reasons': reasons if isinstance(reasons, list) else [],
287
+ }
288
+ except Exception as err: # noqa: BLE001 - fail closed: any error asks on the phone as usual
289
+ logger.warning('[TouchQue] silent sign-in check failed, asking on the phone: %s: %s', type(err).__name__, err)
290
+ return None