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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Cross App Access: Identity Assertion JWT Authorization Grants (ID-JAG,
|
|
2
|
+
draft-ietf-oauth-identity-assertion-authz-grant, an open standard).
|
|
3
|
+
|
|
4
|
+
An organization's identity provider (TouchQue SSO) decides which of its apps
|
|
5
|
+
may reach which other service for its members; the app trades a member's ID
|
|
6
|
+
token for an ID-JAG there, then the ID-JAG for an access token at the other
|
|
7
|
+
service's authorization server, with no consent screen per member.
|
|
8
|
+
|
|
9
|
+
Your app, signed in with TouchQue SSO (an OpenID Connect app with a secret)::
|
|
10
|
+
|
|
11
|
+
jag = request_id_jag(issuer='https://sso.touchque.com', client_id=client_id, client_secret=secret,
|
|
12
|
+
id_token=id_token, audience='https://auth.files.example')
|
|
13
|
+
tokens = redeem_id_jag(token_endpoint='https://auth.files.example/token', client_id='acme',
|
|
14
|
+
client_secret=files_secret, assertion=jag['assertion'])
|
|
15
|
+
|
|
16
|
+
Your own authorization server, taking ID-JAGs from an organization's identity provider::
|
|
17
|
+
|
|
18
|
+
verifier = IdJagVerifier(issuer='https://sso.touchque.com', audience='https://auth.files.example')
|
|
19
|
+
claims = verifier.verify(form['assertion'], client_id=authenticated_client_id)
|
|
20
|
+
# claims['sub'] (the member at that identity provider), claims['email'], claims['scopes'], claims['resource']
|
|
21
|
+
|
|
22
|
+
Needs the optional ``cryptography`` package for verifying: ``pip install "touchque-authenticator[agent]"``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import base64
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
29
|
+
from urllib.parse import quote, urlparse
|
|
30
|
+
|
|
31
|
+
import requests
|
|
32
|
+
|
|
33
|
+
from .exceptions import TouchQueConfigException
|
|
34
|
+
from .mcp_auth import _json_part, _b64d, _public_key, _verify
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
'ID_JAG_TOKEN_TYPE', 'ID_TOKEN_TYPE', 'TOKEN_EXCHANGE_GRANT', 'JWT_BEARER_GRANT',
|
|
38
|
+
'IdJagError', 'IdJagVerifier', 'request_id_jag', 'redeem_id_jag',
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
ID_JAG_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id-jag'
|
|
42
|
+
ID_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token'
|
|
43
|
+
TOKEN_EXCHANGE_GRANT = 'urn:ietf:params:oauth:grant-type:token-exchange'
|
|
44
|
+
JWT_BEARER_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
|
|
45
|
+
_TYP = 'oauth-id-jag+jwt'
|
|
46
|
+
_ALGS = ('RS256', 'ES256', 'EdDSA')
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class IdJagError(Exception):
|
|
50
|
+
"""A refused ID-JAG or exchange; ``code`` is the OAuth error (invalid_grant for a bad ID-JAG)."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, code: str, message: str, status: Optional[int] = None):
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.code = code
|
|
55
|
+
self.status = status
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _safe_url(value: str) -> bool:
|
|
59
|
+
u = urlparse(value or '')
|
|
60
|
+
return u.scheme == 'https' or (u.scheme == 'http' and u.hostname in ('localhost', '127.0.0.1', '::1'))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _get_json(url: str) -> Dict[str, Any]:
|
|
64
|
+
r = requests.get(url, timeout=5, allow_redirects=False, headers={'Accept': 'application/json'})
|
|
65
|
+
if r.status_code != 200:
|
|
66
|
+
raise RuntimeError(f'{url} answered {r.status_code}')
|
|
67
|
+
return r.json()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _discover(issuer: str) -> Dict[str, Any]:
|
|
71
|
+
meta = _get_json(issuer.rstrip('/') + '/.well-known/openid-configuration')
|
|
72
|
+
if meta.get('issuer') != issuer:
|
|
73
|
+
raise TouchQueConfigException("the identity provider's metadata does not match its issuer")
|
|
74
|
+
return meta
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class IdJagVerifier:
|
|
78
|
+
"""Checks the ID-JAG an authenticated client presents at your authorization server (jwt-bearer grant)."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, issuer: str, audience: str, jwks_uri: Optional[str] = None, clock_skew: int = 60,
|
|
81
|
+
first_use: Optional[Callable[[str, int], bool]] = None):
|
|
82
|
+
if not issuer or not audience:
|
|
83
|
+
raise TouchQueConfigException("IdJagVerifier needs issuer (the identity provider) and audience (your authorization server's issuer)")
|
|
84
|
+
self.issuer = issuer
|
|
85
|
+
self.audience = audience
|
|
86
|
+
self.jwks_uri = jwks_uri
|
|
87
|
+
self.skew = clock_skew
|
|
88
|
+
# Records an ID-JAG's jti and says whether it is new; memory in this process by default.
|
|
89
|
+
self.first_use = first_use or self._remember_once
|
|
90
|
+
self._keys: Dict[str, Any] = {}
|
|
91
|
+
self._keys_at = 0.0
|
|
92
|
+
self._tried_at = 0.0
|
|
93
|
+
self._seen: Dict[str, int] = {}
|
|
94
|
+
self._lock = threading.Lock()
|
|
95
|
+
|
|
96
|
+
def _remember_once(self, jti: str, expires_at: int) -> bool:
|
|
97
|
+
now = int(time.time())
|
|
98
|
+
with self._lock:
|
|
99
|
+
for k in [k for k, exp in self._seen.items() if exp < now]:
|
|
100
|
+
del self._seen[k]
|
|
101
|
+
if jti in self._seen:
|
|
102
|
+
return False
|
|
103
|
+
self._seen[jti] = expires_at + self.skew
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
def _load_keys(self) -> None:
|
|
107
|
+
if not self.jwks_uri:
|
|
108
|
+
meta = _discover(self.issuer)
|
|
109
|
+
if not isinstance(meta.get('jwks_uri'), str):
|
|
110
|
+
raise TouchQueConfigException('the identity provider names no jwks_uri')
|
|
111
|
+
self.jwks_uri = meta['jwks_uri']
|
|
112
|
+
jwks = _get_json(self.jwks_uri)
|
|
113
|
+
self._keys = {k['kid']: _public_key(k) for k in jwks.get('keys', []) if k.get('kid')}
|
|
114
|
+
self._keys_at = time.time()
|
|
115
|
+
|
|
116
|
+
def _key_for(self, kid: str):
|
|
117
|
+
if not self._keys_at or time.time() - self._keys_at > 600:
|
|
118
|
+
self._load_keys()
|
|
119
|
+
if kid not in self._keys and time.time() - self._tried_at > 60:
|
|
120
|
+
self._tried_at = time.time()
|
|
121
|
+
self._load_keys()
|
|
122
|
+
return self._keys.get(kid)
|
|
123
|
+
|
|
124
|
+
def verify(self, assertion: str, client_id: str) -> Dict[str, Any]:
|
|
125
|
+
"""The ID-JAG, checked and used once: its claims, or IdJagError (code invalid_grant)."""
|
|
126
|
+
def bad(message: str) -> IdJagError:
|
|
127
|
+
return IdJagError('invalid_grant', message)
|
|
128
|
+
|
|
129
|
+
parts = assertion.split('.') if isinstance(assertion, str) else []
|
|
130
|
+
if len(parts) != 3:
|
|
131
|
+
raise bad('assertion is not a JWT')
|
|
132
|
+
try:
|
|
133
|
+
head = _json_part(parts[0])
|
|
134
|
+
c = _json_part(parts[1])
|
|
135
|
+
sig = _b64d(parts[2])
|
|
136
|
+
except ValueError:
|
|
137
|
+
raise bad('assertion is not a JWT')
|
|
138
|
+
if head.get('typ') != _TYP:
|
|
139
|
+
raise bad('not an ID-JAG (typ oauth-id-jag+jwt)')
|
|
140
|
+
if head.get('alg') not in _ALGS or not isinstance(head.get('kid'), str):
|
|
141
|
+
raise bad('unsupported signature')
|
|
142
|
+
try:
|
|
143
|
+
key = self._key_for(head['kid'])
|
|
144
|
+
except Exception as err: # the provider's keys could not be read
|
|
145
|
+
raise IdJagError('temporarily_unavailable', f"the identity provider's keys could not be read: {err}")
|
|
146
|
+
if key is None or not _verify(f'{parts[0]}.{parts[1]}'.encode(), sig, head['alg'], key):
|
|
147
|
+
raise bad('signature does not verify')
|
|
148
|
+
now = int(time.time())
|
|
149
|
+
auds = c.get('aud') if isinstance(c.get('aud'), list) else [c.get('aud')]
|
|
150
|
+
if c.get('iss') != self.issuer:
|
|
151
|
+
raise bad('another issuer')
|
|
152
|
+
if len(auds) != 1 or auds[0] != self.audience:
|
|
153
|
+
raise bad('not for this authorization server')
|
|
154
|
+
if not isinstance(c.get('client_id'), str) or c['client_id'] != client_id:
|
|
155
|
+
raise bad('issued to another client')
|
|
156
|
+
if not isinstance(c.get('exp'), (int, float)) or c['exp'] + self.skew < now:
|
|
157
|
+
raise bad('expired')
|
|
158
|
+
if not isinstance(c.get('iat'), (int, float)) or c['iat'] - self.skew > now:
|
|
159
|
+
raise bad('issued in the future')
|
|
160
|
+
if not isinstance(c.get('sub'), str) or not isinstance(c.get('jti'), str):
|
|
161
|
+
raise bad('sub and jti are required')
|
|
162
|
+
if not self.first_use(c['jti'], int(c['exp'])):
|
|
163
|
+
raise bad('already used')
|
|
164
|
+
scope = c.get('scope')
|
|
165
|
+
return {
|
|
166
|
+
'iss': c['iss'], 'sub': c['sub'], 'aud': auds[0], 'client_id': c['client_id'], 'jti': c['jti'],
|
|
167
|
+
'issued_at': int(c['iat']), 'expires_at': int(c['exp']),
|
|
168
|
+
'scopes': scope.split(' ') if isinstance(scope, str) and scope else [],
|
|
169
|
+
'resource': c.get('resource'), 'tenant': c.get('tenant'), 'email': c.get('email'),
|
|
170
|
+
'auth_time': c.get('auth_time'), 'amr': c.get('amr'), 'claims': c,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _post_form(url: str, form: Dict[str, Optional[str]], basic: Optional[tuple] = None) -> Dict[str, Any]:
|
|
175
|
+
if not _safe_url(url):
|
|
176
|
+
raise TouchQueConfigException(f'{url} must be an https URL')
|
|
177
|
+
headers = {'Accept': 'application/json'}
|
|
178
|
+
if basic:
|
|
179
|
+
raw = f'{quote(basic[0], safe="")}:{quote(basic[1], safe="")}'
|
|
180
|
+
headers['Authorization'] = 'Basic ' + base64.b64encode(raw.encode()).decode()
|
|
181
|
+
r = requests.post(url, data={k: v for k, v in form.items() if v is not None}, headers=headers, timeout=10, allow_redirects=False)
|
|
182
|
+
try:
|
|
183
|
+
data = r.json()
|
|
184
|
+
except ValueError:
|
|
185
|
+
data = {}
|
|
186
|
+
if r.status_code >= 400:
|
|
187
|
+
raise IdJagError(str(data.get('error') or 'server_error'), str(data.get('error_description') or f'{url} answered {r.status_code}'), r.status_code)
|
|
188
|
+
return data
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _scope_text(scope: Union[str, List[str], None]) -> Optional[str]:
|
|
192
|
+
return ' '.join(scope) if isinstance(scope, (list, tuple)) else scope
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def request_id_jag(*, client_id: str, client_secret: str, id_token: str, audience: str, issuer: Optional[str] = None,
|
|
196
|
+
token_endpoint: Optional[str] = None, resource: Optional[str] = None,
|
|
197
|
+
scope: Union[str, List[str], None] = None) -> Dict[str, Any]:
|
|
198
|
+
"""Trades a member's ID token for an ID-JAG at the identity provider (token exchange, client_secret_basic):
|
|
199
|
+
``{'assertion', 'expires_in', 'scope'}``."""
|
|
200
|
+
if not token_endpoint:
|
|
201
|
+
if not issuer:
|
|
202
|
+
raise TouchQueConfigException('request_id_jag needs issuer or token_endpoint')
|
|
203
|
+
token_endpoint = _discover(issuer).get('token_endpoint')
|
|
204
|
+
if not isinstance(token_endpoint, str):
|
|
205
|
+
raise TouchQueConfigException('the identity provider names no token_endpoint')
|
|
206
|
+
data = _post_form(token_endpoint, {
|
|
207
|
+
'grant_type': TOKEN_EXCHANGE_GRANT, 'requested_token_type': ID_JAG_TOKEN_TYPE,
|
|
208
|
+
'subject_token': id_token, 'subject_token_type': ID_TOKEN_TYPE,
|
|
209
|
+
'audience': audience, 'resource': resource, 'scope': _scope_text(scope),
|
|
210
|
+
}, (client_id, client_secret))
|
|
211
|
+
if data.get('issued_token_type') != ID_JAG_TOKEN_TYPE or not isinstance(data.get('access_token'), str):
|
|
212
|
+
raise IdJagError('invalid_response', 'the identity provider did not answer with an ID-JAG')
|
|
213
|
+
return {'assertion': data['access_token'], 'expires_in': data.get('expires_in'), 'scope': data.get('scope')}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def redeem_id_jag(*, token_endpoint: str, client_id: str, assertion: str, client_secret: Optional[str] = None,
|
|
217
|
+
resource: Optional[str] = None, scope: Union[str, List[str], None] = None) -> Dict[str, Any]:
|
|
218
|
+
"""Trades an ID-JAG for an access token at the other service's authorization server (jwt-bearer)."""
|
|
219
|
+
form = {'grant_type': JWT_BEARER_GRANT, 'assertion': assertion, 'resource': resource, 'scope': _scope_text(scope)}
|
|
220
|
+
if not client_secret:
|
|
221
|
+
form['client_id'] = client_id
|
|
222
|
+
return _post_form(token_endpoint, form, (client_id, client_secret) if client_secret else None)
|
touchque/exceptions.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from typing import Optional, Dict, Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TouchQueException(Exception):
|
|
5
|
+
def __init__(self, message: str, data: Optional[Dict[str, Any]] = None):
|
|
6
|
+
super().__init__(message)
|
|
7
|
+
self.data = data
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TouchQueAPIException(TouchQueException):
|
|
11
|
+
"""Raised when the API returns an error response (status >= 400)."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
status: Optional[int] = None,
|
|
17
|
+
code: Optional[str] = None,
|
|
18
|
+
reason: Optional[str] = None,
|
|
19
|
+
attempts_left: Optional[int] = None,
|
|
20
|
+
retry_after: Optional[int] = None,
|
|
21
|
+
data: Optional[Dict[str, Any]] = None,
|
|
22
|
+
case_code: Optional[str] = None,
|
|
23
|
+
):
|
|
24
|
+
super().__init__(message, data=data)
|
|
25
|
+
#: HTTP status code (e.g. 404, 429, 423).
|
|
26
|
+
self.status = status
|
|
27
|
+
#: Machine-readable error code (e.g. "device_not_linked", "unknown_action").
|
|
28
|
+
self.code = code
|
|
29
|
+
#: Offline sign: why a code was not approved.
|
|
30
|
+
self.reason = reason
|
|
31
|
+
#: Offline sign: wrong codes left before the challenge locks.
|
|
32
|
+
self.attempts_left = attempts_left
|
|
33
|
+
#: Seconds to wait before retrying (429 / 423).
|
|
34
|
+
self.retry_after = retry_after
|
|
35
|
+
#: "Why?" case code of a refusal (``K7P-2QX``): show it to the user
|
|
36
|
+
#: ("Tell your support team: K7P-2QX"); your team opens the decision by it
|
|
37
|
+
#: in the dashboard. ``None`` for other errors.
|
|
38
|
+
self.case_code = case_code
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TouchQueFeatureException(TouchQueAPIException):
|
|
42
|
+
"""The organization behind your workspace cannot use this feature here (HTTP 403).
|
|
43
|
+
|
|
44
|
+
``reason``:
|
|
45
|
+
|
|
46
|
+
- ``not_in_plan`` -- not in its plan (``feature_not_in_plan``): an owner or
|
|
47
|
+
admin adds it in the TouchQue dashboard;
|
|
48
|
+
- ``read_only`` -- being turned off (``feature_read_only``): reads still work
|
|
49
|
+
for 14 days, changes are refused;
|
|
50
|
+
- ``off`` -- switched off on this TouchQue deployment (``feature_off``).
|
|
51
|
+
|
|
52
|
+
``feature`` is the catalog id (``device_risk``, ``ai_agents``, ``mobile_sdk``,
|
|
53
|
+
``ciam``...). A subclass of TouchQueAPIException, so existing ``except``
|
|
54
|
+
blocks still catch it; catch one of the three subclasses to tell them apart.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
reason_code = ''
|
|
58
|
+
|
|
59
|
+
def __init__(self, message: str, **kwargs: Any):
|
|
60
|
+
super().__init__(message, **kwargs)
|
|
61
|
+
data = self.data or {}
|
|
62
|
+
#: The catalog id of the feature (``device_risk``...), or None.
|
|
63
|
+
self.feature: Optional[str] = data.get('feature') if isinstance(data.get('feature'), str) else None
|
|
64
|
+
#: ``not_in_plan``, ``read_only`` or ``off``.
|
|
65
|
+
self.feature_reason = self.reason_code
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TouchQueFeatureNotInPlanException(TouchQueFeatureException):
|
|
69
|
+
"""``feature_not_in_plan``: the feature is not in the organization's plan."""
|
|
70
|
+
reason_code = 'not_in_plan'
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TouchQueFeatureReadOnlyException(TouchQueFeatureException):
|
|
74
|
+
"""``feature_read_only``: the feature is being turned off; reads work, changes are refused."""
|
|
75
|
+
reason_code = 'read_only'
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TouchQueFeatureOffException(TouchQueFeatureException):
|
|
79
|
+
"""``feature_off``: the feature is switched off on this TouchQue deployment."""
|
|
80
|
+
reason_code = 'off'
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_FEATURE_ERRORS = {
|
|
84
|
+
'feature_not_in_plan': TouchQueFeatureNotInPlanException,
|
|
85
|
+
'feature_read_only': TouchQueFeatureReadOnlyException,
|
|
86
|
+
'feature_off': TouchQueFeatureOffException,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def api_exception(message: str, **kwargs: Any) -> TouchQueAPIException:
|
|
91
|
+
"""The exception for an error answer: a feature exception for the three feature codes, else TouchQueAPIException."""
|
|
92
|
+
data = kwargs.get('data') or {}
|
|
93
|
+
cls = _FEATURE_ERRORS.get(data.get('error')) if isinstance(data, dict) else None
|
|
94
|
+
return (cls or TouchQueAPIException)(message, **kwargs)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TouchQueNetworkException(TouchQueException):
|
|
98
|
+
"""Raised when the request never reached the API (DNS, connection, timeout)."""
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TouchQueTimeoutException(TouchQueException):
|
|
103
|
+
"""Raised when a polling operation (like login.verify) times out."""
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TouchQueRejectedException(TouchQueException):
|
|
108
|
+
"""Raised when a request is explicitly rejected by the user."""
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TouchQueWebhookSignatureException(TouchQueException):
|
|
113
|
+
"""Raised when an incoming webhook signature is invalid."""
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TouchQueWebhookReplayException(TouchQueWebhookSignatureException):
|
|
118
|
+
"""
|
|
119
|
+
Raised when a correctly signed webhook with this ``jti`` was already
|
|
120
|
+
accepted. Usually a TouchQue retry of a delivery you processed: answer
|
|
121
|
+
200 so it stops, but don't run your side effects again.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(self, jti: str):
|
|
125
|
+
super().__init__("Webhook was already accepted (replayed jti)")
|
|
126
|
+
self.jti = jti
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TouchQueConfigException(TouchQueException):
|
|
130
|
+
"""Raised when configuration is invalid or incomplete."""
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TouchQuePasskeyRequiredError(TouchQueException):
|
|
135
|
+
"""Raised when the request must be approved with a passkey.
|
|
136
|
+
|
|
137
|
+
``reason`` is ``'policy'`` when a phishing-resistant policy requires it (no
|
|
138
|
+
push was sent), or ``'step_up'`` when the user tapped Approve on the phone
|
|
139
|
+
during a call (Vishing Shield) and the request, still open, now waits for
|
|
140
|
+
a passkey in the browser."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, request_id: str, reason: str = 'policy'):
|
|
143
|
+
if reason == 'step_up':
|
|
144
|
+
message = (f"TouchQue: request '{request_id}' must now be approved with a passkey: "
|
|
145
|
+
"the user approved on the phone during a call.")
|
|
146
|
+
else:
|
|
147
|
+
message = (f"TouchQue: request '{request_id}' must be approved with a passkey "
|
|
148
|
+
"(phishing-resistant policy); no push was sent.")
|
|
149
|
+
super().__init__(message)
|
|
150
|
+
self.request_id = request_id
|
|
151
|
+
self.reason = reason
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class TouchQueApprovalError(TouchQueException):
|
|
155
|
+
"""An AI agent's approval ended without a usable approval: ``status`` is
|
|
156
|
+
rejected, blocked, revoked, expired, stale (approved too long ago) or
|
|
157
|
+
redeemed (already used)."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, approval_id: str, status: str, reason: Optional[str] = None):
|
|
160
|
+
super().__init__(f"TouchQue: approval '{approval_id}' is {status}" + (f' ({reason})' if reason else ''))
|
|
161
|
+
self.approval_id = approval_id
|
|
162
|
+
self.status = status
|
|
163
|
+
self.reason = reason
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class TouchQueApprovalMismatchError(TouchQueException):
|
|
167
|
+
"""An approval token does not say what you are about to execute (another
|
|
168
|
+
person, action type, amount, recipient...), or it is not a valid token
|
|
169
|
+
from TouchQue. Do not execute."""
|
|
170
|
+
pass
|