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.
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/PKG-INFO +144 -2
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/README.md +143 -1
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/setup.py +1 -1
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_contract.py +117 -0
- touchque_authenticator-5.1.0/tests/test_contrib_django.py +140 -0
- touchque_authenticator-5.1.0/tests/test_contrib_fastapi.py +207 -0
- touchque_authenticator-5.1.0/tests/test_contrib_flask.py +269 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_relay.py +63 -0
- touchque_authenticator-5.1.0/tests/test_testing.py +163 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/__init__.py +7 -1
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/client.py +9 -2
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/contrib/django.py +31 -9
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/contrib/fastapi.py +61 -14
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/contrib/flask.py +26 -9
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/guard.py +234 -20
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/http_client.py +7 -5
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/relay.py +25 -14
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/steps.py +54 -10
- touchque_authenticator-5.1.0/touchque/testing.py +376 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque_authenticator.egg-info/PKG-INFO +144 -2
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque_authenticator.egg-info/SOURCES.txt +2 -0
- touchque_authenticator-5.0.0/tests/test_contrib_django.py +0 -65
- touchque_authenticator-5.0.0/tests/test_contrib_fastapi.py +0 -94
- touchque_authenticator-5.0.0/tests/test_contrib_flask.py +0 -85
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/LICENSE +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/setup.cfg +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_actions.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_agent_frameworks.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_agents.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_approval_proof.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_assist.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_auth.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_case_codes.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_code_reply.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_config.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_cross_app_access.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_device_risk.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_feature_errors.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_hosted_login.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_http_client.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_identity.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_login.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_mcp_auth.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_mobile.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_my_account.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_offline.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_org_agent.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_qr_pairings.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_recovery.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_relay_django.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_relay_fastapi.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_relay_flask.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_report_fraud.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_safe_guard.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_security_events.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_silent_sign_in.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_silent_sign_in_vectors.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_verify_challenge.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_webauthn.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/tests/test_webhook.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/agent.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/agent_card.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/agent_frameworks.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/approval_proof.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/approval_token.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/code_reply.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/config.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/contrib/__init__.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/cross_app_access.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/exceptions.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/hosted_login.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/mcp_auth.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/my_account.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/org_agent.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/rate_limiter.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/__init__.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/action_types.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/actions.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/agents.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/assist.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/auth.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/device_risk.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/identity.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/login.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/mobile.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/offline.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/qr_pairings.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/recovery.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/risk.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/safe_guard.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/webauthn.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/resources/webhook.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/safe_guard.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque/security_events.py +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque_authenticator.egg-info/dependency_links.txt +0 -0
- {touchque_authenticator-5.0.0 → touchque_authenticator-5.1.0}/touchque_authenticator.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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')
|