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/org_agent.py ADDED
@@ -0,0 +1,262 @@
1
+ """An organization's AI agent on TouchQue SSO (Organization → AI agents in the
2
+ dashboard): it gets 5-minute tokens for one of the organization's apps,
3
+ proving who it is with its own key (private_key_jwt) or with the token its
4
+ platform already gives it (GitHub Actions, GitLab, Google Cloud, Azure,
5
+ Kubernetes), and binds each token to a DPoP key made when it starts. The app
6
+ checks the token with TouchQue's JWKS and the DPoP proof on every call.
7
+
8
+ agent = TouchQueOrgAgent(agent_id='oagt_...', private_key=os.environ['TQ_ORG_AGENT_KEY'])
9
+ response = agent.request('GET', 'https://payments.acme.com/invoices', resource='tqapp_...')
10
+
11
+ # In a GitHub Actions job (permissions: id-token: write), with no key to store:
12
+ agent = TouchQueOrgAgent(agent_id='oagt_...', assertion=github_actions_token('touchque'))
13
+
14
+ Acting for a member, when its admin allowed it (Organization → AI agents → Access):
15
+
16
+ token = agent.exchange_token(subject_token=members_access_token, resource='tqapp_...') # RFC 8693
17
+ ask = agent.ask_member('ada@acme.com', binding_message='Pay invoice 42', authorization_details=[payment])
18
+ reply(f"Type {ask['binding_code']} in the TouchQue app.")
19
+ approved = agent.wait_for_member(ask) # CIBA, poll mode
20
+
21
+ The token vault: a member's own account at another service they connected on TouchQue, a fresh
22
+ provider token (never the refresh token)::
23
+
24
+ google = agent.get_connection_token(members_access_token, 'google')
25
+
26
+ A shared account (a site with only a password): the member's own TouchQue extension signs in, in their
27
+ browser, after their phone approves with the agent named; the agent never sees the password::
28
+
29
+ ask = agent.ask_to_open_shared_account('k3J...', 'ada@acme.com', message='Post the launch photo')
30
+ reply(f"Open {ask['open_url']} in your browser to let me in.")
31
+ result = agent.wait_for_shared_account(ask) # status: opened | refused | declined | expired
32
+
33
+ Needs the optional ``cryptography`` package: ``pip install "touchque-authenticator[agent]"``.
34
+ """
35
+ import hashlib
36
+ import os
37
+ import re
38
+ import threading
39
+ import time
40
+ import uuid
41
+ from typing import Any, Callable, Dict, Optional, Tuple, Union
42
+ from urllib.parse import urlparse
43
+
44
+ import requests
45
+
46
+ from .agent import AGENT_ASSERTION_TYPE, TouchQueAgent, _b64url, _crypto, _json, _load_private_key, _public_jwk, jwk_thumbprint
47
+ from .exceptions import TouchQueAPIException, TouchQueConfigException, TouchQueNetworkException, TouchQueTimeoutException
48
+
49
+ __all__ = ['TouchQueOrgAgent', 'github_actions_token', 'file_token']
50
+
51
+ _ORG_AGENT_ID = re.compile(r'^oagt_[a-z2-9]{20}$')
52
+ _LOCAL_HOSTS = {'localhost', '127.0.0.1', '::1'}
53
+ _CIBA_GRANT = 'urn:openid:params:grant-type:ciba'
54
+ _TOKEN_EXCHANGE_GRANT = 'urn:ietf:params:oauth:grant-type:token-exchange'
55
+ _ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'
56
+ _CONNECTION_TOKEN_TYPE = 'urn:touchque:params:oauth:token-type:connection-access-token'
57
+
58
+
59
+ def github_actions_token(audience: str = 'touchque') -> Callable[[], str]:
60
+ """The token a GitHub Actions job may ask for (the job needs ``permissions: id-token: write``)."""
61
+ def fetch() -> str:
62
+ url = os.environ.get('ACTIONS_ID_TOKEN_REQUEST_URL')
63
+ bearer = os.environ.get('ACTIONS_ID_TOKEN_REQUEST_TOKEN')
64
+ if not url or not bearer:
65
+ raise TouchQueConfigException('Not in a GitHub Actions job with `permissions: id-token: write`')
66
+ response = requests.get(url, params={'audience': audience}, headers={'Authorization': f'Bearer {bearer}'}, timeout=10)
67
+ value = response.json().get('value') if response.ok else None
68
+ if not value:
69
+ raise TouchQueConfigException('GitHub gave no token')
70
+ return value
71
+ return fetch
72
+
73
+
74
+ def file_token(path: str) -> Callable[[], str]:
75
+ """A token the platform writes to a file and renews (a Kubernetes projected service account token...)."""
76
+ def read() -> str:
77
+ with open(path, encoding='utf-8') as f:
78
+ return f.read().strip()
79
+ return read
80
+
81
+
82
+ class TouchQueOrgAgent:
83
+ """An organization's AI agent (see the module docstring)."""
84
+
85
+ def __init__(self, agent_id: Optional[str] = None, private_key: Union[Dict[str, Any], str, Any, None] = None,
86
+ kid: Optional[str] = None, assertion: Optional[Callable[[], str]] = None, issuer: Optional[str] = None,
87
+ resource: Optional[str] = None, scope: Optional[str] = None, timeout: int = 10000):
88
+ agent_id = agent_id if agent_id is not None else os.environ.get('TQ_ORG_AGENT_ID', '')
89
+ if not _ORG_AGENT_ID.match(agent_id or ''):
90
+ raise TouchQueConfigException('Set TQ_ORG_AGENT_ID (or pass agent_id): the oagt_... id from Organization → AI agents')
91
+ self.agent_id = agent_id
92
+ self.issuer = (issuer or os.environ.get('TQ_SSO_URL') or 'https://sso.touchque.com').rstrip('/')
93
+ parsed = urlparse(self.issuer)
94
+ if parsed.scheme != 'https' and not (parsed.scheme == 'http' and (parsed.hostname or '') in _LOCAL_HOSTS):
95
+ raise TouchQueConfigException(f'Refusing a plaintext issuer for "{parsed.hostname}": use https://')
96
+ self._assertion_source = assertion
97
+ if assertion is None:
98
+ key_input = private_key if private_key is not None else os.environ.get('TQ_ORG_AGENT_KEY')
99
+ if not key_input:
100
+ raise TouchQueConfigException("Pass private_key (or set TQ_ORG_AGENT_KEY), or assertion with the platform's token")
101
+ self._key = _load_private_key(key_input)
102
+ self._key_alg = 'EdDSA' if type(self._key).__name__.startswith('Ed25519') else 'ES256'
103
+ given = key_input.get('kid') if isinstance(key_input, dict) else None
104
+ self._kid = kid or given or jwk_thumbprint(_public_jwk(self._key))
105
+ hashes, serialization, ec, ed25519, _ = _crypto()
106
+ # The DPoP key: made here, never leaves this process.
107
+ self._dpop_key = ec.generate_private_key(ec.SECP256R1())
108
+ self._dpop_jwk = _public_jwk(self._dpop_key)
109
+ self._resource = resource
110
+ self._scope = scope
111
+ self._timeout = timeout / 1000.0
112
+ self._tokens: Dict[Tuple[str, str], Dict[str, Any]] = {}
113
+ self._lock = threading.Lock()
114
+
115
+ @staticmethod
116
+ def _jws(alg: str, key: Any, header: Dict[str, Any], payload: Dict[str, Any]) -> str:
117
+ hashes, serialization, ec, ed25519, decode_dss_signature = _crypto()
118
+ signing_input = f"{_b64url(_json(dict(header, alg=alg)).encode())}.{_b64url(_json(payload).encode())}"
119
+ if alg == 'EdDSA':
120
+ sig = key.sign(signing_input.encode('ascii'))
121
+ else:
122
+ r, s = decode_dss_signature(key.sign(signing_input.encode('ascii'), ec.ECDSA(hashes.SHA256())))
123
+ sig = r.to_bytes(32, 'big') + s.to_bytes(32, 'big')
124
+ return f'{signing_input}.{_b64url(sig)}'
125
+
126
+ def dpop_proof(self, method: str, url: str, access_token: Optional[str] = None) -> str:
127
+ """A DPoP proof (RFC 9449) for one request: with the token, for a call to the app."""
128
+ u = urlparse(url)
129
+ claims: Dict[str, Any] = {'htm': method.upper(), 'htu': f'{u.scheme}://{u.netloc}{u.path}', 'iat': int(time.time()), 'jti': str(uuid.uuid4())}
130
+ if access_token:
131
+ claims['ath'] = _b64url(hashlib.sha256(access_token.encode('ascii')).digest())
132
+ return self._jws('ES256', self._dpop_key, {'typ': 'dpop+jwt', 'jwk': self._dpop_jwk}, claims)
133
+
134
+ def _assertion(self, token_url: str) -> str:
135
+ if self._assertion_source is not None:
136
+ return self._assertion_source()
137
+ now = int(time.time())
138
+ return self._jws(self._key_alg, self._key, {'typ': 'JWT', 'kid': self._kid},
139
+ {'iss': self.agent_id, 'sub': self.agent_id, 'aud': token_url, 'iat': now, 'exp': now + 60, 'jti': str(uuid.uuid4())})
140
+
141
+ def _post(self, path: str, fields: Dict[str, Any], dpop: bool) -> Dict[str, Any]:
142
+ url = f'{self.issuer}{path}'
143
+ form = {'client_id': self.agent_id, 'client_assertion_type': AGENT_ASSERTION_TYPE, 'client_assertion': self._assertion(f'{self.issuer}/sso/token')}
144
+ form.update({k: v for k, v in fields.items() if v not in (None, '')})
145
+ try:
146
+ response = requests.post(url, data=form, headers={'DPoP': self.dpop_proof('POST', url)} if dpop else {}, timeout=self._timeout, allow_redirects=False)
147
+ except requests.exceptions.RequestException as e:
148
+ raise TouchQueNetworkException(str(e)) from e
149
+ if response.status_code != 200:
150
+ raise TouchQueAgent._error(response)
151
+ return response.json()
152
+
153
+ @staticmethod
154
+ def _token_of(data: Dict[str, Any]) -> Dict[str, Any]:
155
+ token = {'access_token': data['access_token'], 'token_type': 'DPoP', 'expires_in': data.get('expires_in', 300), 'scope': data.get('scope', '')}
156
+ if isinstance(data.get('authorization_details'), list):
157
+ token['authorization_details'] = data['authorization_details']
158
+ return token
159
+
160
+ def get_token(self, resource: Optional[str] = None, scope: Optional[str] = None) -> Dict[str, Any]:
161
+ """A token for one app (renewed 30 seconds before it expires):
162
+ ``{'access_token', 'token_type': 'DPoP', 'expires_in', 'scope'}``."""
163
+ resource = resource or self._resource
164
+ scope = scope or self._scope
165
+ cache_key = (resource or '', scope or '')
166
+ with self._lock:
167
+ hit = self._tokens.get(cache_key)
168
+ if hit and hit['expires_at'] - time.time() > 30:
169
+ return hit['token']
170
+ token = self._token_of(self._post('/sso/token', {'grant_type': 'client_credentials', 'resource': resource, 'scope': scope}, True))
171
+ self._tokens[cache_key] = {'token': token, 'expires_at': time.time() + token['expires_in']}
172
+ return token
173
+
174
+ def exchange_token(self, subject_token: str, resource: Optional[str] = None, scope: Optional[str] = None) -> Dict[str, Any]:
175
+ """A token for a member, with their own access token for one of the
176
+ agent's apps (RFC 8693 token exchange): sub is the member, act the agent."""
177
+ return self._token_of(self._post('/sso/token', {
178
+ 'grant_type': _TOKEN_EXCHANGE_GRANT, 'subject_token': subject_token, 'subject_token_type': _ACCESS_TOKEN_TYPE,
179
+ 'resource': resource or self._resource, 'scope': scope or self._scope,
180
+ }, True))
181
+
182
+ def get_connection_token(self, subject_token: str, connection: str, scope: Optional[str] = None) -> Dict[str, Any]:
183
+ """The token vault: a member's own access token at another service (Google, Microsoft 365,
184
+ GitHub...) they connected on TouchQue, for a connection that names this agent:
185
+ ``{'access_token', 'token_type': 'Bearer', 'expires_in'?, 'scope', 'connection'}``.
186
+ ``subject_token`` is the member's token, as for ``exchange_token``; ``scope`` may only narrow
187
+ what the member granted. TouchQue refreshes it; the refresh token never leaves.
188
+ TouchQueAPIError invalid_grant: the member has not connected it (or disconnected, or left)."""
189
+ data = self._post('/sso/token', {
190
+ 'grant_type': _TOKEN_EXCHANGE_GRANT, 'subject_token': subject_token, 'subject_token_type': _ACCESS_TOKEN_TYPE,
191
+ 'requested_token_type': _CONNECTION_TOKEN_TYPE, 'connection': connection, 'scope': scope,
192
+ }, True)
193
+ out = {'access_token': data['access_token'], 'token_type': 'Bearer', 'scope': data.get('scope') or '', 'connection': data.get('connection') or connection}
194
+ if isinstance(data.get('expires_in'), int):
195
+ out['expires_in'] = data['expires_in']
196
+ return out
197
+
198
+ def ask_to_open_shared_account(self, account: str, login_hint: str, message: Optional[str] = None) -> Dict[str, Any]:
199
+ """Asks to open a shared account (its key, from Organization -> AI agents -> Access) for a member:
200
+ ``{'ask_id', 'open_url', 'status', 'expires_in'}``. The member opens ``open_url`` in the browser
201
+ where their TouchQue extension is paired; their phone approves with this agent named and the
202
+ extension signs in there. The agent never sees the password. ``message`` (at most 64
203
+ characters) is shown to the member marked unverified."""
204
+ return self._post('/sso/agents/shared-accounts/asks', {'account': account, 'login_hint': login_hint, 'binding_message': message}, False)
205
+
206
+ def shared_account_ask_status(self, ask_id: str) -> Dict[str, Any]:
207
+ """Where an ask stands: ``{'status', 'reason'?, 'case_code'?}``; only whether it opened, never
208
+ anything of the account."""
209
+ return self._post(f'/sso/agents/shared-accounts/asks/{ask_id}', {}, False)
210
+
211
+ def wait_for_shared_account(self, ask: Dict[str, Any], interval: float = 3.0) -> Dict[str, Any]:
212
+ """Polls an ask until it ends: opened, refused, declined or expired."""
213
+ deadline = time.time() + int(ask.get('expires_in') or 600) + 5
214
+ while True:
215
+ s = self.shared_account_ask_status(ask['ask_id'])
216
+ if s.get('status') not in ('waiting_member', 'asked_phone') or time.time() > deadline:
217
+ return s
218
+ time.sleep(interval)
219
+
220
+ def ask_member(self, login_hint: str, binding_message: Optional[str] = None, authorization_details: Optional[list] = None,
221
+ resource: Optional[str] = None, scope: Optional[str] = None, requested_expiry: Optional[int] = None) -> Dict[str, Any]:
222
+ """Asks a member to approve on their phone (CIBA): ``{'auth_req_id',
223
+ 'expires_in', 'interval', 'binding_code'}``. Show binding_code in the
224
+ conversation, then ``wait_for_member``."""
225
+ data = self._post('/sso/bc-authorize', {
226
+ 'login_hint': login_hint, 'binding_message': binding_message, 'resource': resource or self._resource, 'scope': scope or self._scope,
227
+ 'authorization_details': _json(authorization_details) if authorization_details else None,
228
+ 'requested_expiry': str(requested_expiry) if requested_expiry else None,
229
+ }, False)
230
+ return {'auth_req_id': data['auth_req_id'], 'expires_in': data.get('expires_in', 300), 'interval': data.get('interval', 5),
231
+ **({'binding_code': data['binding_code']} if data.get('binding_code') else {})}
232
+
233
+ def wait_for_member(self, request: Dict[str, Any]) -> Dict[str, Any]:
234
+ """The member's answer (polling at the interval, slower when asked): the
235
+ token once they approve; TouchQueAPIException access_denied or
236
+ expired_token otherwise, TouchQueTimeoutException after it expired."""
237
+ interval = max(1, int(request.get('interval', 5)))
238
+ deadline = time.time() + request.get('expires_in', 300) + 5
239
+ while time.time() < deadline:
240
+ time.sleep(interval)
241
+ try:
242
+ return self._token_of(self._post('/sso/token', {'grant_type': _CIBA_GRANT, 'auth_req_id': request['auth_req_id']}, True))
243
+ except TouchQueAPIException as e:
244
+ if e.code == 'authorization_pending':
245
+ continue
246
+ if e.code == 'slow_down':
247
+ interval += 5
248
+ continue
249
+ raise
250
+ raise TouchQueTimeoutException('The member did not answer in time')
251
+
252
+ def authorization(self, method: str, url: str, resource: Optional[str] = None, scope: Optional[str] = None) -> Dict[str, str]:
253
+ """The two headers for one call to the app: Authorization: DPoP <token> and its proof."""
254
+ token = self.get_token(resource, scope)['access_token']
255
+ return {'Authorization': f'DPoP {token}', 'DPoP': self.dpop_proof(method, url, token)}
256
+
257
+ def request(self, method: str, url: str, resource: Optional[str] = None, scope: Optional[str] = None, **kwargs) -> requests.Response:
258
+ """``requests.request`` with the agent's token and a fresh proof."""
259
+ headers = dict(kwargs.pop('headers', None) or {})
260
+ headers.update(self.authorization(method, url, resource, scope))
261
+ kwargs.setdefault('timeout', self._timeout)
262
+ return requests.request(method, url, headers=headers, **kwargs)
@@ -0,0 +1,56 @@
1
+ """
2
+ A small in-memory fixed-window rate limiter for the relay handler's pre-auth
3
+ routes. It protects your own server (and your TouchQue quota) from scripted
4
+ abuse of routes anyone on the internet can call. It is per process: behind
5
+ several workers or instances each keeps its own counters, which still caps
6
+ abuse per process. Put your own shared limiter in front of the relay if you
7
+ need one global budget, and pass ``rate_limit=False``.
8
+ """
9
+ import math
10
+ import threading
11
+ import time
12
+ from typing import Dict, List, Optional
13
+
14
+ MAX_KEYS = 50000
15
+
16
+
17
+ class FixedWindowLimiter:
18
+ """Fixed-window counter keyed by an arbitrary string.
19
+
20
+ ``window_ms``: window length in milliseconds (default 60000).
21
+ ``per_ip``: requests per client IP per route per window (default 20).
22
+ ``per_user``: requests per username per route per window (default 5).
23
+ """
24
+
25
+ def __init__(self, window_ms: Optional[int] = None, per_ip: Optional[int] = None,
26
+ per_user: Optional[int] = None):
27
+ self.window_ms = 60000 if window_ms is None else window_ms
28
+ self.per_ip = 20 if per_ip is None else per_ip
29
+ self.per_user = 5 if per_user is None else per_user
30
+ # key -> [count, reset_at_ms]; dicts keep insertion order, like a JS Map.
31
+ self._entries = {} # type: Dict[str, List[float]]
32
+ self._lock = threading.Lock()
33
+
34
+ def hit(self, key: str, limit: int, now: Optional[float] = None) -> int:
35
+ """Counts one hit. Returns seconds to wait when over ``limit``, otherwise 0."""
36
+ if now is None:
37
+ now = time.time() * 1000
38
+ with self._lock:
39
+ e = self._entries.get(key)
40
+ if e is None or e[1] <= now:
41
+ if e is None and len(self._entries) >= MAX_KEYS:
42
+ self._prune(now)
43
+ e = [0, now + self.window_ms]
44
+ self._entries[key] = e
45
+ e[0] += 1
46
+ if e[0] > limit:
47
+ return max(1, int(math.ceil((e[1] - now) / 1000.0)))
48
+ return 0
49
+
50
+ def _prune(self, now: float) -> None:
51
+ for k in [k for k, v in self._entries.items() if v[1] <= now]:
52
+ del self._entries[k]
53
+ # Still full (a flood of distinct keys): drop the oldest half rather than grow without bound.
54
+ if len(self._entries) >= MAX_KEYS:
55
+ for k in list(self._entries.keys())[:MAX_KEYS // 2 + 1]:
56
+ del self._entries[k]