touchque-authenticator 4.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. touchque/__init__.py +148 -0
  2. touchque/agent.py +314 -0
  3. touchque/agent_card.py +190 -0
  4. touchque/agent_frameworks.py +215 -0
  5. touchque/approval_proof.py +104 -0
  6. touchque/approval_token.py +134 -0
  7. touchque/client.py +105 -0
  8. touchque/code_reply.py +75 -0
  9. touchque/config.py +56 -0
  10. touchque/contrib/__init__.py +4 -0
  11. touchque/contrib/django.py +175 -0
  12. touchque/contrib/fastapi.py +208 -0
  13. touchque/contrib/flask.py +154 -0
  14. touchque/cross_app_access.py +222 -0
  15. touchque/exceptions.py +170 -0
  16. touchque/guard.py +290 -0
  17. touchque/hosted_login.py +474 -0
  18. touchque/http_client.py +156 -0
  19. touchque/mcp_auth.py +256 -0
  20. touchque/my_account.py +173 -0
  21. touchque/org_agent.py +262 -0
  22. touchque/rate_limiter.py +56 -0
  23. touchque/relay.py +643 -0
  24. touchque/resources/__init__.py +1 -0
  25. touchque/resources/action_types.py +52 -0
  26. touchque/resources/actions.py +234 -0
  27. touchque/resources/agents.py +236 -0
  28. touchque/resources/assist.py +115 -0
  29. touchque/resources/auth.py +65 -0
  30. touchque/resources/device_risk.py +174 -0
  31. touchque/resources/identity.py +268 -0
  32. touchque/resources/login.py +296 -0
  33. touchque/resources/mobile.py +130 -0
  34. touchque/resources/offline.py +138 -0
  35. touchque/resources/qr_pairings.py +93 -0
  36. touchque/resources/recovery.py +118 -0
  37. touchque/resources/risk.py +141 -0
  38. touchque/resources/safe_guard.py +99 -0
  39. touchque/resources/webauthn.py +115 -0
  40. touchque/resources/webhook.py +258 -0
  41. touchque/safe_guard.py +308 -0
  42. touchque/security_events.py +205 -0
  43. touchque/steps.py +239 -0
  44. touchque_authenticator-4.2.0.dist-info/METADATA +630 -0
  45. touchque_authenticator-4.2.0.dist-info/RECORD +48 -0
  46. touchque_authenticator-4.2.0.dist-info/WHEEL +5 -0
  47. touchque_authenticator-4.2.0.dist-info/licenses/LICENSE +21 -0
  48. touchque_authenticator-4.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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