touchque-authenticator 4.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. touchque/__init__.py +148 -0
  2. touchque/agent.py +314 -0
  3. touchque/agent_card.py +190 -0
  4. touchque/agent_frameworks.py +215 -0
  5. touchque/approval_proof.py +104 -0
  6. touchque/approval_token.py +134 -0
  7. touchque/client.py +105 -0
  8. touchque/code_reply.py +75 -0
  9. touchque/config.py +56 -0
  10. touchque/contrib/__init__.py +4 -0
  11. touchque/contrib/django.py +175 -0
  12. touchque/contrib/fastapi.py +208 -0
  13. touchque/contrib/flask.py +154 -0
  14. touchque/cross_app_access.py +222 -0
  15. touchque/exceptions.py +170 -0
  16. touchque/guard.py +290 -0
  17. touchque/hosted_login.py +474 -0
  18. touchque/http_client.py +156 -0
  19. touchque/mcp_auth.py +256 -0
  20. touchque/my_account.py +173 -0
  21. touchque/org_agent.py +262 -0
  22. touchque/rate_limiter.py +56 -0
  23. touchque/relay.py +643 -0
  24. touchque/resources/__init__.py +1 -0
  25. touchque/resources/action_types.py +52 -0
  26. touchque/resources/actions.py +234 -0
  27. touchque/resources/agents.py +236 -0
  28. touchque/resources/assist.py +115 -0
  29. touchque/resources/auth.py +65 -0
  30. touchque/resources/device_risk.py +174 -0
  31. touchque/resources/identity.py +268 -0
  32. touchque/resources/login.py +296 -0
  33. touchque/resources/mobile.py +130 -0
  34. touchque/resources/offline.py +138 -0
  35. touchque/resources/qr_pairings.py +93 -0
  36. touchque/resources/recovery.py +118 -0
  37. touchque/resources/risk.py +141 -0
  38. touchque/resources/safe_guard.py +99 -0
  39. touchque/resources/webauthn.py +115 -0
  40. touchque/resources/webhook.py +258 -0
  41. touchque/safe_guard.py +308 -0
  42. touchque/security_events.py +205 -0
  43. touchque/steps.py +239 -0
  44. touchque_authenticator-4.2.0.dist-info/METADATA +630 -0
  45. touchque_authenticator-4.2.0.dist-info/RECORD +48 -0
  46. touchque_authenticator-4.2.0.dist-info/WHEEL +5 -0
  47. touchque_authenticator-4.2.0.dist-info/licenses/LICENSE +21 -0
  48. touchque_authenticator-4.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,296 @@
1
+ import logging
2
+ import time
3
+ from datetime import datetime, timezone
4
+ from typing import Any, Callable, Dict, List, Optional, Union
5
+
6
+ # Label -> value, or an ordered list of {'label': ..., 'value': ...}.
7
+ LoginDetails = Union[Dict[str, Union[str, int, float]], List[Dict[str, Any]]]
8
+ from urllib.parse import quote
9
+ from ..http_client import HttpClient
10
+ from ..exceptions import TouchQueTimeoutException, TouchQueRejectedException, TouchQuePasskeyRequiredError, TouchQueConfigException
11
+
12
+ logger = logging.getLogger('touchque')
13
+
14
+ SECURITY_CHANGE_KINDS = ('password', 'mfa', 'device', 'email', 'phone')
15
+ DESTINATION_KINDS = ('iban', 'account', 'wallet', 'address', 'payee')
16
+
17
+
18
+ def _security_changes_body(changes: Dict[str, Any]) -> Dict[str, Any]:
19
+ """datetimes go out as ISO 8601 (a naive one is taken as UTC); strings and epoch milliseconds as given."""
20
+ out: Dict[str, Any] = {}
21
+ for kind, at in changes.items():
22
+ if at is None:
23
+ continue
24
+ if isinstance(at, datetime):
25
+ out[kind] = (at if at.tzinfo else at.replace(tzinfo=timezone.utc)).isoformat()
26
+ else:
27
+ out[kind] = at
28
+ return out
29
+
30
+
31
+ class Login:
32
+ def __init__(self, http: HttpClient):
33
+ self._http = http
34
+
35
+ def request(self, external_username: str, type: str = 'LOGIN', reference_id: Optional[str] = None, client_ip: Optional[str] = None, user_agent: Optional[str] = None, require_biometric: bool = False, require_number_match: bool = False, details: Optional[LoginDetails] = None, browser_proof: Optional[str] = None, context: Optional[Dict[str, Any]] = None, challenge_digits: Optional[int] = None, expires_in: Optional[int] = None, client_signals: Optional[str] = None, security_changes: Optional[Dict[str, Any]] = None, destination: Optional[Dict[str, Any]] = None, locale: Optional[str] = None, qr_pairing: Optional[str] = None, device_risk: Optional[str] = None, device_risk_request_id: Optional[str] = None, reply: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
36
+ """
37
+ Request a 2FA login/approval from the user's mobile device.
38
+
39
+ When the integration has behavioral biometrics enabled
40
+ (``TenantPolicy.behavioralBiometricsEnabled``), the response also
41
+ contains ``telemetryToken`` — pass it plus ``requestId`` to your
42
+ frontend to initialize the ``@touchque/web`` behavioral widget
43
+ (``tq.behavioral.attach(...)``). There is no widget in this server SDK.
44
+
45
+ ``details`` is transaction context shown on the approval screen, e.g.
46
+ ``{'Amount': '1,250.00 USD', 'Recipient': 'Jane Doe'}`` (shown in
47
+ order). At most 8 entries, labels <= 40 and values <= 120
48
+ characters; the API rejects (never truncates) anything outside those
49
+ limits. Build it from server-side state, never from browser input.
50
+
51
+ ``browser_proof`` (silent sign-ins): the browser's device-bound key
52
+ proof, exactly as the browser sent it (``X-TouchQue-Browser`` header,
53
+ made by ``@touchque/web``). When the user approves this request on the
54
+ phone (or with a passkey), TouchQue remembers the browser, so a later
55
+ ``risk.check`` can let the same browser in without asking. Pass it
56
+ through untouched; an invalid proof only means "a browser TouchQue
57
+ does not know".
58
+
59
+ ``context``: for your TouchQue Actions only (``event.context``): roles,
60
+ plan, amount… Any JSON object up to 4 KB. Never shown on the phone.
61
+
62
+ ``expires_in``: how long the push waits for the person, in seconds:
63
+ 30 (default) to 300; longer suits a request the person has to read
64
+ first, such as an AI agent asking on their behalf.
65
+
66
+ ``qr_pairing``: a browser pairing by QR code (``client.qr_pairings``),
67
+ scanned and confirmed on the phone: only the phone that scanned it is
68
+ asked, once, with number matching, for the user it scanned for.
69
+
70
+ ``device_risk_request_id``: the page's identification from ``@touchque/web``
71
+ (``deviceRisk.identify()``), for this account only; or ``device_risk``,
72
+ the page's report (``deviceRisk.collect({ purpose })``), passed through untouched. Used
73
+ only when your workspace turned Device risk on: its signals only ever
74
+ ask for more, and a device your admin marked "step up always" asks at once.
75
+
76
+ ``client_signals``: the end user's page check from ``@touchque/web``
77
+ (``startClientSignals().encode()``), passed through untouched. A browser
78
+ driven by software asks with number matching, or is refused where the
79
+ workspace blocks it (403 ``automated_client``).
80
+
81
+ ``security_changes``: when the user's sign-in security last changed in
82
+ your system, ``{'password', 'mfa', 'device', 'email', 'phone'}`` each a
83
+ datetime, ISO 8601 string or epoch milliseconds. TouchQue adds what it
84
+ saw itself; access rules and Actions read the minutes since each
85
+ (``minutesSince.mfaChange``, ``.deviceChange``, ...), e.g. refuse
86
+ ``PAYOUT_CHANGE`` within 24 hours of a 2FA change.
87
+
88
+ ``destination``: where the action sends money or value,
89
+ ``{'kind': 'iban'|'account'|'wallet'|'address'|'payee', 'value': ..., 'known': [...]}``.
90
+ The phone shows it in full in groups of four, with a warning when it
91
+ looks like one the user used before but is not the same; the answer's
92
+ ``destination`` is ``{'known', 'lookalike'}``. Do not repeat it in ``details``.
93
+
94
+ ``locale``: ``'tr'`` (default) or ``'en'``, the language of those rows.
95
+
96
+ ``reply``: ask the person to type back on the phone a sign-in code another service sent
97
+ them, ``{'kind': 'code', 'key': new_code_reply_key()['public_key'], 'channel': 'sms',
98
+ 'target': '+**********26'}``. The phone encrypts it to ``key``; ``status()['reply']['ct']``
99
+ holds only the ciphertext: open it with ``open_code_reply``. Such a request lives 5
100
+ minutes. Not with ``qr_pairing``.
101
+ """
102
+ payload = {
103
+ 'externalUsername': external_username,
104
+ 'type': type
105
+ }
106
+ if reference_id is not None:
107
+ payload['referenceId'] = reference_id
108
+ if client_ip is not None:
109
+ payload['clientIp'] = client_ip
110
+ if user_agent is not None:
111
+ payload['userAgent'] = user_agent
112
+ if require_biometric:
113
+ payload['requireBiometric'] = True
114
+ if require_number_match:
115
+ payload['requireNumberMatch'] = True
116
+ if challenge_digits in (2, 3):
117
+ payload['challengeDigits'] = challenge_digits
118
+ if expires_in is not None:
119
+ payload['expiresIn'] = expires_in
120
+ if qr_pairing:
121
+ payload['qrPairing'] = qr_pairing
122
+ if reply is not None:
123
+ payload['reply'] = reply
124
+ if client_signals:
125
+ payload['clientSignals'] = client_signals
126
+ if device_risk:
127
+ payload['deviceRisk'] = device_risk
128
+ if device_risk_request_id:
129
+ payload['deviceRiskRequestId'] = device_risk_request_id
130
+ if details:
131
+ payload['details'] = details
132
+ if browser_proof:
133
+ payload['browserProof'] = browser_proof
134
+ if context:
135
+ payload['context'] = context
136
+ if security_changes:
137
+ payload['securityChanges'] = _security_changes_body(security_changes)
138
+ if destination:
139
+ payload['destination'] = destination
140
+ if locale:
141
+ payload['locale'] = locale
142
+
143
+ return self._http.post('/login/request', payload)
144
+
145
+ def status(self, request_id: str) -> Dict[str, Any]:
146
+ """Check the current status of a login request."""
147
+ return self._http.get(f"/login/status/{quote(request_id, safe='')}")
148
+
149
+ def wait_for_approval(self, request_id: str, timeout_ms: int = 30000, poll_interval_ms: int = 400) -> Dict[str, Any]:
150
+ """Wait (poll) until the user approves or rejects a request you already sent with ``request``.
151
+
152
+ Returns ``{'approved': True, 'status': 'CONFIRMED', 'requestId', 'assurance', 'confirmedVia',
153
+ 'provisional'}``. Raises ``TouchQueRejectedException`` when the user said no,
154
+ ``TouchQueTimeoutException`` when the request expired or ``timeout_ms`` ran out, and
155
+ ``TouchQuePasskeyRequiredError`` (``reason='step_up'``) when the user tapped Approve
156
+ during a call and the request now waits for a passkey in the browser::
157
+
158
+ req = tq.login.request(user, 'SEND_MONEY')
159
+ tq.login.wait_for_approval(req['requestId'], timeout_ms=60000)"""
160
+ start_time = time.time() * 1000
161
+
162
+ while True:
163
+ status_result = self.status(request_id)
164
+ status = status_result.get('status')
165
+
166
+ if status == 'CONFIRMED':
167
+ return {
168
+ 'approved': True,
169
+ 'status': status,
170
+ 'requestId': request_id,
171
+ 'assurance': status_result.get('assurance'),
172
+ 'confirmedVia': status_result.get('confirmedVia'),
173
+ # Vishing Shield: approved during a call; critical actions wait for the phone to re-confirm.
174
+ 'provisional': bool(status_result.get('provisional')),
175
+ }
176
+
177
+ if status == 'REJECTED':
178
+ raise TouchQueRejectedException(f"Request {request_id} was rejected by the user.")
179
+
180
+ if status == 'EXPIRED':
181
+ raise TouchQueTimeoutException(f"Request {request_id} expired before it was approved.")
182
+
183
+ # Vishing Shield: tapped during a call, the request now waits for a passkey in the browser.
184
+ if status == 'PENDING' and status_result.get('requiresPasskey'):
185
+ raise TouchQuePasskeyRequiredError(request_id, 'step_up')
186
+
187
+ elapsed = (time.time() * 1000) - start_time
188
+ if elapsed > timeout_ms:
189
+ raise TouchQueTimeoutException(f"Waiting for approval timed out after {timeout_ms}ms")
190
+
191
+ time.sleep(poll_interval_ms / 1000)
192
+
193
+ def verify(self, external_username: str, type: str = 'LOGIN', reference_id: Optional[str] = None, client_ip: Optional[str] = None, user_agent: Optional[str] = None, require_biometric: bool = False, require_number_match: bool = False, timeout_ms: int = 30000, details: Optional[LoginDetails] = None, browser_proof: Optional[str] = None, on_challenge: Optional[Callable[[Dict[str, Any]], Any]] = None, context: Optional[Dict[str, Any]] = None, challenge_digits: Optional[int] = None, security_changes: Optional[Dict[str, Any]] = None, destination: Optional[Dict[str, Any]] = None, locale: Optional[str] = None) -> Dict[str, Any]:
194
+ """Request a login and wait (poll) until the user approves or rejects it.
195
+
196
+ With number matching (your ``require_number_match``, a workspace policy
197
+ or the risk engine can turn it on) the user must pick a number on the
198
+ phone. ``on_challenge`` is called once, right after the request is
199
+ created and before any waiting, with ``{'requestId', 'challengeCode',
200
+ 'expiresAt', 'reasons', 'caseCode'}``: show ``challengeCode`` to the
201
+ user now; the phone asks for it. An exception it raises is logged and
202
+ ignored (verify keeps waiting). It is not called when only a passkey
203
+ can approve::
204
+
205
+ tq.login.verify(user, 'SEND_MONEY',
206
+ on_challenge=lambda c: c.get('challengeCode') and show(f"Pick {c['challengeCode']} on your phone"))
207
+
208
+ Raises ``TouchQuePasskeyRequiredError`` when a passkey must approve it:
209
+ a phishing-resistant policy (``reason='policy'``, no push sent), or the
210
+ user tapped Approve during a call (``reason='step_up'``, Vishing Shield):
211
+ finish it in the browser with ``@touchque/web``."""
212
+ request_result = self.request(external_username, type, reference_id, client_ip, user_agent, require_biometric, require_number_match, details, browser_proof, context, challenge_digits, security_changes=security_changes, destination=destination, locale=locale)
213
+ request_id = request_result['requestId']
214
+ if request_result.get('requiresPasskey'):
215
+ raise TouchQuePasskeyRequiredError(request_id)
216
+
217
+ if on_challenge is not None:
218
+ challenge = {'requestId': request_id, 'expiresAt': request_result.get('expiresAt')}
219
+ for key in ('challengeCode', 'reasons', 'caseCode'):
220
+ if request_result.get(key):
221
+ challenge[key] = request_result[key]
222
+ try:
223
+ on_challenge(challenge)
224
+ except Exception as err: # noqa: BLE001 — the user's callback must not stop the wait
225
+ # err.__class__, not type(err): the parameter `type` shadows the builtin here.
226
+ logger.warning('[TouchQue] on_challenge failed: %s: %s', err.__class__.__name__, err)
227
+
228
+ start_time = time.time() * 1000
229
+ poll_interval_sec = 0.4
230
+
231
+ while True:
232
+ status_result = self.status(request_id)
233
+ status = status_result.get('status')
234
+
235
+ if status == 'CONFIRMED':
236
+ return {
237
+ 'approved': True,
238
+ 'status': status,
239
+ 'requestId': request_id,
240
+ 'challengeCode': request_result.get('challengeCode'),
241
+ 'assurance': status_result.get('assurance'),
242
+ 'confirmedVia': status_result.get('confirmedVia'),
243
+ # Vishing Shield: approved during a call; critical actions wait for the phone to re-confirm.
244
+ 'provisional': bool(status_result.get('provisional')),
245
+ }
246
+
247
+ if status == 'REJECTED':
248
+ raise TouchQueRejectedException(f"Request {request_id} was rejected by the user.")
249
+
250
+ if status == 'EXPIRED':
251
+ raise TouchQueTimeoutException(f"Request {request_id} expired before it was approved.")
252
+
253
+ # Vishing Shield: tapped during a call, the request now waits for a passkey in the browser.
254
+ if status == 'PENDING' and status_result.get('requiresPasskey'):
255
+ raise TouchQuePasskeyRequiredError(request_id, 'step_up')
256
+
257
+ elapsed = (time.time() * 1000) - start_time
258
+ if elapsed > timeout_ms:
259
+ raise TouchQueTimeoutException(f"Login verification timed out after {timeout_ms}ms")
260
+
261
+ time.sleep(poll_interval_sec)
262
+
263
+ def approve_with_recovery_code(self, request_id: str, code: str) -> Dict[str, Any]:
264
+ """Approve a pending 2FA request using a Recovery Code (bypasses mobile device)."""
265
+ return self._http.post('/login/recovery', {
266
+ 'requestId': request_id,
267
+ 'code': code
268
+ })
269
+
270
+ def consume(self, request_id: str) -> Dict[str, Any]:
271
+ """Uses an approved request exactly once. The first call on a
272
+ CONFIRMED request wins atomically; every later call (a replayed
273
+ token, a retried form post) raises ``TouchQueAPIException`` with
274
+ ``code='already_used'`` (409)."""
275
+ return self._http.post(f"/login/{quote(request_id, safe='')}/consume", {})
276
+
277
+ FRAUD_REASONS = ('chargeback', 'account_takeover', 'social_engineering', 'other')
278
+
279
+ def report_fraud(self, request_id: str, reason: Optional[str] = None, note: Optional[str] = None) -> Dict[str, Any]:
280
+ """Report a request you later learned was fraud — a chargeback, a
281
+ confirmed account takeover, a scam the user was talked into. TouchQue's
282
+ risk engine learns from it (it is the strongest signal it gets) and the
283
+ report is kept in your audit log. The request's status does not change.
284
+ Safe to call more than once: a repeat answers ``alreadyReported: True``.
285
+
286
+ :param reason: one of ``Login.FRAUD_REASONS`` (default ``'other'``).
287
+ :param note: free text for your audit log, at most 500 characters.
288
+ """
289
+ if not request_id:
290
+ raise TouchQueConfigException('report_fraud() needs the request id')
291
+ payload: Dict[str, Any] = {}
292
+ if reason:
293
+ payload['reason'] = reason
294
+ if note:
295
+ payload['note'] = note
296
+ return self._http.post(f"/login/{quote(request_id, safe='')}/report-fraud", payload)
@@ -0,0 +1,130 @@
1
+ """
2
+ TouchQue Mobile SDK, your backend's side: your own iOS/Android app is the
3
+ approver of your users (white-label); they never install the TouchQue app.
4
+
5
+ 1. A user signed in to your app turns approvals on: ``create_enrollment``
6
+ gives a one-time token; your app hands it to the SDK (``TouchQue.enroll``).
7
+ 2. Ask for approvals as always (``login.request``, ``start``, guards...):
8
+ they reach your app over the SDK's encrypted connection.
9
+ 3. TouchQue holds no push key of your app. For each request it sends you a
10
+ signed MOBILE_PUSH webhook: verify it (``webhook.verify``), then
11
+ ``mobile_push_from_webhook(event)`` gives the APNs payload / FCM data to
12
+ send with your own credentials.
13
+ """
14
+ import json
15
+ from datetime import datetime, timezone
16
+ from typing import Any, Dict, Optional
17
+ from urllib.parse import quote
18
+
19
+ from ..exceptions import TouchQueConfigException
20
+ from ..http_client import HttpClient
21
+
22
+
23
+ def mobile_push_from_webhook(event: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
24
+ """The push to send for a verified MOBILE_PUSH webhook, or None for any other event.
25
+
26
+ Returns ``deviceId``, ``externalUsername``, ``platform``, ``pushToken`` (None: send by your
27
+ own user -> device mapping), ``requestId``, ``expiresAt``, ``kind``, ``alert`` (English,
28
+ translate it), ``apns`` (the APNs payload: alert + the custom key ``touchque``) and
29
+ ``fcmData`` (the FCM data map). The data carries no secret, no details and no number.
30
+ """
31
+ if not isinstance(event, dict) or event.get('event') != 'MOBILE_PUSH':
32
+ return None
33
+ data = event.get('data') or {}
34
+ touchque = data.get('touchque') if isinstance(data.get('touchque'), str) else json.dumps(data.get('touchque') or {})
35
+ alert = event.get('alert') or None
36
+ aps: Dict[str, Any] = {'sound': 'default', 'interruption-level': 'time-sensitive'}
37
+ if alert:
38
+ aps['alert'] = alert
39
+ return {
40
+ 'deviceId': event.get('deviceId'),
41
+ 'externalUsername': event.get('externalUsername'),
42
+ 'platform': 'android' if event.get('platform') == 'android' else 'ios',
43
+ 'pushToken': event.get('pushToken') if isinstance(event.get('pushToken'), str) else None,
44
+ 'requestId': event.get('requestId'),
45
+ 'expiresAt': event.get('expiresAt'),
46
+ 'kind': event['kind'] if event.get('kind') in ('notice', 'test') else 'approval', # test: a test push from the dashboard
47
+ 'alert': alert,
48
+ 'apns': {'aps': aps, 'touchque': touchque},
49
+ 'fcmData': {'touchque': touchque},
50
+ }
51
+
52
+
53
+ def is_mobile_push_expired(push: Dict[str, Any], now: Optional[datetime] = None) -> bool:
54
+ """True once a push is past its request's expiry (don't send it any more)."""
55
+ expires = push.get('expiresAt') if isinstance(push, dict) else None
56
+ if not expires:
57
+ return False
58
+ at = datetime.fromisoformat(str(expires).replace('Z', '+00:00'))
59
+ return at <= (now or datetime.now(timezone.utc))
60
+
61
+
62
+ class Mobile:
63
+ """Enrollment tokens, the devices inside your app and your Mobile SDK settings."""
64
+
65
+ def __init__(self, http: HttpClient):
66
+ self._http = http
67
+
68
+ def create_enrollment(self, external_username: str, replace: bool = False,
69
+ ttl_seconds: Optional[int] = None) -> Dict[str, Any]:
70
+ """A one-time enrollment token for a user you signed in. Whoever spends it becomes the
71
+ user's approver, so authenticate the user first. Raises TouchQueAPIException
72
+ ``already_enrolled`` (pass ``replace=True``) or ``account_frozen``."""
73
+ if not external_username:
74
+ raise TouchQueConfigException('mobile.create_enrollment() needs the user (external_username)')
75
+ body: Dict[str, Any] = {'externalUsername': external_username}
76
+ if replace:
77
+ body['replace'] = True
78
+ if ttl_seconds is not None:
79
+ body['ttlSeconds'] = ttl_seconds
80
+ return self._http.post('/mobile/enrollments', body)
81
+
82
+ def list_devices(self, external_username: Optional[str] = None, status: Optional[str] = None,
83
+ limit: Optional[int] = None, cursor: Optional[str] = None) -> Dict[str, Any]:
84
+ """The devices inside your app (default: active ones), newest first: ``{devices, nextCursor}``."""
85
+ params: Dict[str, Any] = {}
86
+ if external_username:
87
+ params['externalUsername'] = external_username
88
+ if status:
89
+ params['status'] = status
90
+ if limit is not None:
91
+ params['limit'] = limit
92
+ if cursor:
93
+ params['cursor'] = cursor
94
+ return self._http.get('/mobile/devices', params)
95
+
96
+ def get_device(self, device_id: str) -> Dict[str, Any]:
97
+ if not device_id:
98
+ raise TouchQueConfigException('mobile.get_device() needs the device id')
99
+ return self._http.get('/mobile/devices/' + quote(device_id, safe=''))['device']
100
+
101
+ def remove_device(self, device_id: str) -> Dict[str, Any]:
102
+ """Stops a device from approving (the user has no approver until you enrol again)."""
103
+ if not device_id:
104
+ raise TouchQueConfigException('mobile.remove_device() needs the device id')
105
+ return self._http.delete('/mobile/devices/' + quote(device_id, safe=''))
106
+
107
+ def test_push(self, device_id: str) -> Dict[str, Any]:
108
+ """A harmless push to the device, the way a request would reach it: ``via`` 'touchque' (your key for
109
+ its platform) or 'webhook' (MOBILE_PUSH, kind 'test'); ``ok``: APNs / FCM accepted it."""
110
+ if not device_id:
111
+ raise TouchQueConfigException('mobile.test_push() needs the device id')
112
+ return self._http.post('/mobile/devices/' + quote(device_id, safe='') + '/test-push', {})
113
+
114
+ def settings(self) -> Dict[str, Any]:
115
+ return self._http.get('/mobile/settings')['settings']
116
+
117
+ def update_settings(self, **changes: Any) -> Dict[str, Any]:
118
+ """Which apps may enrol, whether they must prove themselves, and your push keys: ``ios_app_ids``,
119
+ ``android_packages``, ``android_certs`` ({package: [SHA-256 of its signing certificate]}),
120
+ ``attestation`` ('off' | 'optional' | 'required'), ``enroll_ttl_seconds``,
121
+ ``apns`` ({keyId, teamId, topic, key: the .p8 text, production}; TouchQue then sends the iOS push
122
+ itself) and ``fcm`` ({serviceAccount: the JSON}; the Android push). None removes a key; the keys
123
+ are never shown back (``settings()['push']``)."""
124
+ names = {'ios_app_ids': 'iosAppIds', 'android_packages': 'androidPackages', 'android_certs': 'androidCerts',
125
+ 'attestation': 'attestation', 'enroll_ttl_seconds': 'enrollTtlSeconds',
126
+ 'apns': 'apns', 'fcm': 'fcm'}
127
+ unknown = set(changes) - set(names)
128
+ if unknown:
129
+ raise TouchQueConfigException('mobile.update_settings(): unknown setting ' + ', '.join(sorted(unknown)))
130
+ return self._http.put('/mobile/settings', {names[k]: v for k, v in changes.items()})['settings']
@@ -0,0 +1,138 @@
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import time
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from ..http_client import HttpClient
8
+ from ..exceptions import TouchQueAPIException
9
+
10
+ _CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
11
+
12
+
13
+ class Offline:
14
+ """Offline Sign — QR challenge / typed code approvals that work with the
15
+ phone offline (no internet on the device)."""
16
+
17
+ def __init__(self, http: HttpClient):
18
+ self._http = http
19
+
20
+ def challenge(
21
+ self,
22
+ external_username: str,
23
+ type: str = 'LOGIN',
24
+ details: Optional[Any] = None,
25
+ client_ip: Optional[str] = None,
26
+ user_agent: Optional[str] = None,
27
+ ttl_seconds: Optional[int] = None,
28
+ include_qr_image: bool = True,
29
+ request_id: Optional[str] = None,
30
+ require_number_match: bool = False,
31
+ ) -> Dict[str, Any]:
32
+ """Issues an offline QR challenge. Returns
33
+ ``{challengeId, qr, qrDataUrl, expiresAt, expiresInSeconds, totpAvailable}``
34
+ (+ ``challengeCode`` when number matching applies: print it under the QR; the
35
+ phone offers it among two decoys and the user taps the match).
36
+ ``details`` is REQUIRED for critical action types.
37
+
38
+ ``request_id`` is the push this QR is a fallback for: once the phone REJECTS it the QR
39
+ is dead (no new QR is issued, a code for the old one is refused with ``request_rejected``),
40
+ and a push with number matching makes the QR show the same number. Always pass it
41
+ when the QR follows a push.
42
+ """
43
+ body: Dict[str, Any] = {'externalUsername': external_username, 'type': type}
44
+ if details is not None:
45
+ body['details'] = details
46
+ if client_ip is not None:
47
+ body['clientIp'] = client_ip
48
+ if user_agent is not None:
49
+ body['userAgent'] = user_agent
50
+ if ttl_seconds is not None:
51
+ body['ttlSeconds'] = ttl_seconds
52
+ if not include_qr_image:
53
+ body['includeQrImage'] = False
54
+ if request_id is not None:
55
+ body['requestId'] = request_id
56
+ if require_number_match:
57
+ body['requireNumberMatch'] = True
58
+ return self._http.post('/offline/challenge', body)
59
+
60
+ def verify(self, challenge_id: str, code: str) -> Dict[str, Any]:
61
+ """Verifies the 7-character code shown on the phone. Never raises for
62
+ a wrong/expired/used code — check ``approved``; ``reason`` is one of
63
+ ``invalid_code | locked | expired | used | unknown_challenge |
64
+ request_rejected | too_many_failures | device_not_enrolled``.
65
+ """
66
+ return self._not_approved_as_result(
67
+ lambda: self._http.post('/offline/verify', {'challengeId': challenge_id, 'code': code})
68
+ )
69
+
70
+ def verify_totp(self, external_username: str, code: str, type: str = 'LOGIN',
71
+ client_ip: Optional[str] = None, request_id: Optional[str] = None) -> Dict[str, Any]:
72
+ """Verifies the rolling time-based code (no QR scan needed). Refused
73
+ for critical action types. ``request_id``: the push this sign-in belongs
74
+ to — a code is refused (``request_rejected``) once the phone rejected it."""
75
+ body: Dict[str, Any] = {'externalUsername': external_username, 'code': code, 'type': type}
76
+ if client_ip is not None:
77
+ body['clientIp'] = client_ip
78
+ if request_id is not None:
79
+ body['requestId'] = request_id
80
+ return self._not_approved_as_result(lambda: self._http.post('/offline/totp/verify', body))
81
+
82
+ def totp_verifiers(self, external_usernames: List[str], from_step: Optional[int] = None,
83
+ steps: Optional[int] = None) -> Dict[str, Any]:
84
+ """For checking typed time-based codes while your server cannot reach
85
+ TouchQue (an outage): salted, slow digests of each user's next codes,
86
+ never a key. Up to 1,000 users and 24,000 user-steps a call; ``steps`` 1-480 (default 480, four hours)
87
+ from ``from_step`` (default: the current 30-second step). Returns
88
+ ``{stepSeconds, fromStep, steps, notAfterStep, kdf, users: [{externalUsername,
89
+ fromStep, salt, digests}]}`` with only the users who can use time-based codes
90
+ now. Check a typed code with :meth:`match_totp_verifier`. Requires the
91
+ workspace policies ``offlineTotpEnabled`` and ``outageVerifiersEnabled``.
92
+ Fetch again every few minutes: removed and frozen users fall out."""
93
+ body: Dict[str, Any] = {'externalUsernames': list(external_usernames)}
94
+ if from_step is not None:
95
+ body['fromStep'] = from_step
96
+ if steps is not None:
97
+ body['steps'] = steps
98
+ return self._http.post('/offline/totp/verifiers', body)
99
+
100
+ @staticmethod
101
+ def match_totp_verifier(verifiers: Dict[str, Any], user: Dict[str, Any], code: str,
102
+ at: Optional[float] = None) -> Optional[int]:
103
+ """Checks a typed code against one user's digests from
104
+ :meth:`totp_verifiers` without calling TouchQue. Returns the step the
105
+ code belongs to (the current one or one either side), or ``None``.
106
+ Accept each step once: remember the step you accepted for the user and
107
+ refuse codes for it or an older one. ``at``: Unix time (default now)."""
108
+ given = ''.join(code.upper().split()).replace('-', '') if isinstance(code, str) else ''
109
+ if len(given) != 7 or any(c not in _CODE_ALPHABET for c in given):
110
+ return None
111
+ kdf = verifiers['kdf']
112
+ length = kdf['length']
113
+ now = int((time.time() if at is None else at) // verifiers['stepSeconds'])
114
+ salt = base64.b64decode(user['salt'])
115
+ digests = base64.b64decode(user['digests'])
116
+ for step in (now, now - 1, now + 1):
117
+ if step < user['fromStep'] or step > verifiers['notAfterStep']:
118
+ continue
119
+ i = (step - user['fromStep']) * 2 * length
120
+ if i + 2 * length > len(digests):
121
+ continue
122
+ got = hashlib.pbkdf2_hmac('sha256', given.encode(), salt + f':{step}'.encode(), kdf['iterations'], length)
123
+ if hmac.compare_digest(digests[i:i + length], got) or hmac.compare_digest(digests[i + length:i + 2 * length], got):
124
+ return step
125
+ return None
126
+
127
+ @staticmethod
128
+ def _not_approved_as_result(call) -> Dict[str, Any]:
129
+ try:
130
+ return call()
131
+ except TouchQueAPIException as err:
132
+ if err.status is not None and err.status < 500:
133
+ return {
134
+ 'approved': False,
135
+ 'reason': err.reason or err.code or (err.data or {}).get('error'),
136
+ 'attemptsLeft': err.attempts_left,
137
+ }
138
+ raise
@@ -0,0 +1,93 @@
1
+ """Pairing a browser by QR code: your page shows a QR code, the person scans it
2
+ with the TouchQue app, and the phone that scanned is the one asked to approve.
3
+
4
+ 1. Your page gets the thumbprint of its browser key (``@touchque/web``
5
+ ``browserKey.thumbprint()``) and sends it to your server.
6
+ 2. ``create(key_thumbprint, client_ip=..., user_agent=...)`` registers a
7
+ pairing and returns ``uri`` (``touchque://pair-browser?...``): show it as a
8
+ QR code. It can be scanned once, for ``ttl_seconds`` (default 120). The
9
+ nonce in it never leaves your server and the QR: TouchQue keeps only its hash.
10
+ 3. Poll ``get(pairing_id)``: ``scanned`` once a phone holding one of your users'
11
+ accounts read it (``externalUsername``, ``proximity``), ``confirmed`` once
12
+ the person went on, ``reported`` when they said someone told them to scan
13
+ it (their 2FA freezes).
14
+ 4. ``login.request(external_username, qr_pairing=pairing_id)`` asks that phone,
15
+ once, with number matching; the browser key's fingerprint is one of the rows
16
+ the phone signs. When your own checks say no, ``refuse(pairing_id, reason,
17
+ message)`` and the phone shows why.
18
+ """
19
+ import base64
20
+ import hashlib
21
+ import os as _os # create() takes an `os` argument, which would hide the module there
22
+ import re
23
+ from typing import Any, Dict, Optional
24
+ from urllib.parse import quote
25
+
26
+ from ..exceptions import TouchQueConfigException
27
+ from ..http_client import HttpClient
28
+
29
+ #: What ``refuse`` may tell the phone.
30
+ QR_PAIRING_REFUSAL_REASONS = ('policy', 'device_not_approved', 'too_many_browsers', 'rate_limited', 'no_account', 'other')
31
+
32
+ _B64URL_43 = re.compile(r'^[A-Za-z0-9_-]{43}$')
33
+
34
+
35
+ def _b64url(raw: bytes) -> str:
36
+ return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
37
+
38
+
39
+ def qr_pairing_uri(pairing_id: str, nonce: str, key_thumbprint: str) -> str:
40
+ """What the QR code says: ``touchque://pair-browser?v=1&p=<pairing>&n=<nonce>&k=<key thumbprint>``."""
41
+ q = lambda v: quote(v, safe='') # noqa: E731
42
+ return f'touchque://pair-browser?v=1&p={q(pairing_id)}&n={q(nonce)}&k={q(key_thumbprint)}'
43
+
44
+
45
+ def _path(pairing_id: str, method: str, rest: str = '') -> str:
46
+ if not isinstance(pairing_id, str) or not pairing_id:
47
+ raise TouchQueConfigException(f'qr_pairings.{method}() needs the pairing_id.')
48
+ return f'/qr-pairings/{quote(pairing_id, safe="")}{rest}'
49
+
50
+
51
+ class QrPairings:
52
+ """Browser pairings by QR code (``POST /qr-pairings``)."""
53
+
54
+ def __init__(self, http: HttpClient):
55
+ self._http = http
56
+
57
+ def create(self, key_thumbprint: str, client_ip: Optional[str] = None, user_agent: Optional[str] = None,
58
+ browser: Optional[str] = None, os_name: Optional[str] = None, ttl_seconds: Optional[int] = None,
59
+ os: Optional[str] = None) -> Dict[str, Any]:
60
+ """Registers a pairing for one browser: ``{pairingId, expiresAt, nonce, uri}``; show ``uri`` as a QR code.
61
+ ``os=`` is the same as ``os_name=`` (the name the other SDKs use)."""
62
+ if os_name is None:
63
+ os_name = os
64
+ if not isinstance(key_thumbprint, str) or not _B64URL_43.match(key_thumbprint):
65
+ raise TouchQueConfigException('qr_pairings.create() needs key_thumbprint: the RFC 7638 thumbprint of the browser key, base64url (43 characters).')
66
+ if ttl_seconds is not None and (not isinstance(ttl_seconds, int) or isinstance(ttl_seconds, bool) or not 30 <= ttl_seconds <= 300):
67
+ raise TouchQueConfigException('qr_pairings.create(): ttl_seconds must be 30 to 300.')
68
+ nonce = _b64url(_os.urandom(32))
69
+ body: Dict[str, Any] = {'nonceHash': _b64url(hashlib.sha256(nonce.encode('utf-8')).digest()), 'keyThumbprint': key_thumbprint}
70
+ for key, value in (('clientIp', client_ip), ('userAgent', user_agent), ('browser', browser), ('os', os_name), ('ttlSeconds', ttl_seconds)):
71
+ if value is not None and value != '':
72
+ body[key] = value
73
+ created = self._http.post('/qr-pairings', body)
74
+ return {**created, 'nonce': nonce, 'uri': qr_pairing_uri(created['pairingId'], nonce, key_thumbprint)}
75
+
76
+ def get(self, pairing_id: str) -> Dict[str, Any]:
77
+ """Where a pairing stands; once scanned, who scanned it and how near the phone was."""
78
+ return self._http.get(_path(pairing_id, 'get'))
79
+
80
+ def refuse(self, pairing_id: str, reason: str, message: str, case_code: Optional[str] = None,
81
+ device_code: Optional[str] = None) -> Dict[str, Any]:
82
+ """Your own checks refused a scanned pairing; the phone shows ``message``.
83
+ Only a ``scanned`` or ``confirmed`` pairing can be refused (409 ``not_open``)."""
84
+ if reason not in QR_PAIRING_REFUSAL_REASONS:
85
+ raise TouchQueConfigException(f'qr_pairings.refuse(): reason must be one of {", ".join(QR_PAIRING_REFUSAL_REASONS)}.')
86
+ if not isinstance(message, str) or not message.strip():
87
+ raise TouchQueConfigException('qr_pairings.refuse() needs message: what the phone shows (up to 300 characters).')
88
+ body: Dict[str, Any] = {'reason': reason, 'message': message}
89
+ if case_code:
90
+ body['caseCode'] = case_code
91
+ if device_code:
92
+ body['deviceCode'] = device_code
93
+ return self._http.post(_path(pairing_id, 'refuse', '/outcome'), body)