touchque-authenticator 5.0.0__tar.gz → 5.1.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 (97) hide show
  1. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/PKG-INFO +144 -2
  2. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/README.md +143 -1
  3. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/setup.py +1 -1
  4. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_contract.py +117 -0
  5. touchque_authenticator-5.1.0/tests/test_contrib_django.py +140 -0
  6. touchque_authenticator-5.1.0/tests/test_contrib_fastapi.py +207 -0
  7. touchque_authenticator-5.1.0/tests/test_contrib_flask.py +269 -0
  8. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_relay.py +63 -0
  9. touchque_authenticator-5.1.0/tests/test_testing.py +163 -0
  10. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/__init__.py +7 -1
  11. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/client.py +9 -2
  12. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/contrib/django.py +31 -9
  13. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/contrib/fastapi.py +61 -14
  14. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/contrib/flask.py +26 -9
  15. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/guard.py +234 -20
  16. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/http_client.py +7 -5
  17. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/relay.py +25 -14
  18. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/steps.py +54 -10
  19. touchque_authenticator-5.1.0/touchque/testing.py +376 -0
  20. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque_authenticator.egg-info/PKG-INFO +144 -2
  21. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque_authenticator.egg-info/SOURCES.txt +2 -0
  22. touchque_authenticator-5.0.0/tests/test_contrib_django.py +0 -65
  23. touchque_authenticator-5.0.0/tests/test_contrib_fastapi.py +0 -94
  24. touchque_authenticator-5.0.0/tests/test_contrib_flask.py +0 -85
  25. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/LICENSE +0 -0
  26. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/setup.cfg +0 -0
  27. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_actions.py +0 -0
  28. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_agent_frameworks.py +0 -0
  29. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_agents.py +0 -0
  30. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_approval_proof.py +0 -0
  31. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_assist.py +0 -0
  32. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_auth.py +0 -0
  33. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_case_codes.py +0 -0
  34. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_code_reply.py +0 -0
  35. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_config.py +0 -0
  36. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_cross_app_access.py +0 -0
  37. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_device_risk.py +0 -0
  38. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_feature_errors.py +0 -0
  39. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_hosted_login.py +0 -0
  40. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_http_client.py +0 -0
  41. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_identity.py +0 -0
  42. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_login.py +0 -0
  43. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_mcp_auth.py +0 -0
  44. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_mobile.py +0 -0
  45. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_my_account.py +0 -0
  46. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_offline.py +0 -0
  47. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_org_agent.py +0 -0
  48. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_qr_pairings.py +0 -0
  49. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_recovery.py +0 -0
  50. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_relay_django.py +0 -0
  51. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_relay_fastapi.py +0 -0
  52. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_relay_flask.py +0 -0
  53. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_report_fraud.py +0 -0
  54. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_safe_guard.py +0 -0
  55. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_security_events.py +0 -0
  56. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_silent_sign_in.py +0 -0
  57. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_silent_sign_in_vectors.py +0 -0
  58. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_verify_challenge.py +0 -0
  59. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_webauthn.py +0 -0
  60. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_webhook.py +0 -0
  61. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/agent.py +0 -0
  62. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/agent_card.py +0 -0
  63. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/agent_frameworks.py +0 -0
  64. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/approval_proof.py +0 -0
  65. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/approval_token.py +0 -0
  66. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/code_reply.py +0 -0
  67. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/config.py +0 -0
  68. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/contrib/__init__.py +0 -0
  69. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/cross_app_access.py +0 -0
  70. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/exceptions.py +0 -0
  71. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/hosted_login.py +0 -0
  72. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/mcp_auth.py +0 -0
  73. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/my_account.py +0 -0
  74. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/org_agent.py +0 -0
  75. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/rate_limiter.py +0 -0
  76. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/__init__.py +0 -0
  77. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/action_types.py +0 -0
  78. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/actions.py +0 -0
  79. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/agents.py +0 -0
  80. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/assist.py +0 -0
  81. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/auth.py +0 -0
  82. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/device_risk.py +0 -0
  83. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/identity.py +0 -0
  84. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/login.py +0 -0
  85. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/mobile.py +0 -0
  86. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/offline.py +0 -0
  87. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/qr_pairings.py +0 -0
  88. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/recovery.py +0 -0
  89. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/risk.py +0 -0
  90. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/safe_guard.py +0 -0
  91. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/webauthn.py +0 -0
  92. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/webhook.py +0 -0
  93. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/safe_guard.py +0 -0
  94. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/security_events.py +0 -0
  95. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque_authenticator.egg-info/dependency_links.txt +0 -0
  96. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque_authenticator.egg-info/requires.txt +0 -0
  97. {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque_authenticator.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: touchque-authenticator
3
- Version: 5.0.0
3
+ Version: 5.1.0
4
4
  Summary: Official Python SDK for Touchque Authenticator (2FA/MFA, passkeys, adaptive auth)
5
5
  Home-page: https://touchque.com
6
6
  Author: Touchque
@@ -119,6 +119,100 @@ header `X-Touchque-Token: <token>` — see
119
119
  this loop for you in the browser. Once approved, the retried request reaches
120
120
  your view exactly once.
121
121
 
122
+ While the first-time QR is up, polls answer `{"touchque": {"state": "enroll", "expiresAt": ...}}` without
123
+ `enroll`: keep showing the QR you have. A new QR comes with `enroll.qrCodeDataUrl` when the old one
124
+ expired. Pages on `@touchque/web` need 2.1.0.
125
+
126
+ ### Refusals in your own error shape
127
+
128
+ A refusal answers 401 (nobody signed in), 403 (`rejected`, `blocked`), 408 (`expired`), 423 (`frozen`) or
129
+ 429 (`rate_limited`, both with `Retry-After`), and 500 `misconfigured` / 503 `unavailable` when Touchque is
130
+ not set up or cannot be reached. The body is `{"touchque": {"state": ..., "reason": ...}}`, with
131
+ `"error": {"code", "message"}` on 401, 500 and 503. `error_body` sends your app's own shape instead:
132
+
133
+ ```python
134
+ @app.post('/transfer')
135
+ @require_touchque('SEND_MONEY', error_body=lambda r: {'message': r['message'], 'code': r['code']})
136
+ def transfer():
137
+ ...
138
+ # 403 {"message": "The approval was rejected.", "code": "rejected", "touchque": {"state": "rejected", ...}}
139
+ ```
140
+
141
+ It is called for every answer with status 400 or above, never for a 202 step, with a dict:
142
+ `status`; `code` (the guard's `error.code`, else the step's `reason`, else its `state`); `message`
143
+ (`error.message`, else "The approval was rejected.", "The approval expired. Try again.", "Too many
144
+ rejected approvals. Try again later.", "Too many attempts. Try again later." or "The approval was
145
+ refused."); and `touchque`, a copy of the step. What it returns counts by its JSON encoding, the
146
+ framework's own (Flask's JSON provider, `DjangoJSONEncoder`, FastAPI's `jsonable_encoder`): what encodes
147
+ to an object (a dict, a dataclass, a pydantic model) is sent with `touchque` set to the step, so
148
+ `@touchque/web` still sees the state (no `token` is added); anything else (a string, a list, `None`) is
149
+ sent as it is. The status and headers stay. If it raises, or returns what cannot be encoded, the default
150
+ body is sent and `[Touchque] error_body failed: ...` is logged. Django's `require_touchque` and FastAPI's
151
+ `RequireTouchque` take the same option (on FastAPI it may be async); on FastAPI,
152
+ `install_touchque_exception_handler(app)` sends it as the whole body (without it, FastAPI puts it under
153
+ `"detail"`).
154
+
155
+ Every refusal (status 400 and above) also carries `X-Touchque-State: <state>`, and
156
+ `X-Touchque-Reason: <reason>` when the step has one: `@touchque/web` 2.1.0 reads the step from them when
157
+ the body has no `touchque`. A page on another origin needs them in `Access-Control-Expose-Headers`.
158
+
159
+ ### Without API keys
160
+
161
+ The adapters build the default client from `TQ_API_KEY` / `TQ_API_SECRET` on the first request, not at
162
+ import, and never raise for it. While the keys are not set, a guarded route answers **500**
163
+ `{"touchque": {"state": "blocked", "reason": "misconfigured"}, "error": {"code": "misconfigured",
164
+ "message": "Two-factor approval is not configured correctly."}}` with `Cache-Control: no-store` (401
165
+ `unauthenticated` when nobody is signed in), your view never runs, and
166
+ `[Touchque] TQ_API_KEY and TQ_API_SECRET are not set: routes guarded by Touchque answer 500 misconfigured.`
167
+ is logged once (see [Logging](#logging)). It is a 500, not a 503: a deploy mistake should show in your
168
+ monitoring, not invite retries. `@touchque/web` shows "Approvals are not set up correctly on this site."
169
+
170
+ ## Guard inside your handler
171
+
172
+ When your own checks come first (the balance, who owns the account), call `run_guard` in the handler
173
+ instead of the decorator. It is the same guard and never raises for Touchque: send its `status` and
174
+ `body` as JSON with the headers `guard_response_headers(result)` gives (`Cache-Control: no-store`,
175
+ `Retry-After` on 423 and 429, `X-Touchque-State` on a refusal), and do the action once it returns
176
+ `approved`:
177
+
178
+ ```python
179
+ from flask import g, jsonify, request
180
+ from touchque import guard_input_from_headers, guard_response_headers, run_guard
181
+
182
+ @app.post('/transfer')
183
+ def transfer():
184
+ amount = int(request.json['amount'])
185
+ if amount > balance_of(g.user): # your checks first: no push for a transfer you refuse anyway
186
+ return jsonify(error='insufficient_funds'), 400
187
+
188
+ result = run_guard(
189
+ None, g.user.email, 'SEND_MONEY', # None: the default client (TQ_API_KEY / TQ_API_SECRET)
190
+ details={'Amount': f'{amount} EUR'}, # the same on every poll: build it from your own state
191
+ ip=request.remote_addr, user_agent=request.headers.get('User-Agent'),
192
+ **guard_input_from_headers(request.headers.get), # X-Touchque-Token, -Offline, -Code, -Code-Type
193
+ )
194
+ if 'approved' not in result:
195
+ return jsonify(result['body']), result['status'], guard_response_headers(result)
196
+
197
+ send_money(g.user, amount) # once: the approval is used up
198
+ return jsonify(ok=True, approval=result['approved'])
199
+ ```
200
+
201
+ The answers are the decorator's. 202 `{"touchque": step, "token": ...}` until the phone answers: the
202
+ page shows the step and sends the request again with `X-Touchque-Token` (`@touchque/web` does), and
203
+ `approved` comes exactly once, for this user, action and details. While the first-time QR is up the 202
204
+ step is `{"state": "enroll", "expiresAt": ...}` without `enroll`: keep the QR on screen; a new one comes
205
+ in `enroll.qrCodeDataUrl` after the old one expired. A refusal is 401, 403, 408, 423 or 429; 500
206
+ `misconfigured` and 503 `unavailable` when Touchque is not set up or cannot be reached.
207
+
208
+ With a rate limit of your own in front of it, count a request that only polls apart from a new attempt:
209
+ `from touchque import is_status_poll` and call it with the same arguments as `run_guard` (`None` for
210
+ the default client too); it is true when the request only reads the status or keeps the first-time QR.
211
+ Then pass `before_start=` to `run_guard`: it calls it at most once, right before a first-time-QR token
212
+ would start something after all (a push now that the phone is linked, or a new QR). Charge your per-user
213
+ limit there and return your 429 as a result (`{'status': 429, 'body': ...}`) to answer it instead, or
214
+ `None` to go on. The relay does this for `POST /login`.
215
+
122
216
  ## Action types
123
217
 
124
218
  `"SEND_MONEY"` above is an action type: one kind of approval, with the title the phone shows and
@@ -609,6 +703,54 @@ else:
609
703
 
610
704
  `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
705
 
706
+ ## Testing your routes
707
+
708
+ `touchque.testing.FakeTouchque` is a `Touchque` client that answers in memory: no network, no API keys,
709
+ no Node.js. The real guard runs, so your routes answer what they answer in production, and your test
710
+ plays the phone:
711
+
712
+ ```python
713
+ # test_transfer.py
714
+ import pytest
715
+ from touchque.testing import FakeTouchque
716
+
717
+ from myapp import app # @require_touchque('SEND_MONEY', ...) with no client
718
+
719
+
720
+ @pytest.fixture
721
+ def tq():
722
+ with FakeTouchque() as fake: # the default client until the test ends
723
+ yield fake
724
+
725
+
726
+ def test_transfer_needs_an_approval(tq):
727
+ client = app.test_client()
728
+ first = client.post('/transfer', json={'amount': 5})
729
+ assert first.status_code == 202
730
+ token = first.get_json()['token']
731
+
732
+ tq.approve() # the person approves on the phone
733
+ done = client.post('/transfer', json={'amount': 5}, headers={'X-Touchque-Token': token})
734
+ assert done.status_code == 200
735
+ assert tq.requests[-1]['details'] == [{'label': 'Amount', 'value': '5 EUR'}]
736
+ ```
737
+
738
+ A route given `client=` uses that client: pass the fake there instead (`tq.install()` / `tq.uninstall()`
739
+ are the context manager's two halves).
740
+
741
+ - `tq.approve()`, `tq.reject()`, `tq.expire()`: the latest request, or one by its id
742
+ (`tq.approve(request_id)`; `via='WEBAUTHN'` approves with a passkey).
743
+ - `FakeTouchque(auto_approve=True)` (or `tq.auto_approve = True`): every request is approved as it is
744
+ made. The first request still answers 202; the next one runs the route.
745
+ - Every user has a phone linked. `tq.unlink(user)` shows them the first-time QR instead
746
+ (`FakeTouchque(linked=False)`: nobody has one); `tq.link(user)` is the phone scanning it.
747
+ `tq.secret_ttl_ms` is how long a QR works (5 minutes).
748
+ - `FakeTouchque(number_match=True)`: pushes ask for the number `47`.
749
+ - `tq.requests` (`id`, `user`, `type`, `details`, `referenceId`, `status`) and `tq.calls` (`method`,
750
+ `path`, `body`): what your app asked Touchque, in order.
751
+ - Offline approval: the code is `ABCD123`, the time-based code `123456`. Calls outside the step-up
752
+ (recovery, agents …) raise `TouchqueAPIException` 404. `FakeClient` is another name for it.
753
+
612
754
  ## Errors
613
755
 
614
756
  All SDK errors extend `TouchqueException`: `TouchqueAPIException`,
@@ -635,7 +777,7 @@ See [SECURITY.md](https://github.com/Touchque/touchque-python/blob/main/SECURITY
635
777
 
636
778
  ## Logging
637
779
 
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.
780
+ The SDK's own problems (a misconfigured guard, missing `TQ_API_KEY` / `TQ_API_SECRET` (once per process), a failed `on_challenge` or `error_body`, 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
781
 
640
782
  ## License
641
783
 
@@ -73,6 +73,100 @@ header `X-Touchque-Token: <token>` — see
73
73
  this loop for you in the browser. Once approved, the retried request reaches
74
74
  your view exactly once.
75
75
 
76
+ While the first-time QR is up, polls answer `{"touchque": {"state": "enroll", "expiresAt": ...}}` without
77
+ `enroll`: keep showing the QR you have. A new QR comes with `enroll.qrCodeDataUrl` when the old one
78
+ expired. Pages on `@touchque/web` need 2.1.0.
79
+
80
+ ### Refusals in your own error shape
81
+
82
+ A refusal answers 401 (nobody signed in), 403 (`rejected`, `blocked`), 408 (`expired`), 423 (`frozen`) or
83
+ 429 (`rate_limited`, both with `Retry-After`), and 500 `misconfigured` / 503 `unavailable` when Touchque is
84
+ not set up or cannot be reached. The body is `{"touchque": {"state": ..., "reason": ...}}`, with
85
+ `"error": {"code", "message"}` on 401, 500 and 503. `error_body` sends your app's own shape instead:
86
+
87
+ ```python
88
+ @app.post('/transfer')
89
+ @require_touchque('SEND_MONEY', error_body=lambda r: {'message': r['message'], 'code': r['code']})
90
+ def transfer():
91
+ ...
92
+ # 403 {"message": "The approval was rejected.", "code": "rejected", "touchque": {"state": "rejected", ...}}
93
+ ```
94
+
95
+ It is called for every answer with status 400 or above, never for a 202 step, with a dict:
96
+ `status`; `code` (the guard's `error.code`, else the step's `reason`, else its `state`); `message`
97
+ (`error.message`, else "The approval was rejected.", "The approval expired. Try again.", "Too many
98
+ rejected approvals. Try again later.", "Too many attempts. Try again later." or "The approval was
99
+ refused."); and `touchque`, a copy of the step. What it returns counts by its JSON encoding, the
100
+ framework's own (Flask's JSON provider, `DjangoJSONEncoder`, FastAPI's `jsonable_encoder`): what encodes
101
+ to an object (a dict, a dataclass, a pydantic model) is sent with `touchque` set to the step, so
102
+ `@touchque/web` still sees the state (no `token` is added); anything else (a string, a list, `None`) is
103
+ sent as it is. The status and headers stay. If it raises, or returns what cannot be encoded, the default
104
+ body is sent and `[Touchque] error_body failed: ...` is logged. Django's `require_touchque` and FastAPI's
105
+ `RequireTouchque` take the same option (on FastAPI it may be async); on FastAPI,
106
+ `install_touchque_exception_handler(app)` sends it as the whole body (without it, FastAPI puts it under
107
+ `"detail"`).
108
+
109
+ Every refusal (status 400 and above) also carries `X-Touchque-State: <state>`, and
110
+ `X-Touchque-Reason: <reason>` when the step has one: `@touchque/web` 2.1.0 reads the step from them when
111
+ the body has no `touchque`. A page on another origin needs them in `Access-Control-Expose-Headers`.
112
+
113
+ ### Without API keys
114
+
115
+ The adapters build the default client from `TQ_API_KEY` / `TQ_API_SECRET` on the first request, not at
116
+ import, and never raise for it. While the keys are not set, a guarded route answers **500**
117
+ `{"touchque": {"state": "blocked", "reason": "misconfigured"}, "error": {"code": "misconfigured",
118
+ "message": "Two-factor approval is not configured correctly."}}` with `Cache-Control: no-store` (401
119
+ `unauthenticated` when nobody is signed in), your view never runs, and
120
+ `[Touchque] TQ_API_KEY and TQ_API_SECRET are not set: routes guarded by Touchque answer 500 misconfigured.`
121
+ is logged once (see [Logging](#logging)). It is a 500, not a 503: a deploy mistake should show in your
122
+ monitoring, not invite retries. `@touchque/web` shows "Approvals are not set up correctly on this site."
123
+
124
+ ## Guard inside your handler
125
+
126
+ When your own checks come first (the balance, who owns the account), call `run_guard` in the handler
127
+ instead of the decorator. It is the same guard and never raises for Touchque: send its `status` and
128
+ `body` as JSON with the headers `guard_response_headers(result)` gives (`Cache-Control: no-store`,
129
+ `Retry-After` on 423 and 429, `X-Touchque-State` on a refusal), and do the action once it returns
130
+ `approved`:
131
+
132
+ ```python
133
+ from flask import g, jsonify, request
134
+ from touchque import guard_input_from_headers, guard_response_headers, run_guard
135
+
136
+ @app.post('/transfer')
137
+ def transfer():
138
+ amount = int(request.json['amount'])
139
+ if amount > balance_of(g.user): # your checks first: no push for a transfer you refuse anyway
140
+ return jsonify(error='insufficient_funds'), 400
141
+
142
+ result = run_guard(
143
+ None, g.user.email, 'SEND_MONEY', # None: the default client (TQ_API_KEY / TQ_API_SECRET)
144
+ details={'Amount': f'{amount} EUR'}, # the same on every poll: build it from your own state
145
+ ip=request.remote_addr, user_agent=request.headers.get('User-Agent'),
146
+ **guard_input_from_headers(request.headers.get), # X-Touchque-Token, -Offline, -Code, -Code-Type
147
+ )
148
+ if 'approved' not in result:
149
+ return jsonify(result['body']), result['status'], guard_response_headers(result)
150
+
151
+ send_money(g.user, amount) # once: the approval is used up
152
+ return jsonify(ok=True, approval=result['approved'])
153
+ ```
154
+
155
+ The answers are the decorator's. 202 `{"touchque": step, "token": ...}` until the phone answers: the
156
+ page shows the step and sends the request again with `X-Touchque-Token` (`@touchque/web` does), and
157
+ `approved` comes exactly once, for this user, action and details. While the first-time QR is up the 202
158
+ step is `{"state": "enroll", "expiresAt": ...}` without `enroll`: keep the QR on screen; a new one comes
159
+ in `enroll.qrCodeDataUrl` after the old one expired. A refusal is 401, 403, 408, 423 or 429; 500
160
+ `misconfigured` and 503 `unavailable` when Touchque is not set up or cannot be reached.
161
+
162
+ With a rate limit of your own in front of it, count a request that only polls apart from a new attempt:
163
+ `from touchque import is_status_poll` and call it with the same arguments as `run_guard` (`None` for
164
+ the default client too); it is true when the request only reads the status or keeps the first-time QR.
165
+ Then pass `before_start=` to `run_guard`: it calls it at most once, right before a first-time-QR token
166
+ would start something after all (a push now that the phone is linked, or a new QR). Charge your per-user
167
+ limit there and return your 429 as a result (`{'status': 429, 'body': ...}`) to answer it instead, or
168
+ `None` to go on. The relay does this for `POST /login`.
169
+
76
170
  ## Action types
77
171
 
78
172
  `"SEND_MONEY"` above is an action type: one kind of approval, with the title the phone shows and
@@ -563,6 +657,54 @@ else:
563
657
 
564
658
  `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.
565
659
 
660
+ ## Testing your routes
661
+
662
+ `touchque.testing.FakeTouchque` is a `Touchque` client that answers in memory: no network, no API keys,
663
+ no Node.js. The real guard runs, so your routes answer what they answer in production, and your test
664
+ plays the phone:
665
+
666
+ ```python
667
+ # test_transfer.py
668
+ import pytest
669
+ from touchque.testing import FakeTouchque
670
+
671
+ from myapp import app # @require_touchque('SEND_MONEY', ...) with no client
672
+
673
+
674
+ @pytest.fixture
675
+ def tq():
676
+ with FakeTouchque() as fake: # the default client until the test ends
677
+ yield fake
678
+
679
+
680
+ def test_transfer_needs_an_approval(tq):
681
+ client = app.test_client()
682
+ first = client.post('/transfer', json={'amount': 5})
683
+ assert first.status_code == 202
684
+ token = first.get_json()['token']
685
+
686
+ tq.approve() # the person approves on the phone
687
+ done = client.post('/transfer', json={'amount': 5}, headers={'X-Touchque-Token': token})
688
+ assert done.status_code == 200
689
+ assert tq.requests[-1]['details'] == [{'label': 'Amount', 'value': '5 EUR'}]
690
+ ```
691
+
692
+ A route given `client=` uses that client: pass the fake there instead (`tq.install()` / `tq.uninstall()`
693
+ are the context manager's two halves).
694
+
695
+ - `tq.approve()`, `tq.reject()`, `tq.expire()`: the latest request, or one by its id
696
+ (`tq.approve(request_id)`; `via='WEBAUTHN'` approves with a passkey).
697
+ - `FakeTouchque(auto_approve=True)` (or `tq.auto_approve = True`): every request is approved as it is
698
+ made. The first request still answers 202; the next one runs the route.
699
+ - Every user has a phone linked. `tq.unlink(user)` shows them the first-time QR instead
700
+ (`FakeTouchque(linked=False)`: nobody has one); `tq.link(user)` is the phone scanning it.
701
+ `tq.secret_ttl_ms` is how long a QR works (5 minutes).
702
+ - `FakeTouchque(number_match=True)`: pushes ask for the number `47`.
703
+ - `tq.requests` (`id`, `user`, `type`, `details`, `referenceId`, `status`) and `tq.calls` (`method`,
704
+ `path`, `body`): what your app asked Touchque, in order.
705
+ - Offline approval: the code is `ABCD123`, the time-based code `123456`. Calls outside the step-up
706
+ (recovery, agents …) raise `TouchqueAPIException` 404. `FakeClient` is another name for it.
707
+
566
708
  ## Errors
567
709
 
568
710
  All SDK errors extend `TouchqueException`: `TouchqueAPIException`,
@@ -589,7 +731,7 @@ See [SECURITY.md](https://github.com/Touchque/touchque-python/blob/main/SECURITY
589
731
 
590
732
  ## Logging
591
733
 
592
- 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.
734
+ The SDK's own problems (a misconfigured guard, missing `TQ_API_KEY` / `TQ_API_SECRET` (once per process), a failed `on_challenge` or `error_body`, 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.
593
735
 
594
736
  ## License
595
737
 
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="touchque-authenticator",
5
- version="5.0.0",
5
+ version="5.1.0",
6
6
  description="Official Python SDK for Touchque Authenticator (2FA/MFA, passkeys, adaptive auth)",
7
7
  long_description=open("README.md").read(),
8
8
  long_description_content_type="text/markdown",
@@ -1,11 +1,14 @@
1
1
  """Contract tests against the shared fake Touchque API — a real HTTP round
2
2
  trip, so request signing is verified too (not just mocked). Same scenarios
3
3
  as the Node SDK's step-up tests."""
4
+ import time
5
+
4
6
  import pytest
5
7
 
6
8
  from touchque import Config, Touchque
7
9
  from touchque.exceptions import TouchqueConfigException
8
10
  from touchque.guard import is_status_poll, run_guard
11
+ from touchque.steps import _iso_ms, verify_guard_token
9
12
  from .fake_api import fake_api # noqa: F401 (fixture)
10
13
 
11
14
 
@@ -250,3 +253,117 @@ def test_an_approval_taken_back_is_never_used(fake_api):
250
253
  done = run_guard(tq, 'jane@acme.com', 'LOGIN', token=first['body']['token'])
251
254
  assert done['status'] == 408
252
255
  assert done['body']['touchque'] == {'state': 'expired', 'reason': 'revoked'}
256
+
257
+
258
+ # ── first-time QR: one QR per enrollment, not one per poll ──────────────
259
+
260
+ def test_enroll_polls_keep_the_qr_until_the_phone_is_linked(fake_api):
261
+ api = fake_api
262
+ tq = client_for(api)
263
+ first = run_guard(tq, 'new@acme.com', 'LOGIN', details={'Device': 'Mac'})
264
+ qr = first['body']['touchque']['enroll']
265
+ assert qr['qrCodeDataUrl'] == 'data:image/png;base64,QR-new@acme.com' and qr['recoveryCodes'] == ['r1']
266
+ ee = _iso_ms(qr['expiresAt'])
267
+ assert 290000 < ee - time.time() * 1000 <= 300000 # the API's own expiresAt (5 minutes)
268
+ claims = verify_guard_token('test_secret', first['body']['token'])
269
+ assert claims['st'] == 'enroll' and claims['ee'] == ee
270
+
271
+ before = len(api.calls())
272
+ poll = run_guard(tq, 'new@acme.com', 'LOGIN', details={'Device': 'Mac'}, token=first['body']['token'])
273
+ assert poll['status'] == 202
274
+ assert poll['body']['touchque'] == {'state': 'enroll', 'expiresAt': qr['expiresAt'],
275
+ 'details': [{'label': 'Device', 'value': 'Mac'}]}
276
+ assert [c['path'] for c in api.calls()[before:]] == ['/users/new%40acme.com'] # no generate or reset
277
+ again = verify_guard_token('test_secret', poll['body']['token'])
278
+ assert again['st'] == 'enroll' and again['ee'] == ee
279
+
280
+ api.link('new@acme.com') # the phone scanned the QR
281
+ waiting = run_guard(tq, 'new@acme.com', 'LOGIN', details={'Device': 'Mac'}, token=poll['body']['token'])
282
+ assert waiting['status'] == 202 and waiting['body']['touchque']['state'] == 'waiting'
283
+
284
+
285
+ def test_an_enroll_qr_about_to_expire_is_replaced(fake_api):
286
+ api = fake_api
287
+ api.opts(secretTtlMs=1000)
288
+ tq = client_for(api)
289
+ first = run_guard(tq, 'new@acme.com', 'LOGIN')
290
+ assert first['body']['touchque']['enroll']['qrCodeDataUrl'] == 'data:image/png;base64,QR-new@acme.com'
291
+ poll = run_guard(tq, 'new@acme.com', 'LOGIN', token=first['body']['token'])
292
+ assert poll['status'] == 202
293
+ assert poll['body']['touchque']['enroll']['qrCodeDataUrl'] == 'data:image/png;base64,QR2-new@acme.com'
294
+ assert '/auth/secret/reset' in api.paths()
295
+
296
+
297
+ def test_an_enroll_token_without_an_expiry_starts_again(fake_api):
298
+ """No "ee" (an enroll step without a QR, or a 5.0 token): every poll starts again, as before."""
299
+ api = fake_api
300
+ tq = client_for(api)
301
+ old = run_guard(tq, 'new@acme.com', 'LOGIN', enroll=False)
302
+ assert old['body']['touchque'] == {'state': 'enroll'}
303
+ assert 'ee' not in verify_guard_token('test_secret', old['body']['token'])
304
+ assert is_status_poll(tq, 'new@acme.com', 'LOGIN', token=old['body']['token']) is False
305
+ again = run_guard(tq, 'new@acme.com', 'LOGIN', token=old['body']['token'])
306
+ assert again['body']['touchque']['enroll']['qrCodeDataUrl']
307
+ assert '/users/new%40acme.com' not in api.paths()
308
+
309
+
310
+ def test_is_status_poll_counts_a_bound_enroll_token(fake_api):
311
+ tq = client_for(fake_api)
312
+ token = run_guard(tq, 'new@acme.com', 'LOGIN')['body']['token']
313
+ assert is_status_poll(tq, 'new@acme.com', 'LOGIN', token=token) is True
314
+ assert is_status_poll(tq, 'other@acme.com', 'LOGIN', token=token) is False
315
+ assert is_status_poll(tq, 'new@acme.com', 'SEND_MONEY', token=token) is False
316
+ assert is_status_poll(tq, 'new@acme.com', 'LOGIN', token=token, offline=True) is False
317
+
318
+ # The poll that renews an expiring QR (or replays an old token) still counts against the limit.
319
+ fake_api.opts(secretTtlMs=1000)
320
+ expiring = run_guard(tq, 'late@acme.com', 'LOGIN')['body']['token']
321
+ assert verify_guard_token('test_secret', expiring)['ee']
322
+ assert is_status_poll(tq, 'late@acme.com', 'LOGIN', token=expiring) is False
323
+
324
+
325
+ def test_enroll_qr_expiry_is_the_apis_or_now_plus_ttl():
326
+ from touchque.steps import _enroll_qr, _iso_from_ms
327
+ assert _enroll_qr({'qrCodeDataUrl': 'qr', 'expiresAt': '2026-11-12T10:05:00.000Z', 'ttlMs': 1000})['enroll'][
328
+ 'expiresAt'] == '2026-11-12T10:05:00.000Z'
329
+ before = int(time.time() * 1000)
330
+ step = _enroll_qr({'qrCodeDataUrl': 'qr', 'expiresAt': 'soon', 'ttlMs': 60000})
331
+ assert before + 60000 <= _iso_ms(step['enroll']['expiresAt']) <= time.time() * 1000 + 60000
332
+ assert 'expiresAt' not in _enroll_qr({'qrCodeDataUrl': 'qr', 'expiresAt': 'soon'})['enroll']
333
+ assert _iso_from_ms(_iso_ms('2026-11-12T12:05:00+02:00')) == '2026-11-12T10:05:00.000Z'
334
+ assert _iso_ms('2026-13-12T10:05:00Z') is None and _iso_ms(1700000000000) is None
335
+
336
+
337
+ def test_before_start_runs_once_only_when_an_enroll_token_starts(fake_api):
338
+ api = fake_api
339
+ tq = client_for(api)
340
+ seen = []
341
+
342
+ def hook():
343
+ seen.append(api.paths()[-1])
344
+ return None
345
+
346
+ token = run_guard(tq, 'new@acme.com', 'LOGIN', before_start=hook)['body']['token'] # a fresh attempt
347
+ keep = run_guard(tq, 'new@acme.com', 'LOGIN', token=token, before_start=hook) # keeps the QR
348
+ assert keep['body']['touchque']['state'] == 'enroll' and seen == []
349
+
350
+ api.link('new@acme.com')
351
+ before = len(api.calls())
352
+ refused = run_guard(tq, 'new@acme.com', 'LOGIN', token=token,
353
+ before_start=lambda: {'status': 429, 'body': {'error': 'RATE_LIMITED'}})
354
+ assert refused == {'status': 429, 'body': {'error': 'RATE_LIMITED'}}
355
+ assert api.paths()[before:] == ['/users/new%40acme.com'] # nothing started
356
+ went = run_guard(tq, 'new@acme.com', 'LOGIN', token=token, before_start=hook)
357
+ assert went['body']['touchque']['state'] == 'waiting' and seen == ['/users/new%40acme.com']
358
+
359
+ api.opts(secretTtlMs=1000) # a QR within 15 s of its expiry: the hook runs before the new QR
360
+ late = run_guard(tq, 'late@acme.com', 'LOGIN')['body']['token']
361
+ renewed = run_guard(tq, 'late@acme.com', 'LOGIN', token=late, before_start=hook)
362
+ assert renewed['body']['touchque']['enroll']['qrCodeDataUrl'] and len(seen) == 2
363
+
364
+ api.reset()
365
+ fresh = run_guard(tq, 'gone@acme.com', 'LOGIN')['body']['token']
366
+ api.reset() # the QR is gone: GET /users answers 404, and the hook runs before the new QR
367
+ gone = run_guard(tq, 'gone@acme.com', 'LOGIN', token=fresh, before_start=hook)
368
+ assert gone['body']['touchque']['enroll']['qrCodeDataUrl'] and len(seen) == 3
369
+ assert run_guard(tq, 'gone@acme.com', 'LOGIN', before_start=hook)['status'] == 202 and len(seen) == 3
@@ -0,0 +1,140 @@
1
+ import dataclasses
2
+ import json
3
+
4
+ import pytest
5
+
6
+ django = pytest.importorskip('django')
7
+ from django.conf import settings as _settings
8
+ if not _settings.configured: # test_relay_django.py may have configured it already
9
+ _settings.configure(DEBUG=True, ALLOWED_HOSTS=['*'], DEFAULT_CHARSET='utf-8')
10
+ import django as _django
11
+ _django.setup()
12
+
13
+ from django.test import RequestFactory # noqa: E402
14
+
15
+ from touchque import Config, Touchque # noqa: E402
16
+ from touchque.contrib.django import require_touchque # noqa: E402
17
+ from touchque.testing import FakeTouchque # noqa: E402
18
+ from .fake_api import fake_api # noqa: F401,E402
19
+ from .guard_helpers import MISCONFIGURED, no_keys # noqa: F401,E402
20
+
21
+
22
+ def make_view(tq, action='SEND_MONEY'):
23
+ @require_touchque(action, client=tq, user=lambda req: req.headers.get('X-User'),
24
+ details=lambda req: {'Amount': '5 EUR'})
25
+ def transfer(request):
26
+ from django.http import JsonResponse
27
+ return JsonResponse({'done': True, 'touchque': request.touchque})
28
+ return transfer
29
+
30
+
31
+ def test_full_flow(fake_api):
32
+ fake_api.link('jane@acme.com')
33
+ fake_api.opts(numberMatch=True)
34
+ tq = Touchque(Config(api_key='tq_test_key', api_secret='test_secret', base_url=fake_api.base_url))
35
+ view = make_view(tq)
36
+ rf = RequestFactory()
37
+
38
+ first = view(rf.post('/transfer', data=b'{}', content_type='application/json', HTTP_X_USER='jane@acme.com'))
39
+ assert first.status_code == 202
40
+ body = json.loads(first.content)
41
+ assert body['touchque']['number'] == '47'
42
+
43
+ fake_api.approve()
44
+ done = view(rf.post('/transfer', data=b'{}', content_type='application/json',
45
+ HTTP_X_USER='jane@acme.com', HTTP_X_TOUCHQUE_TOKEN=body['token']))
46
+ assert done.status_code == 200
47
+ assert json.loads(done.content)['done'] is True
48
+
49
+
50
+ def test_silent_sign_in_runs_the_view_at_once_for_a_known_browser(fake_api):
51
+ from .silent_vectors import PROOF
52
+ from django.http import JsonResponse
53
+
54
+ fake_api.link('jane@acme.com')
55
+ fake_api.opts(silent=True)
56
+ tq = Touchque(Config(api_key='tq_test_key', api_secret='test_secret', base_url=fake_api.base_url))
57
+
58
+ @require_touchque('LOGIN', client=tq, user=lambda req: 'jane@acme.com', silent_sign_in=True)
59
+ def login(request):
60
+ return JsonResponse({'done': True, 'touchque': request.touchque})
61
+
62
+ res = login(RequestFactory().post('/login/2fa', data=b'{}', content_type='application/json',
63
+ HTTP_X_TOUCHQUE_BROWSER=PROOF))
64
+ assert res.status_code == 200
65
+ body = json.loads(res.content)
66
+ assert body['touchque']['confirmedVia'] == 'SILENT'
67
+ assert [r['code'] for r in body['touchque']['reasons']][0] == 'known_browser'
68
+ assert fake_api.last_check()['browserProof'] == PROOF
69
+
70
+
71
+ def make_guarded_view(ran, **options):
72
+ @require_touchque('SEND_MONEY', user=lambda req: req.headers.get('X-User'), **options)
73
+ def transfer(request):
74
+ from django.http import JsonResponse
75
+ ran.append(True)
76
+ return JsonResponse({'done': True})
77
+ return transfer
78
+
79
+
80
+ def post(view, **headers):
81
+ return view(RequestFactory().post('/transfer', data=b'{}', content_type='application/json', **headers))
82
+
83
+
84
+ def test_without_keys_the_view_answers_500_misconfigured_and_never_runs(no_keys):
85
+ ran = []
86
+ view = make_guarded_view(ran) # no client: nothing raised at import
87
+ res = post(view, HTTP_X_USER='jane@acme.com')
88
+ assert res.status_code == 500
89
+ assert json.loads(res.content) == MISCONFIGURED
90
+ assert res['Cache-Control'] == 'no-store'
91
+ anonymous = post(view)
92
+ assert anonymous.status_code == 401 and json.loads(anonymous.content)['error']['code'] == 'unauthenticated'
93
+ assert ran == []
94
+
95
+
96
+ def test_error_body_object_is_merged_and_anything_else_sent_as_it_is():
97
+ tq, seen = FakeTouchque(), []
98
+
99
+ def body(refusal):
100
+ seen.append(refusal['status'])
101
+ return {'message': refusal['message'], 'code': refusal['code']}
102
+
103
+ view = make_guarded_view([], client=tq, error_body=body)
104
+ first = post(view, HTTP_X_USER='jane@acme.com')
105
+ assert first.status_code == 202 and seen == []
106
+ tq.expire()
107
+ res = post(view, HTTP_X_USER='jane@acme.com', HTTP_X_TOUCHQUE_TOKEN=json.loads(first.content)['token'])
108
+ assert res.status_code == 408 and res['Cache-Control'] == 'no-store'
109
+ assert json.loads(res.content) == {'message': 'The approval expired. Try again.', 'code': 'expired',
110
+ 'touchque': {'state': 'expired', 'requestId': tq.requests[-1]['id']}}
111
+ assert seen == [408]
112
+
113
+ listed = post(make_guarded_view([], client=tq, error_body=lambda r: [r['code']]))
114
+ assert listed.status_code == 401 and json.loads(listed.content) == ['unauthenticated']
115
+
116
+
117
+ @dataclasses.dataclass
118
+ class Problem:
119
+ code: str
120
+ message: str
121
+
122
+
123
+ def test_refusal_headers_and_error_body_by_its_json_encoding():
124
+ step = {'state': 'blocked', 'reason': 'unauthenticated'}
125
+ res = post(make_guarded_view([], client=FakeTouchque(), error_body=lambda r: Problem(r['code'], r['message'])))
126
+ assert res.status_code == 401
127
+ assert json.loads(res.content) == {'code': 'unauthenticated', 'message': 'Sign in first.', 'touchque': step}
128
+ assert res['X-Touchque-State'] == 'blocked' and res['X-Touchque-Reason'] == 'unauthenticated'
129
+
130
+ broken = post(make_guarded_view([], client=FakeTouchque(), error_body=lambda r: {r['code']}))
131
+ assert broken.status_code == 401 and json.loads(broken.content) == {
132
+ 'touchque': step, 'error': {'code': 'unauthenticated', 'message': 'Sign in first.'}}
133
+
134
+ tq = FakeTouchque()
135
+ view = make_guarded_view([], client=tq)
136
+ first = post(view, HTTP_X_USER='jane@acme.com')
137
+ assert first.status_code == 202 and not first.has_header('X-Touchque-State')
138
+ tq.reject()
139
+ res = post(view, HTTP_X_USER='jane@acme.com', HTTP_X_TOUCHQUE_TOKEN=json.loads(first.content)['token'])
140
+ assert res.status_code == 403 and res['X-Touchque-State'] == 'rejected' and not res.has_header('X-Touchque-Reason')