touchque-authenticator 5.0.0__tar.gz

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 (92) hide show
  1. touchque_authenticator-5.0.0/LICENSE +21 -0
  2. touchque_authenticator-5.0.0/PKG-INFO +646 -0
  3. touchque_authenticator-5.0.0/README.md +600 -0
  4. touchque_authenticator-5.0.0/setup.cfg +8 -0
  5. touchque_authenticator-5.0.0/setup.py +43 -0
  6. touchque_authenticator-5.0.0/tests/test_actions.py +117 -0
  7. touchque_authenticator-5.0.0/tests/test_agent_frameworks.py +132 -0
  8. touchque_authenticator-5.0.0/tests/test_agents.py +286 -0
  9. touchque_authenticator-5.0.0/tests/test_approval_proof.py +40 -0
  10. touchque_authenticator-5.0.0/tests/test_assist.py +81 -0
  11. touchque_authenticator-5.0.0/tests/test_auth.py +69 -0
  12. touchque_authenticator-5.0.0/tests/test_case_codes.py +45 -0
  13. touchque_authenticator-5.0.0/tests/test_code_reply.py +27 -0
  14. touchque_authenticator-5.0.0/tests/test_config.py +36 -0
  15. touchque_authenticator-5.0.0/tests/test_contract.py +252 -0
  16. touchque_authenticator-5.0.0/tests/test_contrib_django.py +65 -0
  17. touchque_authenticator-5.0.0/tests/test_contrib_fastapi.py +94 -0
  18. touchque_authenticator-5.0.0/tests/test_contrib_flask.py +85 -0
  19. touchque_authenticator-5.0.0/tests/test_cross_app_access.py +108 -0
  20. touchque_authenticator-5.0.0/tests/test_device_risk.py +100 -0
  21. touchque_authenticator-5.0.0/tests/test_feature_errors.py +36 -0
  22. touchque_authenticator-5.0.0/tests/test_hosted_login.py +229 -0
  23. touchque_authenticator-5.0.0/tests/test_http_client.py +102 -0
  24. touchque_authenticator-5.0.0/tests/test_identity.py +100 -0
  25. touchque_authenticator-5.0.0/tests/test_login.py +216 -0
  26. touchque_authenticator-5.0.0/tests/test_mcp_auth.py +87 -0
  27. touchque_authenticator-5.0.0/tests/test_mobile.py +69 -0
  28. touchque_authenticator-5.0.0/tests/test_my_account.py +62 -0
  29. touchque_authenticator-5.0.0/tests/test_offline.py +103 -0
  30. touchque_authenticator-5.0.0/tests/test_org_agent.py +96 -0
  31. touchque_authenticator-5.0.0/tests/test_qr_pairings.py +71 -0
  32. touchque_authenticator-5.0.0/tests/test_recovery.py +67 -0
  33. touchque_authenticator-5.0.0/tests/test_relay.py +632 -0
  34. touchque_authenticator-5.0.0/tests/test_relay_django.py +101 -0
  35. touchque_authenticator-5.0.0/tests/test_relay_fastapi.py +86 -0
  36. touchque_authenticator-5.0.0/tests/test_relay_flask.py +113 -0
  37. touchque_authenticator-5.0.0/tests/test_report_fraud.py +34 -0
  38. touchque_authenticator-5.0.0/tests/test_safe_guard.py +99 -0
  39. touchque_authenticator-5.0.0/tests/test_security_events.py +113 -0
  40. touchque_authenticator-5.0.0/tests/test_silent_sign_in.py +276 -0
  41. touchque_authenticator-5.0.0/tests/test_silent_sign_in_vectors.py +34 -0
  42. touchque_authenticator-5.0.0/tests/test_verify_challenge.py +45 -0
  43. touchque_authenticator-5.0.0/tests/test_webauthn.py +91 -0
  44. touchque_authenticator-5.0.0/tests/test_webhook.py +227 -0
  45. touchque_authenticator-5.0.0/touchque/__init__.py +148 -0
  46. touchque_authenticator-5.0.0/touchque/agent.py +314 -0
  47. touchque_authenticator-5.0.0/touchque/agent_card.py +190 -0
  48. touchque_authenticator-5.0.0/touchque/agent_frameworks.py +215 -0
  49. touchque_authenticator-5.0.0/touchque/approval_proof.py +104 -0
  50. touchque_authenticator-5.0.0/touchque/approval_token.py +134 -0
  51. touchque_authenticator-5.0.0/touchque/client.py +105 -0
  52. touchque_authenticator-5.0.0/touchque/code_reply.py +75 -0
  53. touchque_authenticator-5.0.0/touchque/config.py +56 -0
  54. touchque_authenticator-5.0.0/touchque/contrib/__init__.py +4 -0
  55. touchque_authenticator-5.0.0/touchque/contrib/django.py +175 -0
  56. touchque_authenticator-5.0.0/touchque/contrib/fastapi.py +208 -0
  57. touchque_authenticator-5.0.0/touchque/contrib/flask.py +154 -0
  58. touchque_authenticator-5.0.0/touchque/cross_app_access.py +222 -0
  59. touchque_authenticator-5.0.0/touchque/exceptions.py +170 -0
  60. touchque_authenticator-5.0.0/touchque/guard.py +290 -0
  61. touchque_authenticator-5.0.0/touchque/hosted_login.py +474 -0
  62. touchque_authenticator-5.0.0/touchque/http_client.py +156 -0
  63. touchque_authenticator-5.0.0/touchque/mcp_auth.py +256 -0
  64. touchque_authenticator-5.0.0/touchque/my_account.py +173 -0
  65. touchque_authenticator-5.0.0/touchque/org_agent.py +262 -0
  66. touchque_authenticator-5.0.0/touchque/rate_limiter.py +56 -0
  67. touchque_authenticator-5.0.0/touchque/relay.py +643 -0
  68. touchque_authenticator-5.0.0/touchque/resources/__init__.py +1 -0
  69. touchque_authenticator-5.0.0/touchque/resources/action_types.py +52 -0
  70. touchque_authenticator-5.0.0/touchque/resources/actions.py +234 -0
  71. touchque_authenticator-5.0.0/touchque/resources/agents.py +236 -0
  72. touchque_authenticator-5.0.0/touchque/resources/assist.py +115 -0
  73. touchque_authenticator-5.0.0/touchque/resources/auth.py +65 -0
  74. touchque_authenticator-5.0.0/touchque/resources/device_risk.py +174 -0
  75. touchque_authenticator-5.0.0/touchque/resources/identity.py +268 -0
  76. touchque_authenticator-5.0.0/touchque/resources/login.py +296 -0
  77. touchque_authenticator-5.0.0/touchque/resources/mobile.py +130 -0
  78. touchque_authenticator-5.0.0/touchque/resources/offline.py +138 -0
  79. touchque_authenticator-5.0.0/touchque/resources/qr_pairings.py +93 -0
  80. touchque_authenticator-5.0.0/touchque/resources/recovery.py +118 -0
  81. touchque_authenticator-5.0.0/touchque/resources/risk.py +141 -0
  82. touchque_authenticator-5.0.0/touchque/resources/safe_guard.py +99 -0
  83. touchque_authenticator-5.0.0/touchque/resources/webauthn.py +115 -0
  84. touchque_authenticator-5.0.0/touchque/resources/webhook.py +258 -0
  85. touchque_authenticator-5.0.0/touchque/safe_guard.py +309 -0
  86. touchque_authenticator-5.0.0/touchque/security_events.py +205 -0
  87. touchque_authenticator-5.0.0/touchque/steps.py +239 -0
  88. touchque_authenticator-5.0.0/touchque_authenticator.egg-info/PKG-INFO +646 -0
  89. touchque_authenticator-5.0.0/touchque_authenticator.egg-info/SOURCES.txt +91 -0
  90. touchque_authenticator-5.0.0/touchque_authenticator.egg-info/dependency_links.txt +1 -0
  91. touchque_authenticator-5.0.0/touchque_authenticator.egg-info/requires.txt +22 -0
  92. touchque_authenticator-5.0.0/touchque_authenticator.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Touchque
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,646 @@
1
+ Metadata-Version: 2.4
2
+ Name: touchque-authenticator
3
+ Version: 5.0.0
4
+ Summary: Official Python SDK for Touchque Authenticator (2FA/MFA, passkeys, adaptive auth)
5
+ Home-page: https://touchque.com
6
+ Author: Touchque
7
+ Author-email: developer@touchque.com
8
+ Project-URL: Source, https://github.com/Touchque/touchque-python
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Flask
14
+ Classifier: Framework :: FastAPI
15
+ Requires-Python: >=3.7
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: requests>=2.25.1
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
21
+ Requires-Dist: cryptography>=3.1; extra == "dev"
22
+ Requires-Dist: httpx>=0.23; extra == "dev"
23
+ Provides-Extra: proof
24
+ Requires-Dist: cryptography>=3.1; extra == "proof"
25
+ Provides-Extra: agent
26
+ Requires-Dist: cryptography>=3.1; extra == "agent"
27
+ Provides-Extra: flask
28
+ Requires-Dist: flask>=2.0; extra == "flask"
29
+ Provides-Extra: django
30
+ Requires-Dist: django>=3.2; extra == "django"
31
+ Provides-Extra: fastapi
32
+ Requires-Dist: fastapi>=0.100; extra == "fastapi"
33
+ Requires-Dist: anyio>=3.0; extra == "fastapi"
34
+ Dynamic: author
35
+ Dynamic: author-email
36
+ Dynamic: classifier
37
+ Dynamic: description
38
+ Dynamic: description-content-type
39
+ Dynamic: home-page
40
+ Dynamic: license-file
41
+ Dynamic: project-url
42
+ Dynamic: provides-extra
43
+ Dynamic: requires-dist
44
+ Dynamic: requires-python
45
+ Dynamic: summary
46
+
47
+ # touchque-authenticator
48
+
49
+ The official Python server SDK for [Touchque](https://touchque.com) — biometric push
50
+ 2FA, passkeys, and offline approval codes, added to any backend with one
51
+ decorator per route. Adapters for Flask, Django and FastAPI included.
52
+
53
+ [![PyPI version](https://img.shields.io/pypi/v/touchque-authenticator.svg)](https://pypi.org/project/touchque-authenticator/)
54
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
55
+
56
+ 📘 Full docs: **[support.touchque.com](https://support.touchque.com)**
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ pip install touchque-authenticator
62
+ # with a framework adapter:
63
+ pip install "touchque-authenticator[flask]" # or [django] / [fastapi]
64
+ ```
65
+
66
+ The importable package is `touchque` (`import touchque`).
67
+
68
+ ## Setup
69
+
70
+ Get an API key and secret from your [Touchque Dashboard](https://dashboard.touchque.com):
71
+
72
+ ```bash
73
+ export TQ_API_KEY=tq_auth_your_key
74
+ export TQ_API_SECRET=your_api_secret
75
+ ```
76
+
77
+ `Touchque()` with no arguments reads these automatically.
78
+
79
+ ## Quick start (Flask)
80
+
81
+ ```python
82
+ from flask import request, jsonify
83
+ from touchque.contrib.flask import require_touchque
84
+
85
+ @app.post('/transfer')
86
+ @require_touchque('SEND_MONEY', details=lambda: {'Amount': f'{request.json["amount"]} EUR'})
87
+ def transfer():
88
+ return jsonify(ok=True)
89
+ ```
90
+
91
+ Django and FastAPI look the same:
92
+
93
+ ```python
94
+ # Django
95
+ from touchque.contrib.django import require_touchque
96
+
97
+ @require_touchque('SEND_MONEY')
98
+ def transfer(request):
99
+ ...
100
+ ```
101
+
102
+ ```python
103
+ # FastAPI
104
+ from touchque.contrib.fastapi import RequireTouchque, install_touchque_exception_handler
105
+
106
+ install_touchque_exception_handler(app) # optional: flattens the response shape
107
+
108
+ @app.post('/transfer')
109
+ def transfer(approval = Depends(RequireTouchque('SEND_MONEY'))):
110
+ ...
111
+ ```
112
+
113
+ Until the user approves on their phone, the decorator answers
114
+ **`202 {"touchque": step, "token": "..."}`** instead of running your view.
115
+ Your frontend renders `step` in its own UI (a matching number, or a QR code
116
+ the first time the user links the app) and sends the same request again with
117
+ header `X-Touchque-Token: <token>` — see
118
+ [`@touchque/web`](https://www.npmjs.com/package/@touchque/web), which does
119
+ this loop for you in the browser. Once approved, the retried request reaches
120
+ your view exactly once.
121
+
122
+ ## Action types
123
+
124
+ `"SEND_MONEY"` above is an action type: one kind of approval, with the title the phone shows and
125
+ whether it is critical. Define each one once, in the dashboard (**Developers → Action types**)
126
+ or from code:
127
+
128
+ ```python
129
+ tq.action_types.define("SEND_MONEY", name="Send money", critical=True)
130
+ ```
131
+
132
+ The guard refuses what is not ready. A name that is not defined is a setup error
133
+ (`TouchqueConfigException`, "Unknown action …"): the guard answers 500 with `reason: 'misconfigured'`. A type
134
+ turned off in the dashboard answers 403 with `reason: 'action_disabled'`, which stops that kind of
135
+ approval at once, without a deploy.
136
+
137
+ ## Relay routes (sign-in, enrollment, passkeys, offline)
138
+
139
+ Instead of hand-writing the routes a frontend such as `@touchque/web` calls,
140
+ mount the relay: `POST /login` (two-phase, same `202` + token contract as
141
+ above), `/enroll/start`, `/enroll/status`, `/offline/challenge|verify|totp`,
142
+ `/passkey/authenticate/*`, `/passkey/login/*`, `/passkey/register/*`,
143
+ `GET /passkey/credentials` and `DELETE /passkey/credentials/<id>`.
144
+ `POST /passkey/authenticate/options` with no `email` answers options for any
145
+ passkey of your site, for `@touchque/web`'s passkey autofill
146
+ (`webauthn.primary_options()` with no username does the same).
147
+
148
+ ```python
149
+ # Flask
150
+ from touchque.contrib.flask import touchque_relay_blueprint
151
+
152
+ def opened(request, response, info): # open YOUR session here
153
+ session['user'] = info['externalUsername'] # info: externalUsername, requestId, via
154
+
155
+ app.register_blueprint(touchque_relay_blueprint(
156
+ tq,
157
+ get_user_id=lambda req: session.get('user'),
158
+ # The user who already passed your password step. Binds POST /login and the
159
+ # offline routes to them; the offline routes stay off (403) without it.
160
+ get_login_user=lambda req: session.get('password_ok'),
161
+ on_authenticated=opened,
162
+ ), url_prefix='/touchque')
163
+ ```
164
+
165
+ ```python
166
+ # FastAPI (callbacks may be async)
167
+ from touchque.contrib.fastapi import touchque_relay_router
168
+ app.include_router(touchque_relay_router(tq, get_user_id=..., get_login_user=...), prefix='/touchque')
169
+
170
+ # Django (urls.py) - CSRF stays on: send X-CSRFToken from the page, or pass csrf_exempt=True
171
+ from touchque.contrib.django import touchque_relay_urls
172
+ urlpatterns = [path('touchque/', include(touchque_relay_urls(get_user_id=..., get_login_user=...)))]
173
+ ```
174
+
175
+ Every callback gets the framework's request; `on_authenticated(request,
176
+ response, info)` also gets the response that will be sent, so it can call
177
+ `response.set_cookie(...)`. Other options: `rate_limit` (on by default: 20 per
178
+ IP and 5 per username per route per minute; a dict with `window_ms` /
179
+ `per_ip` / `per_user`, or `False`), `action_type` (default `'LOGIN'`),
180
+ `get_details`, `allow_offline_without_first_factor` and `silent_sign_in` (see
181
+ [Silent sign-ins](#silent-sign-ins-stop-asking-when-nothing-is-new)). Without a framework,
182
+ call `touchque.RelayHandler(...).handle(method, path, body=..., ...)` and
183
+ send the `RelayResponse` it returns.
184
+
185
+ ## The three primitives, if you're not using a framework adapter
186
+
187
+ ```python
188
+ from touchque import Touchque
189
+
190
+ tq = Touchque() # from TQ_API_KEY / TQ_API_SECRET
191
+
192
+ step = tq.start('SEND_MONEY', user='jane@acme.com', details={'Amount': '250 EUR', 'To': 'DE89...'})
193
+ # step['state']: 'waiting' (show step['number']) | 'enroll' (show step['enroll']['qrCodeDataUrl'])
194
+ # | 'approved' | 'rejected' | 'expired' | 'passkey_required' | 'frozen' | 'blocked'
195
+
196
+ latest = tq.check(step['requestId'])
197
+
198
+ # Once approved, consume it exactly once, right before doing the protected thing:
199
+ approval = tq.complete(step['requestId'], user='jane@acme.com', action='SEND_MONEY',
200
+ details={'Amount': '250 EUR', 'To': 'DE89...'})
201
+ ```
202
+
203
+ `complete()` verifies the approval was actually issued for this user, action
204
+ and transaction, and can only be consumed once.
205
+
206
+ ## Passkeys (phishing-resistant)
207
+
208
+ Push approval and offline codes stop password reuse and push fatigue, but a
209
+ real-time phishing proxy can still relay them. A passkey can't be phished —
210
+ the browser signs your site's real origin, and Touchque refuses any other
211
+ (NIST SP 800-63B-4 §3.2.5). Register one via `tq.webauthn` on the server and
212
+ `@touchque/web`'s `passkeys.register()` in the browser; optionally require it
213
+ for critical actions in the Dashboard's Security Policy.
214
+
215
+ ## Offline sign
216
+
217
+ ```python
218
+ ch = tq.offline.challenge(external_username='jane@acme.com', type='WITHDRAW',
219
+ details={'Amount': '1,250.00 USD', 'Recipient': 'Jane Doe'})
220
+ # show ch['qrDataUrl'] — the phone scans it offline and shows a 7-character code
221
+ result = tq.offline.verify(challenge_id=ch['challengeId'], code=code)
222
+ ```
223
+
224
+ **A QR that follows a push.** Pass the push's request id when the offline QR is the fallback for a push
225
+ the user already started (the guard does this for you):
226
+
227
+ ```python
228
+ ch = tq.offline.challenge(user, type='LOGIN', request_id=step['requestId'])
229
+ # ch.get('challengeCode') is the number to print under the QR when number matching applies.
230
+ ```
231
+
232
+ If the user **rejects the push on the phone, the offline QR dies with it**: no new QR is issued for that
233
+ sign-in (409 `request_rejected`), a code for a QR already on screen is refused (`reason: 'request_rejected'`)
234
+ and so is the time-based code (`verify_totp(..., request_id=...)`). With number matching, print `challengeCode`
235
+ under the QR: the phone shows it among two decoys and the user taps the match; a wrong tap yields a code that
236
+ fails verification.
237
+
238
+ ## Webhooks
239
+
240
+ ```python
241
+ from touchque import WebhookEvents, WebhookReasons
242
+ from touchque.exceptions import TouchqueWebhookSignatureException
243
+
244
+ try:
245
+ # raw_body is text: in Flask request.get_data(as_text=True), in Django request.body.decode('utf-8').
246
+ event = tq.webhook.verify(raw_body=request.get_data(as_text=True), signature=request.headers['X-Signature'])
247
+ except TouchqueWebhookSignatureException:
248
+ return '', 403 # not from Touchque
249
+ if event.get('event') == WebhookEvents.APPROVAL_REVOKED:
250
+ end_sessions_built_on(event['requestId'])
251
+ ```
252
+
253
+ Login results carry `status` (`SUCCESS` | `REJECTED`) and, when rejected, a `reason` (`WebhookReasons`):
254
+ `user_rejected`, `user_reported_fraud`, `user_reported_coached` (the user said someone on the phone told
255
+ them to approve), `risk_reversed`, `risk_step_up`, `approval_signature_invalid`, `device_integrity_failed`,
256
+ `vishing_suspected` and `phishing_origin` (see Vishing Shield). Treat a reason you do not know as a rejection.
257
+ Event webhooks carry `event` (`WebhookEvents`): `DEVICE_LINKED`, `DEVICE_UNLINKED`, `PASSKEY_REGISTERED`,
258
+ `PASSKEY_DELETED`, `APPROVAL_RECONFIRMED`, `APPROVAL_REVOKED`. Ignore events you do not know.
259
+
260
+ ## Vishing Shield (someone on the phone guiding the user)
261
+
262
+ The phone tells Touchque, signed with its hardware key, whether a call was active (or just ended), the screen
263
+ was shared or the phone remote-controlled when the user tapped Approve. Depending on the server's mode:
264
+
265
+ - **Passkey instead.** A user with a passkey finishes in the browser: the request stays open and turns into
266
+ `passkey_required` mid-flight. The step-up flow and the relay handle it; the blocking `login.verify` raises
267
+ `TouchquePasskeyRequiredError` (`reason == 'step_up'`). No offline or recovery code can finish such a request.
268
+ - **Refused.** A critical action without a passkey is rejected (`reason: vishing_suspected`).
269
+ - **Provisional.** Anything else is approved with `provisional` set on the approval dict from `complete`. Let the user
270
+ in, but treat the session as limited: Touchque refuses the user's critical actions (`blocked` / `provisional`)
271
+ until the phone re-confirms after the call (`APPROVAL_RECONFIRMED`). `APPROVAL_REVOKED` (`reason` `COACHED` or
272
+ `UNDO`) means end the session; an approval revoked before you consumed it can no longer be consumed
273
+ (409 `revoked`).
274
+
275
+ **Mirror Alarm.** The first waiting step carries `canary.url`. `@touchque/web` loads it on the page showing the
276
+ sign-in and sends a beacon to it, so a real-time phishing proxy in front of your site shows up as itself: the
277
+ approval is refused (`reason: phishing_origin`) and the phone warns the user. Allow the canary host in your
278
+ Content-Security-Policy (`img-src` and `connect-src`).
279
+
280
+ ## Touchque Actions (your code in the approval pipeline)
281
+
282
+ Alerts, step-ups and refusals by country, local time, role or amount, passkeys
283
+ for admins — from a template or a few lines of JavaScript, replayed against your
284
+ real recent sign-ins before they go live. Full guide: [support.touchque.com/actions](https://support.touchque.com/actions).
285
+
286
+ ```python
287
+ tq.actions.deploy('night-alert', trigger='approval.decided',
288
+ template={'id': 'alert', 'params': {'channel': 'slack', 'when': {'countries': ['TR'], 'hours': {'from': 0, 'to': 6}}}})
289
+
290
+ draft = 'exports.onExecute = async (event, api) => { if (event.context?.amount > 10000) api.stepUp("Over 10,000"); };'
291
+ report = tq.actions.dry_run(trigger='login.request', code=draft) # report['transitions'] → {'push→number_match': 3}
292
+ tq.actions.deploy('big-transfers', trigger='login.request', code=draft)
293
+ ```
294
+
295
+ Give Actions what only your server knows with `context=` (never shown on the
296
+ phone): `@require_touchque('SEND_MONEY', context=lambda: {'amount': request.json['amount']})`.
297
+ Loosening calls need a capability an admin approves on their phone. Action types
298
+ moved to `tq.action_types` in 4.0.
299
+
300
+ ## AI agents (an identity per agent, never your API key)
301
+
302
+ Register each agent with its **public** key; the private key stays where the agent runs. The agent signs in
303
+ with its own key and gets a 10-minute token bound to that key (DPoP). `pip install "touchque-authenticator[agent]"`.
304
+
305
+ ```python
306
+ from touchque import Touchque, TouchqueAgent, generate_agent_key
307
+
308
+ keys = generate_agent_key() # or where the agent runs
309
+ agent = client.agents.create('Invoice bot', ['PAY_INVOICE'], public_key=keys['public_jwk'],
310
+ owner_email='ada@acme.com', platform='langchain')
311
+ # store agent['id'] and keys['private_jwk'] in the agent's secrets (TQ_AGENT_ID, TQ_AGENT_KEY)
312
+
313
+ me = TouchqueAgent().me() # in the agent
314
+ ```
315
+
316
+ `client.agents.suspend(agent_id)` stops one agent at once, `client.agents.set_kill_switch(True)` every agent.
317
+
318
+ ```python
319
+ ask = agent.request_approval('jane@example.com', 'PAY_INVOICE', [{
320
+ 'type': 'payment_initiation', 'instructedAmount': {'currency': 'TRY', 'amount': '500.00'},
321
+ 'creditorName': 'Jane Doe', 'creditorAccount': {'iban': 'TR330006100519786457841326'}}], reason='October rent')
322
+ reply(f"Type {ask['challengeCode']} in the Touchque app to approve.")
323
+ token = agent.wait_for_approval(ask['approvalId'])['approvalToken']
324
+
325
+ # in your executor, right before paying: used once, only for what you are about to do
326
+ approval = client.agents.redeem_approval(token, user='jane@example.com', type='PAY_INVOICE',
327
+ authorization_details={'type': 'payment_initiation', 'instructedAmount': {'currency': 'TRY', 'amount': '500.00'}})
328
+ ```
329
+
330
+ In an agent framework, a policy says who approves which tool calls and what their
331
+ phone shows; the tool runs only after the person approved, `current_approval()`
332
+ holds the approval token inside it, and the function keeps its signature (LangChain
333
+ `@tool`, LlamaIndex `FunctionTool.from_defaults`, OpenAI Agents SDK `@function_tool`):
334
+
335
+ ```python
336
+ from touchque import ApprovalPolicy, with_touchque_approval, current_approval, approve_interruptions_with_touchque, touchque_can_use_tool, guard_mcp_session
337
+
338
+ policy = ApprovalPolicy(agent, user='jane@example.com', type='PAY_INVOICE',
339
+ details=lambda a, tool: [{'type': 'payment_initiation', 'instructedAmount': {'currency': 'TRY', 'amount': a['amount']}, 'creditorAccount': {'iban': a['iban']}}],
340
+ on_challenge=lambda s, tool: reply(f"Type {s['challengeCode']} in the Touchque app to approve."))
341
+
342
+ @tool
343
+ @with_touchque_approval(policy)
344
+ def pay_invoice(iban: str, amount: str) -> str:
345
+ return bank.pay(iban, amount, current_approval()['approvalToken'])
346
+
347
+ state = approve_interruptions_with_touchque(result, {'pay_invoice': policy}) # OpenAI Agents SDK
348
+ options = ClaudeAgentOptions(can_use_tool=touchque_can_use_tool({'pay_invoice': policy})) # Claude Agent SDK
349
+ session = guard_mcp_session(session, {'pay_invoice': policy}) # an MCP ClientSession
350
+ ```
351
+
352
+ Agents also stop when their review date passes and when their owner leaves the organization.
353
+
354
+ ### Cross App Access (ID-JAG) and A2A agent cards
355
+
356
+ An organization's identity provider (Touchque SSO) can let one of its apps reach another service for a member with no consent screen (Cross App Access, ID-JAG, an open IETF draft): the app trades the member's ID token for an ID-JAG, then the ID-JAG for an access token at the other service. If you run an authorization server, the verifier checks an ID-JAG (issuer, single audience, your client, expiry, `typ`) and uses it once. Before your agent trusts another agent's A2A card, check its signatures (A2A 1.0 §8.4: JWS over the RFC 8785 canonical card); keys named by `jku` are only fetched from the card's own host or hosts you trust.
357
+
358
+ ```python
359
+ from touchque import request_id_jag, redeem_id_jag, IdJagVerifier, verify_agent_card
360
+
361
+ jag = request_id_jag(issuer='https://sso.touchque.com', client_id=client_id, client_secret=secret,
362
+ id_token=id_token, audience='https://auth.files.example')
363
+ tokens = redeem_id_jag(token_endpoint='https://auth.files.example/token', client_id='acme',
364
+ client_secret=files_secret, assertion=jag['assertion'])
365
+
366
+ # Your own authorization server (jwt-bearer grant):
367
+ claims = IdJagVerifier(issuer='https://sso.touchque.com', audience='https://auth.files.example') \
368
+ .verify(form['assertion'], client_id=authenticated_client_id)
369
+
370
+ verify_agent_card(card, card_url='https://agent.example/.well-known/agent-card.json')
371
+ ```
372
+
373
+ ## Shared Signals (security events from Touchque SSO)
374
+
375
+ Touchque SSO tells your app when something happens to a member's account after sign-in, as Security Event Tokens
376
+ (OpenID Shared Signals Framework 1.0): CAEP `session-revoked`, `credential-change`, `device-compliance-change`; RISC
377
+ `account-disabled`, `credential-compromise`. The organization's admin adds your app as a receiver (Organization >
378
+ Continuous access), by push to your endpoint or by poll. The verifier finds the keys through the issuer's
379
+ `/.well-known/ssf-configuration` and checks the signature, `typ`, issuer, audience and that there is one event; `txn`
380
+ is the case code of Touchque's decision. A SET does not expire: keep the `jti`s you handled and skip a repeat.
381
+ `pip install "touchque-authenticator[agent]"`.
382
+
383
+ ```python
384
+ from touchque import SecurityEventVerifier, SecurityEventError, poll_security_events
385
+
386
+ verifier = SecurityEventVerifier(issuer='https://sso.touchque.com', audience='https://app.example.com')
387
+
388
+ # Push (RFC 8935): the SET is the request body (application/secevent+jwt).
389
+ @app.post('/ssf')
390
+ def ssf():
391
+ try:
392
+ event = verifier.verify(request.get_data(as_text=True))
393
+ except SecurityEventError as e: # e.code: invalid_request, invalid_key, invalid_issuer, invalid_audience
394
+ return {'err': e.code, 'description': str(e)}, 503 if e.code == 'temporarily_unavailable' else 400
395
+ if event.name == 'session-revoked':
396
+ end_sessions_of(event.subject.get('email'))
397
+ return '', 202
398
+
399
+ # Poll (RFC 8936): acknowledge what you handled on the next call.
400
+ out = poll_security_events(url=poll_url, token=stream_token, ack=handled_jtis, max_events=50)
401
+ for jti, token in out['sets'].items():
402
+ event = verifier.verify(token) # then act on it as above
403
+ ```
404
+
405
+ ## Silent sign-ins (stop asking when nothing is new)
406
+
407
+ A user who approved on their phone in this browser a little while ago, from the same phone, network and country,
408
+ with no risk signal since, does not need another push. Touchque's risk engine decides; your server only skips the
409
+ push when it says so.
410
+
411
+ 1. In the browser, `@touchque/web` 2.0.0 with `createTouchqueWeb({ browserKey: true })` keeps a non-extractable
412
+ key for your site and sends a fresh, signed proof of it in the `X-Touchque-Browser` header.
413
+ 2. On the server, turn on `silent_sign_in`:
414
+
415
+ ```python
416
+ # Flask (Django's require_touchque and FastAPI's RequireTouchque take the same option)
417
+ @app.post('/login/2fa')
418
+ @require_touchque('LOGIN', user=lambda: session.get('password_ok'), silent_sign_in=True)
419
+ def login_2fa():
420
+ # g.touchque['assurance']['method'] is 'silent' when no push was needed.
421
+ return jsonify(ok=True)
422
+
423
+ # Relay POST /login
424
+ app.register_blueprint(touchque_relay_blueprint(
425
+ tq,
426
+ get_user_id=lambda req: session.get('user'),
427
+ get_login_user=lambda req: session.get('password_ok'),
428
+ silent_sign_in=True, # or {'max_age_seconds': 8 * 3600} for your own limit
429
+ ), url_prefix='/touchque')
430
+ ```
431
+
432
+ With it on, a new attempt that carries the header first calls `tq.risk.check`. Only an answer of exactly
433
+ `prompt: false` approves it at once, with nothing sent to the phone: the approval has
434
+ `assurance == {'phishingResistant': False, 'method': 'silent'}`, `confirmedVia == 'SILENT'`,
435
+ `requestId == 'silent'` and `reasons` (`known_browser`, `recent_approval`, `same_phone`, `familiar_network`,
436
+ `no_risk_signal`); the relay calls `on_authenticated` with `via: 'silent'`. Anything else, and any error in the
437
+ check, starts the usual push with the proof attached, so its approval makes the browser known. Polling with the
438
+ token never checks again. `run_guard(..., browser_proof=..., silent_sign_in=True)` does the same for your own
439
+ framework (`silent_input_from_headers` reads the header).
440
+
441
+ Touchque only lets a sign-in through silently when all of this holds: the browser proved its key and was approved
442
+ on the phone (a confirmed push, not one approved during a call, or a passkey) within your `max_age_seconds` and at
443
+ most 7 days ago, by the phone that is linked now; the country and the network were seen in the user's earlier
444
+ approved sign-ins; the risk engine allows it with no risk signal; and the action is an everyday one. A critical
445
+ action always asks. In `shadow` mode (`silentMode`) it always asks and the reasons say what would have happened.
446
+
447
+ Without the adapters:
448
+
449
+ ```python
450
+ browser_proof = request.headers.get('X-Touchque-Browser')
451
+ risk = tq.risk.check(user, type='LOGIN', client_ip=request.remote_addr, browser_proof=browser_proof)
452
+ if risk['prompt'] is False:
453
+ return open_session(user) # risk['reasons'] say why
454
+ step = tq.start('LOGIN', user, ip=request.remote_addr, browser_proof=browser_proof) # the approval makes the browser known
455
+
456
+ browsers = tq.risk.browsers(user)['browsers'] # for an "approved browsers" list
457
+ tq.risk.forget_browsers(user) # all; or thumbprint=... for one
458
+ ```
459
+
460
+ Forget a user's browsers after a password reset or a "sign out everywhere". `risk.check` reads the answer
461
+ fail-closed (`parse_risk_check`): `prompt` is `False` only when Touchque answered the boolean `false`; malformed
462
+ `reasons` become `[]` and a missing `assurance` is `'normal'`.
463
+
464
+ ## Never Locked Out (lost, dead or new phone)
465
+
466
+ A user whose phone is gone moves to a new phone without a help desk. Colleagues they named beforehand vouch on their own
467
+ phones, each typing a number the user reads them on a call; then a cooling-off (default 60 minutes) lets the old phone, any
468
+ voucher or an admin stop it. The risk engine can refuse it or ask for more vouchers, and for 24 hours afterwards critical
469
+ actions are refused. It is an option: offer it next to passkeys and the old phone's transfer, never as the only way. Every
470
+ step is a hash-chained receipt (`receipts`); a voucher's step carries the signature of their phone's hardware key.
471
+
472
+ ```python
473
+ # Settings page, after an approval on the user's phone:
474
+ tq.recovery.set_vouchers(user, ['bo@acme.com', 'cy@acme.com'])
475
+
476
+ # "I lost my phone", after the password:
477
+ r = tq.recovery.start(user, client_ip=ip, display_name='Ada', fallback_vouchers=admins)
478
+ show_qr(r['bindUri']) # the Touchque app on the new phone scans it
479
+ now = tq.recovery.status(r['recoveryId']) # each voucher's 'code' to read to them
480
+ # now['status']: AWAITING_DEVICE -> VOUCHING -> COOLING -> COMPLETED (or VETOED / EXPIRED / FAILED)
481
+
482
+ tq.recovery.veto(r['recoveryId'], by='admin@acme.com') # an admin who does not believe it
483
+ ```
484
+
485
+ ## "Why?": case codes instead of guesswork
486
+
487
+ Every approval decision names a short case code (`K7P-2QX`: 6 characters, no I, O, 0 or 1). A user who was asked to
488
+ match a number, needed a passkey or was refused reads it out to your support team, and the team opens the whole
489
+ decision by it in the Touchque dashboard (Logs → Look up a case): what went in, each check's verdict (the access rule
490
+ that matched, the risk signals that were acted on and the ones only measured), and the single change that would have
491
+ let it through. No screenshots, no log digging. The code opens nothing on its own: only your workspace sees its
492
+ decisions.
493
+
494
+ ```python
495
+ step = client.start('WITHDRAW', user, ip=request.remote_addr)
496
+ # step['caseCode'] -> 'K7P-2QX'; step['reasons'] -> [{'code': 'critical_action', 'text': 'The action is marked critical'}]
497
+ try:
498
+ client.login.request(user, type='LOGIN')
499
+ except TouchqueAPIException as err:
500
+ if err.case_code:
501
+ show(f'Tell your support team: {err.case_code}')
502
+ ```
503
+
504
+ `caseCode` is in the `login.request` and `risk.check` answers and every step (the guard keeps it while it waits);
505
+ `TouchqueAPIException.case_code` carries it on refusals.
506
+
507
+ ## Blocking `verify()` and number matching
508
+
509
+ `tq.login.verify()` sends the request and waits for the phone in one call. With number matching (your option, a
510
+ workspace policy, a critical action or the risk engine can turn it on) the phone asks the user to pick a number,
511
+ and the call returns only after they have. Pass `on_challenge`: it runs once, right after the request is created
512
+ and before the wait. **Show `challengeCode` to the user now; the phone asks for it.**
513
+
514
+ ```python
515
+ def show(challenge):
516
+ if challenge.get('challengeCode'):
517
+ notify_user(f"Pick {challenge['challengeCode']} on your phone", case=challenge.get('caseCode'))
518
+
519
+ tq.login.verify(user, 'SEND_MONEY', on_challenge=show)
520
+ ```
521
+
522
+ It gets a dict with `requestId`, `expiresAt` and, when present, `challengeCode`, `reasons` and `caseCode`. It is
523
+ not called when only a passkey can approve (`verify()` raises `TouchquePasskeyRequiredError` first). An exception it
524
+ raises is logged (`touchque` logger, warning) and ignored: `verify()` keeps waiting. In a web request, where the
525
+ page hears nothing until you answer, use the relay or `tq.start()`: their first answer already carries the number.
526
+
527
+ ## Verifying the device proof (no need to trust Touchque)
528
+
529
+ Every approval from a phone with a hardware key carries `approvalProof`: the phone's own
530
+ signature over the request id, action, number-match code and the exact details it showed.
531
+ `pip install "touchque-authenticator[proof]"`, then:
532
+
533
+ ```python
534
+ from touchque import verify_approval_proof
535
+
536
+ ok = verify_approval_proof(
537
+ event['approvalProof'], request_id=request_id, type='WITHDRAW', details=details,
538
+ challenge_code=challenge_code,
539
+ expected_public_key=user.touchque_device_key, # stored from their first verified approval
540
+ )
541
+ ```
542
+
543
+ Without `expected_public_key` it returns `False` unless `allow_unpinned_key=True` (trust on first
544
+ use — store `proof['publicKey']`, then pin it).
545
+
546
+ ## Reporting fraud
547
+
548
+ Learned later that an approved request was fraud? Report it — it is the strongest signal
549
+ Touchque's risk engine learns from, and it stays in your audit log:
550
+
551
+ ```python
552
+ tq.login.report_fraud(request_id, reason='chargeback', note='Disputed by card holder')
553
+ # reason: 'chargeback' | 'account_takeover' | 'social_engineering' | 'other'
554
+ ```
555
+
556
+ ## Pairing a browser by QR code
557
+
558
+ Your page shows a QR code, the person scans it with the Touchque app, and the phone that scanned is the one asked to approve, with number matching. Your page sends the thumbprint of its browser key (`@touchque/web` `browserKey.thumbprint()`); the nonce in the QR never leaves your server (Touchque keeps its hash).
559
+
560
+ ```python
561
+ p = client.qr_pairings.create(key_thumbprint, client_ip=ip, user_agent=ua)
562
+ # show p['uri'] (touchque://pair-browser?...) as a QR code; poll:
563
+ now = client.qr_pairings.get(p['pairingId']) # waiting -> scanned -> confirmed
564
+ if now['state'] == 'confirmed':
565
+ client.login.request(now['externalUsername'], qr_pairing=p['pairingId'])
566
+ # or: client.qr_pairings.refuse(p['pairingId'], 'device_not_approved', 'Ask IT to approve this computer.')
567
+ ```
568
+
569
+ `get` says `scanned` / `confirmed` once a phone holding one of your users' accounts read it (with `proximity`: same network, same country), `reported` when the person said someone told them to scan it (their 2FA freezes). `refuse` tells the phone why your own checks said no.
570
+
571
+ ## A code typed back on the phone
572
+
573
+ When a sign-in needs a code another service sent the person (a text message, WhatsApp, e-mail), ask them to type it on the phone instead. The phone encrypts it to a one-time key you made for this request: Touchque only ever holds the ciphertext. Such a request lives 5 minutes.
574
+
575
+ ```python
576
+ key = new_code_reply_key() # pip install "touchque-authenticator[proof]"; keep key['private_key']
577
+ sent = tq.login.request(user, reply={'kind': 'code', 'key': key['public_key'], 'channel': 'sms', 'target': '+**********26'})
578
+ status = tq.login.status(sent['requestId']) # once CONFIRMED
579
+ code = open_code_reply(key['private_key'], sent['requestId'], status['reply']['ct']) if 'reply' in status else None
580
+ ```
581
+
582
+ ## Safe Guard (Safe transactions approved on the phone)
583
+
584
+ TouchqueSafeGuard is a transaction guard for a Safe: a transaction runs only with approvals signed by the P-256 keys on its approvers' phones. You send the raw transaction; the phone decodes it itself, shows its own rows and signs what it computed with a key only its biometrics unlock. Delegatecalls, changes to the Safe and proxy upgrades are governance: scheduled, time-locked and vetoable. A phone without that key yet gets `409 crypto_key_missing`.
585
+
586
+ ```python
587
+ from touchque import verify_safe_approval, with_safe_approvals, safe_schedule_call # pip install "touchque-authenticator[proof]"
588
+
589
+ target = {'chainId': 1, 'safe': SAFE, 'guard': GUARD, 'tx': {'to': to, 'value': 0, 'data': data, 'operation': 0, 'nonce': 42}}
590
+ sent = tq.safe_guard.request('treasurer@acme.com', 'safe_payment', { # an action type from your dashboard
591
+ **target,
592
+ 'config': {'threshold': 2, 'govThreshold': 3, 'govDelay': 172800, 'exitDelay': 259200},
593
+ 'batchTargets': [MULTI_SEND_CALL_ONLY],
594
+ 'approverIndex': 0, # this approver's index in the guard
595
+ })
596
+ done = tq.safe_guard.wait_for_approval(sent['requestId']) # 5 minutes at most
597
+ approval = done['safeTx']['approval']
598
+
599
+ # Check it yourself: your own transaction, the approver's key from the guard on chain, and what you will do with it.
600
+ purpose = done['safeTx']['purpose'] # 'approve' (execute it) or 'schedule' (a governance change)
601
+ check = verify_safe_approval(target, approval, approver_key, purpose=purpose, approver_index=0)
602
+ if not check['valid']:
603
+ raise RuntimeError(f"approval refused: {check['reason']}")
604
+ if purpose == 'approve':
605
+ signatures = with_safe_approvals(owner_signatures, [approval]) # execTransaction(..., signatures)
606
+ else:
607
+ data = safe_schedule_call(SAFE, check['governanceHash'], [approval]) # a call to the guard; runs after govDelay
608
+ ```
609
+
610
+ `verify_safe_approval` makes the guard's check off chain, so a co-signer or your own server never has to trust Touchque: it computes the Safe transaction hash (EIP-712) and the governance hash from the raw fields, rebuilds the 149-byte message the phone signed and checks the P-256 signature. Without `purpose` it accepts only an approval to execute this exact transaction (`approve`); an approval that schedules a governance change covers neither the Safe nonce nor the gas fields, so it passes only when you name `purpose='schedule'`. It refuses a high `s`, an approval that expired or is valid for more than a day, another approver index, and, when you pass `rows` or `display_digest` from your own decoding, rows the phone did not show. `reason` is `approval_malformed`, `public_key_invalid`, `approver_index_mismatch`, `approval_expired`, `rows_differ`, `high_s` or `signature_invalid`. `safe_tx_hash`, `safe_governance_hash`, `safe_approval_message` and `safe_display_digest` are exported too.
611
+
612
+ ## Errors
613
+
614
+ All SDK errors extend `TouchqueException`: `TouchqueAPIException`,
615
+ `TouchqueNetworkException`, `TouchqueRejectedException`,
616
+ `TouchqueTimeoutException`, `TouchqueWebhookSignatureException`,
617
+ `TouchqueConfigException`.
618
+
619
+ A feature your organization cannot use here raises a `TouchqueFeatureException` (still a `TouchqueAPIException`, status 403) with `feature` (`device_risk`, `ai_agents`…) and `feature_reason`: `TouchqueFeatureNotInPlanException`, `TouchqueFeatureReadOnlyException` or `TouchqueFeatureOffException` — not in its plan (an owner or admin adds it in the Touchque dashboard), being turned off (reads work for 14 days, changes are refused) or switched off on this Touchque deployment.
620
+
621
+ ## Security
622
+
623
+ - Every API request is signed HMAC-SHA256 (method, path+query, timestamp, nonce, body hash).
624
+ - The `X-Touchque-Token` a frontend echoes back is itself signed and bound to
625
+ one user + action + transaction digest.
626
+ - An approval is consumed exactly once, server-side.
627
+ - Your API secret never leaves your server.
628
+
629
+ See [SECURITY.md](https://github.com/Touchque/touchque-python/blob/main/SECURITY.md) to report a vulnerability.
630
+
631
+ ## Requirements
632
+
633
+ - Python 3.7+
634
+ - A [Touchque Dashboard](https://dashboard.touchque.com) account
635
+
636
+ ## Logging
637
+
638
+ The SDK's own problems (a misconfigured guard, a failed `on_challenge`, a relay error) go to `logging.getLogger('touchque')`, which has a `NullHandler`: nothing is printed until your app configures logging (`logging.basicConfig()`, or a handler on `'touchque'`). Lines carry an error's class and message, never the request.
639
+
640
+ ## License
641
+
642
+ MIT © [Touchque](https://touchque.com)
643
+
644
+ ## Touchque Login (hosted sign-in)
645
+
646
+ Sign-up and sign-in pages for your own customers, on your brand, with OpenID Connect. See [support.touchque.com/touchque-login](https://support.touchque.com/touchque-login) for this SDK's client and a quickstart.