touchque-authenticator 4.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. touchque/__init__.py +148 -0
  2. touchque/agent.py +314 -0
  3. touchque/agent_card.py +190 -0
  4. touchque/agent_frameworks.py +215 -0
  5. touchque/approval_proof.py +104 -0
  6. touchque/approval_token.py +134 -0
  7. touchque/client.py +105 -0
  8. touchque/code_reply.py +75 -0
  9. touchque/config.py +56 -0
  10. touchque/contrib/__init__.py +4 -0
  11. touchque/contrib/django.py +175 -0
  12. touchque/contrib/fastapi.py +208 -0
  13. touchque/contrib/flask.py +154 -0
  14. touchque/cross_app_access.py +222 -0
  15. touchque/exceptions.py +170 -0
  16. touchque/guard.py +290 -0
  17. touchque/hosted_login.py +474 -0
  18. touchque/http_client.py +156 -0
  19. touchque/mcp_auth.py +256 -0
  20. touchque/my_account.py +173 -0
  21. touchque/org_agent.py +262 -0
  22. touchque/rate_limiter.py +56 -0
  23. touchque/relay.py +643 -0
  24. touchque/resources/__init__.py +1 -0
  25. touchque/resources/action_types.py +52 -0
  26. touchque/resources/actions.py +234 -0
  27. touchque/resources/agents.py +236 -0
  28. touchque/resources/assist.py +115 -0
  29. touchque/resources/auth.py +65 -0
  30. touchque/resources/device_risk.py +174 -0
  31. touchque/resources/identity.py +268 -0
  32. touchque/resources/login.py +296 -0
  33. touchque/resources/mobile.py +130 -0
  34. touchque/resources/offline.py +138 -0
  35. touchque/resources/qr_pairings.py +93 -0
  36. touchque/resources/recovery.py +118 -0
  37. touchque/resources/risk.py +141 -0
  38. touchque/resources/safe_guard.py +99 -0
  39. touchque/resources/webauthn.py +115 -0
  40. touchque/resources/webhook.py +258 -0
  41. touchque/safe_guard.py +308 -0
  42. touchque/security_events.py +205 -0
  43. touchque/steps.py +239 -0
  44. touchque_authenticator-4.2.0.dist-info/METADATA +630 -0
  45. touchque_authenticator-4.2.0.dist-info/RECORD +48 -0
  46. touchque_authenticator-4.2.0.dist-info/WHEEL +5 -0
  47. touchque_authenticator-4.2.0.dist-info/licenses/LICENSE +21 -0
  48. touchque_authenticator-4.2.0.dist-info/top_level.txt +1 -0
touchque/relay.py ADDED
@@ -0,0 +1,643 @@
1
+ """
2
+ Framework-agnostic relay: the routes every partner backend needs for classic
3
+ push 2FA, offline codes and passkeys, so you don't hand-write them. The Flask,
4
+ FastAPI and Django adapters in ``touchque.contrib`` are thin wrappers over
5
+ ``RelayHandler.handle()``. Same routes and behaviour as the Node SDK's
6
+ ``touchqueRouter``.
7
+
8
+ Routes (relative to wherever you mount the relay):
9
+
10
+ POST /enroll/start (auth required) -> generate_secret response
11
+ GET /enroll/status (auth required) -> {linked, used, deviceId}
12
+ POST /login (pre-auth) -> 202 {touchque: step, token} until approved
13
+ POST /offline/challenge (pre-auth, body.externalUsername) -> QR for a phone with no internet
14
+ POST /offline/verify (pre-auth, body.challengeId/code)
15
+ POST /offline/totp (pre-auth, body.externalUsername/code) -> time-based code fallback
16
+ POST /passkey/authenticate/options (pre-auth, body.email; none = any passkey, for autofill)
17
+ POST /passkey/authenticate/verify (pre-auth, body.attemptId/response)
18
+ POST /passkey/login/options (pre-auth, body.requestId - approve a pending 2FA request)
19
+ POST /passkey/login/verify (pre-auth, body.requestId/response)
20
+ POST /passkey/register/options (auth required)
21
+ POST /passkey/register/verify (auth required)
22
+ GET /passkey/credentials (auth required)
23
+ DELETE /passkey/credentials/<id> (auth required)
24
+
25
+ "Auth required" routes answer 401 ``UNAUTHORIZED_FOR_2FA`` when
26
+ ``get_user_id(request)`` returns nothing.
27
+
28
+ Pre-auth routes are rate limited per IP and per username by default
29
+ (``rate_limit``), and TouchQue itself caps approval pushes per user. Set
30
+ ``get_login_user`` so POST /login and the offline routes only work for a user
31
+ who already passed YOUR first factor (password step): then nobody can make
32
+ TouchQue push to, or lock out, an arbitrary user through your server. The
33
+ offline routes are OFF until you do (or explicitly opt out with
34
+ ``allow_offline_without_first_factor=True``): with the username taken from
35
+ the body, anyone could burn a user's offline attempts and lock them out.
36
+ """
37
+ import inspect
38
+ import logging
39
+ import re
40
+ from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Union
41
+
42
+ from .exceptions import (
43
+ TouchQueAPIException, TouchQuePasskeyRequiredError, TouchQueRejectedException, TouchQueTimeoutException,
44
+ )
45
+ from .guard import SilentSignIn, guard_input_from_headers, is_status_poll, run_guard, silent_input_from_headers
46
+ from .rate_limiter import FixedWindowLimiter
47
+
48
+ # A 1.5 s poll for a 2-minute push is 80 requests: one attempt's worth of polls.
49
+ POLLS_PER_ATTEMPT = 10
50
+ #: Reserved action: the approval on the phone that unlocks adding a passkey.
51
+ PASSKEY_ENROLL_ACTION = 'PASSKEY_ENROLL'
52
+
53
+ if TYPE_CHECKING:
54
+ from .client import TouchQue
55
+
56
+ logger = logging.getLogger('touchque')
57
+
58
+ #: Every (method, sub-path) the relay answers. ``<id>`` is one path segment.
59
+ RELAY_ROUTES = (
60
+ ('POST', '/enroll/start'),
61
+ ('GET', '/enroll/status'),
62
+ ('POST', '/login'),
63
+ ('POST', '/offline/challenge'),
64
+ ('POST', '/offline/verify'),
65
+ ('POST', '/offline/totp'),
66
+ ('POST', '/passkey/authenticate/options'),
67
+ ('POST', '/passkey/authenticate/verify'),
68
+ ('POST', '/passkey/login/options'),
69
+ ('POST', '/passkey/login/verify'),
70
+ ('POST', '/passkey/register/options'),
71
+ ('POST', '/passkey/register/verify'),
72
+ ('GET', '/passkey/credentials'),
73
+ ('DELETE', '/passkey/credentials/<id>'),
74
+ )
75
+
76
+ _CREDENTIAL_PATH = re.compile(r'^/passkey/credentials/([^/]+)$')
77
+
78
+ _warned_pre_auth = False
79
+
80
+
81
+ class RelayResponse:
82
+ """What the adapter should send: ``status``, a JSON-serialisable ``body``
83
+ and extra ``headers`` (``Retry-After``, ``Cache-Control``)."""
84
+
85
+ __slots__ = ('status', 'body', 'headers')
86
+
87
+ def __init__(self, status: int, body: Any, headers: Optional[Dict[str, str]] = None):
88
+ self.status = status
89
+ self.body = body
90
+ self.headers = headers or {}
91
+
92
+ def __repr__(self) -> str:
93
+ return 'RelayResponse(status=%r, body=%r, headers=%r)' % (self.status, self.body, self.headers)
94
+
95
+
96
+ class _Done(BaseException):
97
+ """Internal: carries an early response out of a helper. A BaseException so the
98
+ routes' ``except Exception`` error mapping never swallows it."""
99
+
100
+ def __init__(self, response: RelayResponse):
101
+ super().__init__()
102
+ self.response = response
103
+
104
+
105
+ def _json(body: Any, status: int = 200, headers: Optional[Dict[str, str]] = None) -> RelayResponse:
106
+ return RelayResponse(status, body, headers)
107
+
108
+
109
+ def _field(body: Any, name: str) -> Any:
110
+ return body.get(name) if isinstance(body, dict) else None
111
+
112
+
113
+ def _non_empty_str(value: Any) -> Optional[str]:
114
+ return value if isinstance(value, str) and value else None
115
+
116
+
117
+ def _compact(d: Dict[str, Any]) -> Dict[str, Any]:
118
+ """Drops ``None`` values (JSON.stringify drops ``undefined`` the same way)."""
119
+ return {k: v for k, v in d.items() if v is not None}
120
+
121
+
122
+ def _auth_result_error(error: BaseException) -> RelayResponse:
123
+ """Same status-code mapping as the step-up guard adapters - kept in sync."""
124
+ if isinstance(error, TouchQueRejectedException):
125
+ return _json({'error': '2FA_REJECTED', 'message': 'User rejected the 2FA request on their device.'}, 403)
126
+ if isinstance(error, TouchQuePasskeyRequiredError):
127
+ return _json({'error': 'PASSKEY_REQUIRED', 'requestId': error.request_id,
128
+ 'message': 'Approve this sign-in with your passkey.'}, 409)
129
+ if isinstance(error, TouchQueTimeoutException):
130
+ return _json({'error': '2FA_TIMEOUT', 'message': 'User did not respond to the 2FA request in time.'}, 408)
131
+ # A real API error (e.g. 404 "no linked device for this user") still carries a useful
132
+ # client-range status + message - surface it. Only a network failure or an unexpected
133
+ # error falls through to 503.
134
+ status = getattr(error, 'status', None)
135
+ if isinstance(status, int) and not isinstance(status, bool) and status < 500:
136
+ data = getattr(error, 'data', None)
137
+ data = data if isinstance(data, dict) else {}
138
+ return _json({'error': data.get('error') or data.get('message') or str(error) or 'request_failed'}, status)
139
+ logger.error('[TouchQue Relay Error] %s: %s', type(error).__name__, error)
140
+ return _json({'error': '2FA_UNAVAILABLE', 'message': 'The 2FA verification service is temporarily unavailable.'},
141
+ 503)
142
+
143
+
144
+ def _relay_error(error: BaseException) -> RelayResponse:
145
+ """For plain relay routes: a client-range TouchQue status is forwarded with its
146
+ machine-readable code only - never TouchQue's internal message text - anything
147
+ else becomes 502."""
148
+ if isinstance(error, TouchQueAPIException) and error.status is not None and error.status < 500:
149
+ data = error.data if isinstance(error.data, dict) else {}
150
+ return _json({'error': error.code or data.get('error') or 'request_failed'}, error.status)
151
+ return _json({'error': 'relay_error'}, 502)
152
+
153
+
154
+ class RelayHandler:
155
+ """
156
+ The relay routes, independent of any web framework.
157
+
158
+ ``client``: a ``TouchQue`` instance (default: ``get_default_client()``).
159
+
160
+ ``get_user_id(request)``: REQUIRED. The signed-in user's identifier for
161
+ the "auth required" routes (enrollment, passkey registration and
162
+ management). Return ``None`` when nobody is signed in - the route answers 401.
163
+
164
+ ``get_login_user(request)``: STRONGLY RECOMMENDED. The user who already
165
+ passed your first factor (e.g. the password step stored in the session).
166
+ When set, POST /login, /offline/challenge and /offline/totp act for this
167
+ user and IGNORE any username in the request body; when it returns nothing
168
+ they answer 401 ``FIRST_FACTOR_REQUIRED``.
169
+
170
+ ``rate_limit``: built-in limits for the pre-auth routes, per client IP and
171
+ per username, per route. On by default (20 per IP and 5 per username per
172
+ minute). A dict with ``window_ms`` / ``per_ip`` / ``per_user`` overrides
173
+ them; ``False`` turns the limiter off (if you already limit in front).
174
+ The adapters use the framework's client address - make sure it is the
175
+ real client behind a proxy (e.g. werkzeug ``ProxyFix``).
176
+
177
+ ``allow_offline_without_first_factor``: the offline routes answer 403
178
+ ``OFFLINE_REQUIRES_FIRST_FACTOR`` unless ``get_login_user`` is set. Set
179
+ this to ``True`` only if they really should trust the username in the body.
180
+
181
+ ``action_type``: action type for POST /login and the offline routes. Default ``'LOGIN'``.
182
+
183
+ ``get_details(request)``: optional transaction context shown on the
184
+ approval screen. Build it from server-side state - the request body is
185
+ attacker-controlled.
186
+
187
+ ``silent_sign_in``: silent sign-ins for POST /login (default off). When
188
+ on, a new sign-in whose browser sent its device-bound key proof
189
+ (``X-TouchQue-Browser``, added by ``@touchque/web`` with
190
+ ``browserKey: true``) first asks TouchQue whether anything about it is
191
+ new. If nothing is, it succeeds at once with no push (``on_authenticated``
192
+ gets ``via: 'silent'``; the answer's ``assurance['method']`` is
193
+ ``'silent'``). Otherwise, and on any error, the usual push starts and its
194
+ approval makes the browser known. Set ``get_login_user`` too: the user
195
+ must have passed your password step. ``{'max_age_seconds': n}`` sets your
196
+ own limit on how long ago the last phone approval in that browser may be.
197
+
198
+ ``on_authenticated(request, response, info)``: called right before the
199
+ relay sends its own success response for POST /login (once approved),
200
+ /offline/verify, /offline/totp, /passkey/authenticate/verify and
201
+ /passkey/login/verify. ``info`` is ``{"externalUsername", "requestId"
202
+ (when known), "via": "login" | "passkey" | "offline" | "offline-totp" |
203
+ "silent"}``. Open your session here: mutate ``request`` (session) or
204
+ ``response`` (``set_cookie``); do not build a response yourself.
205
+
206
+ Callbacks receive the framework's native request object.
207
+ """
208
+
209
+ def __init__(
210
+ self,
211
+ client: Optional['TouchQue'] = None,
212
+ get_user_id: Optional[Callable[[Any], Optional[str]]] = None,
213
+ get_login_user: Optional[Callable[[Any], Optional[str]]] = None,
214
+ rate_limit: Union[Dict[str, int], bool, None] = None,
215
+ allow_offline_without_first_factor: bool = False,
216
+ action_type: str = 'LOGIN',
217
+ get_details: Optional[Callable[[Any], Any]] = None,
218
+ on_authenticated: Optional[Callable[[Any, Any, Dict[str, Any]], Any]] = None,
219
+ silent_sign_in: SilentSignIn = False,
220
+ ):
221
+ global _warned_pre_auth
222
+ if get_user_id is None:
223
+ raise TypeError('RelayHandler: get_user_id is required')
224
+ self._client = client
225
+ self.get_user_id = get_user_id
226
+ self.get_login_user = get_login_user
227
+ self.action_type = action_type
228
+ self.get_details = get_details
229
+ self.on_authenticated = on_authenticated
230
+ self.silent_sign_in = silent_sign_in
231
+ if rate_limit is False:
232
+ self.limiter = None # type: Optional[FixedWindowLimiter]
233
+ else:
234
+ opts = rate_limit if isinstance(rate_limit, dict) else {}
235
+ self.limiter = FixedWindowLimiter(
236
+ window_ms=opts.get('window_ms'), per_ip=opts.get('per_ip'), per_user=opts.get('per_user'))
237
+ self.offline_enabled = bool(get_login_user) or allow_offline_without_first_factor is True
238
+
239
+ if not get_login_user and not _warned_pre_auth:
240
+ _warned_pre_auth = True
241
+ logger.warning(
242
+ '[TouchQue] RelayHandler: POST /login takes the username from the request body%s. Set '
243
+ '`get_login_user` to bind them to your first factor (password step) so they cannot be used '
244
+ 'against arbitrary users.',
245
+ ', and so do the offline routes' if self.offline_enabled
246
+ else '; the offline routes are disabled until you set it')
247
+
248
+ @property
249
+ def client(self) -> 'TouchQue':
250
+ if self._client is None:
251
+ from .client import get_default_client
252
+ self._client = get_default_client()
253
+ return self._client
254
+
255
+ # ── entry point ─────────────────────────────────────────────────────
256
+ def handle(
257
+ self,
258
+ method: str,
259
+ path: str,
260
+ body: Any = None,
261
+ ip: Optional[str] = None,
262
+ user_agent: Optional[str] = None,
263
+ get_header: Optional[Callable[[str], Optional[str]]] = None,
264
+ request: Any = None,
265
+ response: Any = None,
266
+ run_async: Optional[Callable[[Any], Any]] = None,
267
+ ) -> Optional[RelayResponse]:
268
+ """Runs one relay route.
269
+
270
+ ``method``/``path``: HTTP method and the sub-path below the mount
271
+ point (e.g. ``'/login'``). ``body``: the parsed JSON body (or None).
272
+ ``get_header``: case-insensitive header getter. ``request`` /
273
+ ``response``: the framework's native objects, passed to the callbacks
274
+ as-is. ``run_async``: how to resolve an awaitable returned by a
275
+ callback (the FastAPI adapter supplies it).
276
+
277
+ Returns the response to send, or ``None`` when no route matches.
278
+ """
279
+ ctx = _Ctx(self, (method or '').upper(), _norm_path(path), body, ip, user_agent,
280
+ get_header or (lambda name: None), request, response, run_async)
281
+ try:
282
+ return ctx.dispatch()
283
+ except _Done as done:
284
+ return done.response
285
+
286
+
287
+ def _norm_path(path: str) -> str:
288
+ return '/' + (path or '').strip('/')
289
+
290
+
291
+ class _Ctx:
292
+ """One request through the relay."""
293
+
294
+ def __init__(self, h: RelayHandler, method: str, path: str, body: Any, ip: Optional[str],
295
+ user_agent: Optional[str], get_header: Callable[[str], Optional[str]], request: Any,
296
+ response: Any, run_async: Optional[Callable[[Any], Any]]):
297
+ self.h = h
298
+ self.method = method
299
+ self.path = path
300
+ self.body = body
301
+ self.ip = ip
302
+ self.user_agent = user_agent
303
+ self.get_header = get_header
304
+ self.request = request
305
+ self.response = response
306
+ self.run_async = run_async
307
+
308
+ # ── helpers ─────────────────────────────────────────────────────────
309
+ def call(self, fn: Callable, *args: Any) -> Any:
310
+ result = fn(*args)
311
+ if inspect.isawaitable(result):
312
+ if self.run_async is None:
313
+ if inspect.iscoroutine(result):
314
+ result.close()
315
+ raise TypeError('TouchQue relay: an async callback needs an async adapter (touchque.contrib.fastapi)')
316
+ result = self.run_async(result)
317
+ return result
318
+
319
+ def require_user_id(self) -> str:
320
+ user_id = self.call(self.h.get_user_id, self.request)
321
+ if not user_id:
322
+ raise _Done(_json({
323
+ 'error': 'UNAUTHORIZED_FOR_2FA',
324
+ 'message': 'Could not find user ID in request. Put your own auth in front of this relay.',
325
+ }, 401))
326
+ return user_id
327
+
328
+ def offline_allowed(self) -> None:
329
+ if not self.h.offline_enabled:
330
+ raise _Done(_json({
331
+ 'error': 'OFFLINE_REQUIRES_FIRST_FACTOR',
332
+ 'message': 'Offline sign-in is disabled: configure get_login_user (or '
333
+ 'allow_offline_without_first_factor) on the TouchQue relay.',
334
+ }, 403))
335
+
336
+ def allow(self, route: str, user: Optional[str] = None, ip_factor: int = 1) -> None:
337
+ """Answers 429 when this IP or this user is over the limit for ``route``."""
338
+ limiter = self.h.limiter
339
+ if limiter is None:
340
+ return
341
+ ip_wait = limiter.hit('ip|%s|%s' % (route, self.ip or 'unknown'), limiter.per_ip * ip_factor)
342
+ user_wait = limiter.hit('user|%s|%s' % (route, user), limiter.per_user) if user else 0
343
+ wait = max(ip_wait, user_wait)
344
+ if not wait:
345
+ return
346
+ raise _Done(_json({'error': 'RATE_LIMITED', 'message': 'Too many attempts. Try again later.',
347
+ 'retryAfter': wait}, 429, {'Retry-After': str(wait)}))
348
+
349
+ def expected_login_user(self) -> Optional[str]:
350
+ return self.call(self.h.get_login_user, self.request)
351
+
352
+ def login_user(self, body_field: str) -> str:
353
+ """Who a pre-auth route acts for: the first-factor user when configured, otherwise the typed username."""
354
+ if self.h.get_login_user:
355
+ u = self.expected_login_user()
356
+ if not u:
357
+ raise _Done(_json({'error': 'FIRST_FACTOR_REQUIRED', 'message': 'Sign in with your password first.'},
358
+ 401))
359
+ return str(u).lower()
360
+ typed = str(_field(self.body, body_field) or '').lower()
361
+ if not typed:
362
+ raise _Done(_json({'error': 'externalUsername_required'}, 400))
363
+ return typed
364
+
365
+ def details(self) -> Any:
366
+ return self.call(self.h.get_details, self.request) if self.h.get_details else None
367
+
368
+ def authenticated(self, info: Dict[str, Any]) -> None:
369
+ if self.h.on_authenticated:
370
+ self.call(self.h.on_authenticated, self.request, self.response, info)
371
+
372
+ def same_user(self, expected: Any, actual: str) -> bool:
373
+ return bool(expected) and str(expected).lower() == actual.lower()
374
+
375
+ # ── routing ─────────────────────────────────────────────────────────
376
+ def dispatch(self) -> Optional[RelayResponse]:
377
+ routes = {
378
+ ('POST', '/enroll/start'): self.enroll_start,
379
+ ('GET', '/enroll/status'): self.enroll_status,
380
+ ('POST', '/login'): self.login,
381
+ ('POST', '/offline/challenge'): self.offline_challenge,
382
+ ('POST', '/offline/verify'): self.offline_verify,
383
+ ('POST', '/offline/totp'): self.offline_totp,
384
+ ('POST', '/passkey/authenticate/options'): self.passkey_authenticate_options,
385
+ ('POST', '/passkey/authenticate/verify'): self.passkey_authenticate_verify,
386
+ ('POST', '/passkey/login/options'): self.passkey_login_options,
387
+ ('POST', '/passkey/login/verify'): self.passkey_login_verify,
388
+ ('POST', '/passkey/register/options'): self.passkey_register_options,
389
+ ('POST', '/passkey/register/verify'): self.passkey_register_verify,
390
+ ('GET', '/passkey/credentials'): self.passkey_credentials,
391
+ } # type: Dict[Tuple[str, str], Callable[[], RelayResponse]]
392
+ route = routes.get((self.method, self.path))
393
+ if route is not None:
394
+ return route()
395
+ m = _CREDENTIAL_PATH.match(self.path)
396
+ if m and self.method == 'DELETE':
397
+ return self.passkey_credential_delete(m.group(1))
398
+ return None
399
+
400
+ # ── classic push 2FA: enrollment ────────────────────────────────────
401
+ def enroll_start(self) -> RelayResponse:
402
+ user_id = self.require_user_id()
403
+ tq = self.h.client
404
+ try:
405
+ return _json(tq.auth.generate_secret(user_id))
406
+ except Exception as error: # noqa: BLE001 - mapped below, like the Node router
407
+ # 409: either a QR was issued and not scanned yet (safe to issue a new one - no
408
+ # phone to lose), or a phone is ALREADY linked. Replacing a linked phone must never
409
+ # happen from a plain session: that is exactly how an attacker with a stolen
410
+ # password would swap in their own phone.
411
+ if getattr(error, 'status', None) == 409:
412
+ try:
413
+ current = tq.auth.get_user(user_id)
414
+ if current.get('used') or current.get('deviceId'):
415
+ return _json({'error': 'already_linked',
416
+ 'message': 'A phone is already linked to this account.'}, 409)
417
+ return _json(tq.auth.reset_secret(user_id))
418
+ except Exception as retry_error: # noqa: BLE001
419
+ return _relay_error(retry_error)
420
+ return _relay_error(error)
421
+
422
+ def enroll_status(self) -> RelayResponse:
423
+ user_id = self.require_user_id()
424
+ try:
425
+ u = self.h.client.auth.get_user(user_id)
426
+ out = {'linked': bool(u.get('used') and u.get('deviceId'))} # type: Dict[str, Any]
427
+ for key in ('used', 'deviceId'):
428
+ if key in u:
429
+ out[key] = u[key]
430
+ return _json(out)
431
+ except Exception as error: # noqa: BLE001
432
+ if getattr(error, 'status', None) == 404:
433
+ return _json({'linked': False, 'used': False, 'deviceId': None})
434
+ return _relay_error(error)
435
+
436
+ # ── classic push 2FA: login ─────────────────────────────────────────
437
+ def login(self) -> RelayResponse:
438
+ external_username = self.login_user('externalUsername')
439
+ details = self.details()
440
+ headers_in = guard_input_from_headers(self.get_header)
441
+ # Polling an approval already sent is not a new attempt: it has its own,
442
+ # larger per-IP budget and never uses the user's push budget.
443
+ if is_status_poll(self.h.client, external_username, self.h.action_type, details=details, **headers_in):
444
+ self.allow('login-poll', ip_factor=POLLS_PER_ATTEMPT)
445
+ else:
446
+ self.allow('login', external_username)
447
+ # Same two-phase contract as the step-up guard: 202 with the step (matching number /
448
+ # first-time QR / offline QR) until approved; the page re-sends with X-TouchQue-Token.
449
+ result = run_guard(
450
+ self.h.client, external_username, self.h.action_type,
451
+ details=details, ip=self.ip, user_agent=self.user_agent,
452
+ # Pre-auth: a typed username proves nothing, so no linking QR unless
453
+ # get_login_user ties this route to the password step.
454
+ enroll=bool(self.h.get_login_user),
455
+ **headers_in,
456
+ **silent_input_from_headers(self.h.silent_sign_in, self.get_header)
457
+ )
458
+ approved = result.get('approved')
459
+ if not approved:
460
+ headers = {}
461
+ retry = (result['body'].get('touchque') or {}).get('retryAfter')
462
+ if retry:
463
+ headers['Retry-After'] = str(retry)
464
+ headers['Cache-Control'] = 'no-store'
465
+ return _json(result['body'], result['status'], headers)
466
+ try:
467
+ confirmed = approved.get('confirmedVia')
468
+ via = 'offline-totp' if confirmed == 'OFFLINE_TOTP' else 'offline' if confirmed == 'OFFLINE_CODE' \
469
+ else 'silent' if confirmed == 'SILENT' else 'login'
470
+ self.authenticated({'externalUsername': external_username, 'requestId': approved.get('requestId'),
471
+ 'via': via})
472
+ return _json(_compact({'status': 'success', 'requestId': approved.get('requestId'),
473
+ 'assurance': approved.get('assurance')}))
474
+ except Exception as error: # noqa: BLE001
475
+ return _auth_result_error(error)
476
+
477
+ # ── offline sign: QR challenge / typed code ─────────────────────────
478
+ # The username used for on_authenticated is the one TouchQue returns for the
479
+ # challenge/code, never the one in the request body.
480
+ def offline_challenge(self) -> RelayResponse:
481
+ self.offline_allowed()
482
+ external_username = self.login_user('externalUsername')
483
+ self.allow('offline-challenge', external_username)
484
+ try:
485
+ # `requestId` (the push this QR follows) makes a phone-side rejection kill the QR
486
+ # and carries the number.
487
+ c = self.h.client.offline.challenge(
488
+ external_username, type=self.h.action_type, client_ip=self.ip, user_agent=self.user_agent,
489
+ details=self.details(), request_id=_non_empty_str(_field(self.body, 'requestId')),
490
+ )
491
+ out = {k: c[k] for k in ('challengeId', 'qr', 'qrDataUrl', 'expiresAt', 'expiresInSeconds',
492
+ 'totpAvailable') if k in c}
493
+ if c.get('challengeCode'):
494
+ out['challengeCode'] = c['challengeCode']
495
+ return _json(out)
496
+ except Exception as error: # noqa: BLE001
497
+ return _relay_error(error)
498
+
499
+ def offline_verify(self) -> RelayResponse:
500
+ self.offline_allowed()
501
+ self.allow('offline-verify')
502
+ try:
503
+ r = self.h.client.offline.verify(str(_field(self.body, 'challengeId') or ''),
504
+ str(_field(self.body, 'code') or ''))
505
+ if not r.get('approved') or not r.get('externalUsername'):
506
+ return _json(_compact({'approved': False, 'reason': r.get('reason'),
507
+ 'attemptsLeft': r.get('attemptsLeft')}),
508
+ 401 if r.get('reason') == 'invalid_code' else 400)
509
+ # With a first factor, the code must belong to the user who passed it.
510
+ if self.h.get_login_user:
511
+ if not self.same_user(self.expected_login_user(), r['externalUsername']):
512
+ return _json({'approved': False, 'reason': 'invalid_code'}, 401)
513
+ self.authenticated({'externalUsername': r['externalUsername'], 'requestId': r.get('challengeId'),
514
+ 'via': 'offline'})
515
+ return _json(_compact({'status': 'success', 'approved': True, 'requestId': r.get('challengeId')}))
516
+ except Exception as error: # noqa: BLE001
517
+ return _relay_error(error)
518
+
519
+ def offline_totp(self) -> RelayResponse:
520
+ self.offline_allowed()
521
+ external_username = self.login_user('externalUsername')
522
+ self.allow('offline-totp', external_username)
523
+ try:
524
+ r = self.h.client.offline.verify_totp(
525
+ external_username, str(_field(self.body, 'code') or ''), type=self.h.action_type,
526
+ client_ip=self.ip, request_id=_non_empty_str(_field(self.body, 'requestId')),
527
+ )
528
+ if not r.get('approved') or not r.get('externalUsername'):
529
+ return _json(_compact({'approved': False, 'reason': r.get('reason')}),
530
+ 401 if r.get('reason') == 'invalid_code' else 400)
531
+ self.authenticated({'externalUsername': r['externalUsername'], 'via': 'offline-totp'})
532
+ return _json({'status': 'success', 'approved': True})
533
+ except Exception as error: # noqa: BLE001
534
+ return _relay_error(error)
535
+
536
+ # ── passkey: passwordless sign-in ───────────────────────────────────
537
+ def passkey_authenticate_options(self) -> RelayResponse:
538
+ email = str(_field(self.body, 'email') or '').lower()
539
+ self.allow('passkey-options', email or None)
540
+ try:
541
+ return _json(self.h.client.webauthn.primary_options(email or None))
542
+ except Exception as error: # noqa: BLE001
543
+ return _relay_error(error)
544
+
545
+ def passkey_authenticate_verify(self) -> RelayResponse:
546
+ self.allow('passkey-verify')
547
+ try:
548
+ r = self.h.client.webauthn.primary_verify(_field(self.body, 'attemptId'), _field(self.body, 'response'))
549
+ if r.get('success'):
550
+ self.authenticated({'externalUsername': r.get('externalUsername'), 'requestId': r.get('requestId'),
551
+ 'via': 'passkey'})
552
+ return _json(_compact({'status': 'success', 'requestId': r.get('requestId')}))
553
+ if r.get('requiresStepUp'):
554
+ return _json({'requiresStepUp': True})
555
+ return _json({'error': 'verification_failed'}, 401)
556
+ except Exception as error: # noqa: BLE001
557
+ return _relay_error(error)
558
+
559
+ # ── passkey: approve an already-pending 2FA request (second factor) ─
560
+ def passkey_login_options(self) -> RelayResponse:
561
+ self.allow('passkey-approve')
562
+ try:
563
+ return _json(self.h.client.webauthn.authenticate_options(_field(self.body, 'requestId')))
564
+ except Exception as error: # noqa: BLE001
565
+ return _relay_error(error)
566
+
567
+ def passkey_login_verify(self) -> RelayResponse:
568
+ self.allow('passkey-approve')
569
+ try:
570
+ r = self.h.client.webauthn.authenticate_verify(_field(self.body, 'requestId'),
571
+ _field(self.body, 'response'))
572
+ approved_user = r.get('externalUsername')
573
+ # Open the session only for the user TouchQue says was approved. A name sent by the
574
+ # browser is never trusted: anyone could approve their own request and claim to be
575
+ # someone else.
576
+ if r.get('success') and self.h.get_login_user and isinstance(approved_user, str):
577
+ if not self.same_user(self.expected_login_user(), approved_user):
578
+ return _json({'error': 'verification_failed'}, 401)
579
+ if r.get('success') and isinstance(approved_user, str) and approved_user:
580
+ self.authenticated({'externalUsername': approved_user,
581
+ 'requestId': r.get('requestId') or _field(self.body, 'requestId'),
582
+ 'via': 'passkey'})
583
+ return _json(r)
584
+ except Exception as error: # noqa: BLE001
585
+ return _relay_error(error)
586
+
587
+ # ── passkey: enrollment / management (auth required) ────────────────
588
+ def passkey_register_options(self) -> RelayResponse:
589
+ """A user with a linked phone approves adding a passkey there first: a
590
+ passkey approves requests on its own, so a stolen session of your app
591
+ must not be able to add one. Same two-phase contract as /login: 202
592
+ with the step (and number to show) until approved, then the options."""
593
+ user_id = self.require_user_id()
594
+ headers_in = guard_input_from_headers(self.get_header)
595
+ try:
596
+ if not headers_in.get('token'):
597
+ try:
598
+ return _json(self.h.client.webauthn.register_options(user_id, discoverable=True))
599
+ except TouchQueAPIException as error:
600
+ if not (error.status == 403 and error.code == 'enrollment_approval_required'):
601
+ raise
602
+ if is_status_poll(self.h.client, user_id, PASSKEY_ENROLL_ACTION, **headers_in):
603
+ self.allow('login-poll', ip_factor=POLLS_PER_ATTEMPT)
604
+ else:
605
+ self.allow('passkey-enroll', user_id)
606
+ result = run_guard(self.h.client, user_id, PASSKEY_ENROLL_ACTION, ip=self.ip, user_agent=self.user_agent,
607
+ enroll=False, **headers_in)
608
+ approved = result.get('approved')
609
+ if not approved:
610
+ headers = {'Cache-Control': 'no-store'}
611
+ retry = (result['body'].get('touchque') or {}).get('retryAfter')
612
+ if retry:
613
+ headers['Retry-After'] = str(retry)
614
+ return _json(result['body'], result['status'], headers)
615
+ return _json(self.h.client.webauthn.register_options(
616
+ user_id, discoverable=True, approval_request_id=approved.get('requestId')))
617
+ except Exception as error: # noqa: BLE001
618
+ return _relay_error(error)
619
+
620
+ def passkey_register_verify(self) -> RelayResponse:
621
+ user_id = self.require_user_id()
622
+ try:
623
+ return _json(self.h.client.webauthn.register_verify(
624
+ user_id, _field(self.body, 'response'), label=_field(self.body, 'label')))
625
+ except Exception as error: # noqa: BLE001
626
+ return _relay_error(error)
627
+
628
+ def passkey_credentials(self) -> RelayResponse:
629
+ user_id = self.require_user_id()
630
+ try:
631
+ return _json(self.h.client.webauthn.list_credentials(user_id))
632
+ except Exception as error: # noqa: BLE001
633
+ return _relay_error(error)
634
+
635
+ def passkey_credential_delete(self, credential_id: str) -> RelayResponse:
636
+ user_id = self.require_user_id()
637
+ try:
638
+ return _json(self.h.client.webauthn.delete_credential(credential_id, external_username=user_id))
639
+ except Exception as error: # noqa: BLE001
640
+ return _relay_error(error)
641
+
642
+
643
+ __all__ = ['RelayHandler', 'RelayResponse', 'RELAY_ROUTES', 'FixedWindowLimiter']
@@ -0,0 +1 @@
1
+ # Empty init for resources module