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