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/config.py ADDED
@@ -0,0 +1,56 @@
1
+ import os
2
+ from urllib.parse import urlparse
3
+
4
+ from .exceptions import TouchQueConfigException
5
+
6
+ _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
7
+ DEFAULT_BASE_URL = "https://api.touchque.com"
8
+
9
+
10
+ def _assert_safe_base_url(base_url: str) -> None:
11
+ """Allow plaintext http only for localhost / loopback (local development)."""
12
+ parsed = urlparse(base_url)
13
+ if parsed.scheme == "https":
14
+ return
15
+ if parsed.scheme == "http" and (parsed.hostname or "").lower() in _LOCAL_HOSTS:
16
+ return
17
+ if parsed.scheme == "http":
18
+ raise TouchQueConfigException(
19
+ f'Refusing a plaintext http:// base_url for "{parsed.hostname}". '
20
+ "The API key and request signature would be sent in the clear — use https://."
21
+ )
22
+ raise TouchQueConfigException(f"base_url must be http(s): {base_url}")
23
+
24
+
25
+ class Config:
26
+ def __init__(
27
+ self,
28
+ api_key: str = None,
29
+ api_secret: str = None,
30
+ base_url: str = None,
31
+ timeout: int = 10000,
32
+ ):
33
+ """
34
+ Falls back to the environment when an argument is omitted:
35
+ ``TQ_API_KEY``, ``TQ_API_SECRET``, ``TQ_API_URL`` (default
36
+ ``https://api.touchque.com``).
37
+ """
38
+ api_key = api_key if api_key is not None else os.environ.get("TQ_API_KEY")
39
+ api_secret = api_secret if api_secret is not None else os.environ.get("TQ_API_SECRET")
40
+ base_url = base_url if base_url is not None else os.environ.get("TQ_API_URL", DEFAULT_BASE_URL)
41
+
42
+ if not api_key or not api_secret:
43
+ raise TouchQueConfigException(
44
+ "Set TQ_API_KEY and TQ_API_SECRET (or pass api_key=/api_secret=)."
45
+ )
46
+ if not api_key.startswith('tq_'):
47
+ raise TouchQueConfigException(
48
+ 'api_key must start with "tq_". Did you accidentally swap api_key and api_secret?'
49
+ )
50
+
51
+ _assert_safe_base_url(base_url)
52
+
53
+ self.api_key = api_key
54
+ self.api_secret = api_secret
55
+ self.base_url = base_url.rstrip('/')
56
+ self.timeout = timeout
@@ -0,0 +1,4 @@
1
+ """Framework adapters. Each is a thin wrapper over ``touchque.guard.run_guard``
2
+ (step-up decorator) and ``touchque.relay.RelayHandler`` (relay routes)
3
+ — import only the one you use, so the others' framework isn't a hard
4
+ dependency (``touchque.contrib.flask`` needs Flask installed, etc.)."""
@@ -0,0 +1,175 @@
1
+ """
2
+ Django: one line per protected view.
3
+
4
+ from touchque.contrib.django import require_touchque
5
+
6
+ @require_touchque('SEND_MONEY', details=lambda req: {'Amount': f'{req.POST["amount"]} EUR'})
7
+ def transfer(request):
8
+ return JsonResponse({'ok': True, 'touchque': request.touchque})
9
+
10
+ Until the user approves on their phone this answers
11
+ 202 {"touchque": step, "token": ...}. Your page shows the step in its own
12
+ design and sends the same request again with header
13
+ ``X-TouchQue-Token: <token>``. Once approved the view runs ONCE, with
14
+ ``request.touchque`` set to the approval.
15
+
16
+ Relay routes (POST /login, enrollment, offline codes, passkey ceremonies)
17
+ under one URL prefix - see ``touchque.relay.RelayHandler`` for every option:
18
+
19
+ # urls.py
20
+ from touchque.contrib.django import touchque_relay_urls
21
+
22
+ def opened(request, response, info):
23
+ request.session['user'] = info['externalUsername']
24
+
25
+ urlpatterns = [
26
+ path('touchque/', include(touchque_relay_urls(
27
+ get_user_id=lambda req: req.session.get('user'),
28
+ get_login_user=lambda req: req.session.get('password_ok'),
29
+ on_authenticated=opened,
30
+ ))),
31
+ ]
32
+
33
+ Django's CSRF protection stays on for these POST/DELETE routes: send the
34
+ token from the page (``@touchque/web``: ``headers: {'X-CSRFToken': ...}``),
35
+ or pass ``csrf_exempt=True`` if you protect them another way.
36
+ """
37
+ import functools
38
+ import json
39
+ from typing import Any, Callable, Dict, List, Optional
40
+
41
+ from django.core.serializers.json import DjangoJSONEncoder
42
+ from django.http import Http404, HttpRequest, HttpResponse, JsonResponse
43
+ from django.urls import re_path
44
+ from django.views.decorators.csrf import csrf_exempt as _csrf_exempt
45
+
46
+ from ..client import TouchQue, get_default_client
47
+ from ..guard import SilentSignIn, guard_input_from_headers, run_guard, silent_input_from_headers
48
+ from ..relay import RelayHandler
49
+ from ..steps import LoginDetails
50
+
51
+
52
+ def _default_user(request: HttpRequest) -> Optional[str]:
53
+ u = getattr(request, 'user', None)
54
+ if u is None or not getattr(u, 'is_authenticated', True):
55
+ return None
56
+ if isinstance(u, str):
57
+ return u
58
+ email = getattr(u, 'email', None)
59
+ return email or getattr(u, 'username', None) or str(getattr(u, 'pk', '') or '') or None
60
+
61
+
62
+ def require_touchque(
63
+ action: str,
64
+ client: Optional[TouchQue] = None,
65
+ user: Optional[Callable[[HttpRequest], Optional[str]]] = None,
66
+ details: Optional[Callable[[HttpRequest], Optional[LoginDetails]]] = None,
67
+ reference_id: Optional[Callable[[HttpRequest], Optional[str]]] = None,
68
+ silent_sign_in: SilentSignIn = False,
69
+ context: Optional[Callable[[HttpRequest], Optional[Dict[str, Any]]]] = None,
70
+ ) -> Callable:
71
+ """
72
+ ``user``: who is approving, given the request. Default:
73
+ ``request.user.email`` (Django auth). For the login step, return the user
74
+ who just passed your password check, e.g.
75
+ ``user=lambda req: req.session.get('password_ok')``.
76
+
77
+ ``silent_sign_in`` (default off): when on, a new attempt whose browser
78
+ sent its device-bound key proof (``X-TouchQue-Browser``, added by
79
+ ``@touchque/web`` with ``browserKey: true``) first asks TouchQue whether
80
+ anything about it is new. If nothing is, your view runs at once with no
81
+ push (``request.touchque['assurance']['method'] == 'silent'``,
82
+ ``request.touchque['reasons']``). Otherwise, and on any error, the usual push
83
+ starts and its approval makes the browser known.
84
+ ``{'max_age_seconds': n}`` sets your own limit on how long ago the last
85
+ phone approval in that browser may be.
86
+
87
+ ``context``: for your TouchQue Actions only (``event.context``), e.g. the
88
+ user's roles. Never shown on the phone.
89
+ """
90
+ user_of = user or _default_user
91
+ tq = client
92
+
93
+ def decorator(view: Callable) -> Callable:
94
+ @functools.wraps(view)
95
+ def wrapped(request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
96
+ c = tq or get_default_client()
97
+ uid = user_of(request)
98
+ result = run_guard(
99
+ c, uid, action,
100
+ details=details(request) if details else None,
101
+ reference_id=reference_id(request) if reference_id else None,
102
+ context=context(request) if context else None,
103
+ ip=request.META.get('REMOTE_ADDR'), user_agent=request.META.get('HTTP_USER_AGENT'),
104
+ **guard_input_from_headers(lambda name: request.headers.get(name)),
105
+ **silent_input_from_headers(silent_sign_in, lambda name: request.headers.get(name)),
106
+ )
107
+ if 'approved' in result:
108
+ request.touchque = result['approved']
109
+ return view(request, *args, **kwargs)
110
+ resp = JsonResponse(result['body'], status=result['status'])
111
+ resp['Cache-Control'] = 'no-store'
112
+ retry = result['body']['touchque'].get('retryAfter')
113
+ if retry:
114
+ resp['Retry-After'] = str(retry)
115
+ return resp
116
+ return wrapped
117
+ return decorator
118
+
119
+
120
+ def _json_body(request: HttpRequest) -> Any:
121
+ if request.content_type != 'application/json' or not request.body:
122
+ return None
123
+ try:
124
+ return json.loads(request.body)
125
+ except ValueError:
126
+ return None
127
+
128
+
129
+ def touchque_relay_view(
130
+ client: Optional[TouchQue] = None,
131
+ csrf_exempt: bool = False,
132
+ handler: Optional[RelayHandler] = None,
133
+ **options: Any
134
+ ) -> Callable:
135
+ """One view for every relay route; the sub-path comes from the ``sub``
136
+ URL kwarg (see ``touchque_relay_urls``). ``options`` are
137
+ ``RelayHandler``'s (``get_user_id`` is required); or pass a ready ``handler``.
138
+
139
+ Callbacks get the ``HttpRequest``; ``on_authenticated(request, response,
140
+ info)`` gets the ``HttpResponse`` that will be sent, so it may call
141
+ ``response.set_cookie(...)`` or write ``request.session``.
142
+ ``REMOTE_ADDR`` is the client IP the rate limiter uses: behind a proxy,
143
+ make sure it holds the real client address.
144
+ """
145
+ relay = handler or RelayHandler(client, **options)
146
+
147
+ def view(request: HttpRequest, sub: str = '') -> HttpResponse:
148
+ response = HttpResponse(content_type='application/json')
149
+ result = relay.handle(
150
+ request.method or '', '/' + sub, body=_json_body(request),
151
+ ip=request.META.get('REMOTE_ADDR'), user_agent=request.META.get('HTTP_USER_AGENT'),
152
+ get_header=lambda name: request.headers.get(name),
153
+ request=request, response=response,
154
+ )
155
+ if result is None:
156
+ raise Http404()
157
+ response.status_code = result.status
158
+ response.content = json.dumps(result.body, cls=DjangoJSONEncoder).encode('utf-8')
159
+ for key, value in result.headers.items():
160
+ response[key] = value
161
+ return response
162
+
163
+ return _csrf_exempt(view) if csrf_exempt else view
164
+
165
+
166
+ def touchque_relay_urls(
167
+ client: Optional[TouchQue] = None,
168
+ csrf_exempt: bool = False,
169
+ handler: Optional[RelayHandler] = None,
170
+ **options: Any
171
+ ) -> List[Any]:
172
+ """URL patterns for the relay, to mount with
173
+ ``path('touchque/', include(touchque_relay_urls(...)))``."""
174
+ view = touchque_relay_view(client, csrf_exempt=csrf_exempt, handler=handler, **options)
175
+ return [re_path(r'^(?P<sub>.*)$', view, name='touchque_relay')]
@@ -0,0 +1,208 @@
1
+ """
2
+ FastAPI: one line per protected route, as a dependency.
3
+
4
+ from fastapi import Depends, Request
5
+ from touchque.contrib.fastapi import RequireTouchQue
6
+
7
+ @app.post('/transfer')
8
+ async def transfer(request: Request, touchque=Depends(
9
+ RequireTouchQue('SEND_MONEY', details=lambda req: {'Amount': f'{req.state.amount} EUR'})
10
+ )):
11
+ return {'ok': True, 'touchque': touchque}
12
+
13
+ Until the user approves on their phone the dependency raises
14
+ ``HTTPException(202, detail={"touchque": step, "token": ...})``. Your page
15
+ shows the step in its own design and sends the same request again with
16
+ header ``X-TouchQue-Token: <token>``. Once approved the dependency resolves
17
+ to the approval (once) and your route runs.
18
+
19
+ FastAPI's default exception handler puts that JSON under a top-level
20
+ ``"detail"`` key (``{"detail": {"touchque": ..., "token": ...}}``) — unlike
21
+ the Django/Flask adapters, which return it at the top level. Call
22
+ ``install_touchque_exception_handler(app)`` once at startup to flatten it and
23
+ match the other adapters exactly.
24
+
25
+ ``run_guard`` makes a blocking HTTP call (the ``requests`` library); it runs
26
+ in FastAPI's threadpool via ``anyio.to_thread`` so it never blocks the event
27
+ loop.
28
+
29
+ Relay routes (POST /login, enrollment, offline codes, passkey ceremonies)
30
+ as one APIRouter - see ``touchque.relay.RelayHandler`` for every option.
31
+ Callbacks may be sync or async:
32
+
33
+ from touchque.contrib.fastapi import touchque_relay_router
34
+
35
+ async def opened(request, response, info):
36
+ response.set_cookie('session', await create_session(info['externalUsername']))
37
+
38
+ app.include_router(touchque_relay_router(
39
+ tq,
40
+ get_user_id=lambda req: req.session.get('user'),
41
+ get_login_user=lambda req: req.session.get('password_ok'),
42
+ on_authenticated=opened,
43
+ ), prefix='/touchque')
44
+ """
45
+ import json
46
+ from typing import Any, Awaitable, Callable, Optional
47
+
48
+ import anyio
49
+ import anyio.from_thread
50
+ import anyio.to_thread
51
+ import fastapi
52
+ from fastapi import APIRouter, HTTPException, Request
53
+ from fastapi.responses import JSONResponse, Response
54
+
55
+ from ..client import TouchQue, get_default_client
56
+ from ..guard import SilentSignIn, guard_input_from_headers, run_guard, silent_input_from_headers
57
+ from ..relay import RELAY_ROUTES, RelayHandler
58
+ from ..steps import LoginDetails
59
+
60
+
61
+ async def _default_user(request: Request) -> Optional[str]:
62
+ u = getattr(request.state, 'user', None)
63
+ if u is None:
64
+ return None
65
+ if isinstance(u, str):
66
+ return u
67
+ return getattr(u, 'email', None) or getattr(u, 'id', None) or getattr(u, 'username', None)
68
+
69
+
70
+ def RequireTouchQue( # noqa: N802 — reads as a dependency factory, PascalCase is idiomatic here
71
+ action: str,
72
+ client: Optional[TouchQue] = None,
73
+ user: Optional[Callable[[Request], Any]] = None,
74
+ details: Optional[Callable[[Request], Optional[LoginDetails]]] = None,
75
+ reference_id: Optional[Callable[[Request], Any]] = None,
76
+ silent_sign_in: SilentSignIn = False,
77
+ context: Optional[Callable[[Request], Any]] = None,
78
+ ) -> Callable[[Request], Any]:
79
+ """
80
+ ``user``: async or sync callable resolving who is approving from the
81
+ request. Default: ``request.state.user``. For the login step, return the
82
+ user who just passed your password check.
83
+
84
+ ``silent_sign_in`` (default off): when on, a new attempt whose browser
85
+ sent its device-bound key proof (``X-TouchQue-Browser``, added by
86
+ ``@touchque/web`` with ``browserKey: true``) first asks TouchQue whether
87
+ anything about it is new. If nothing is, the dependency resolves at once
88
+ with no push (``approval['assurance']['method'] == 'silent'``,
89
+ ``approval['reasons']``). Otherwise, and on any error, the usual push
90
+ starts and its approval makes the browser known.
91
+ ``{'max_age_seconds': n}`` sets your own limit on how long ago the last
92
+ phone approval in that browser may be.
93
+
94
+ ``context``: for your TouchQue Actions only (``event.context``), e.g. the
95
+ user's roles. Never shown on the phone.
96
+ """
97
+ user_of = user or _default_user
98
+ tq = client
99
+
100
+ async def dependency(request: Request) -> Any:
101
+ c = tq or get_default_client()
102
+ uid = user_of(request)
103
+ if hasattr(uid, '__await__'):
104
+ uid = await uid
105
+ det = details(request) if details else None
106
+ if hasattr(det, '__await__'):
107
+ det = await det
108
+ ref = reference_id(request) if reference_id else None
109
+ if hasattr(ref, '__await__'):
110
+ ref = await ref
111
+ ctx = context(request) if context else None
112
+ if hasattr(ctx, '__await__'):
113
+ ctx = await ctx
114
+
115
+ result = await anyio.to_thread.run_sync(lambda: run_guard(
116
+ c, uid, action, details=det, reference_id=ref, context=ctx,
117
+ ip=request.client.host if request.client else None,
118
+ user_agent=request.headers.get('user-agent'),
119
+ **guard_input_from_headers(lambda name: request.headers.get(name)),
120
+ **silent_input_from_headers(silent_sign_in, lambda name: request.headers.get(name)),
121
+ ))
122
+ if 'approved' in result:
123
+ return result['approved']
124
+ headers = {'Cache-Control': 'no-store'}
125
+ retry = result['body']['touchque'].get('retryAfter')
126
+ if retry:
127
+ headers['Retry-After'] = str(retry)
128
+ raise HTTPException(status_code=result['status'], detail=result['body'], headers=headers)
129
+
130
+ return dependency
131
+
132
+
133
+ def install_touchque_exception_handler(app: fastapi.FastAPI) -> None:
134
+ """Optional: makes a raised TouchQue step return the SAME JSON shape as
135
+ the Django/Flask adapters (no ``{"detail": ...}`` wrapper). Every other
136
+ ``HTTPException`` goes to FastAPI's own handler, unchanged (no body on a
137
+ 204 or 304)."""
138
+ from fastapi import Request as _Request
139
+ from fastapi.exception_handlers import http_exception_handler
140
+ from fastapi.responses import JSONResponse
141
+
142
+ @app.exception_handler(HTTPException)
143
+ async def _touchque_exception_handler(request: _Request, exc: HTTPException): # type: ignore[no-redef]
144
+ if isinstance(exc.detail, dict) and 'touchque' in exc.detail:
145
+ return JSONResponse(exc.detail, status_code=exc.status_code, headers=exc.headers)
146
+ return await http_exception_handler(request, exc)
147
+
148
+
149
+ async def _await(awaitable: Awaitable[Any]) -> Any:
150
+ return await awaitable
151
+
152
+
153
+ def _run_async(awaitable: Awaitable[Any]) -> Any:
154
+ """Resolves an async callback's result from the relay's worker thread."""
155
+ return anyio.from_thread.run(_await, awaitable)
156
+
157
+
158
+ def touchque_relay_router(
159
+ client: Optional[TouchQue] = None,
160
+ prefix: str = '',
161
+ handler: Optional[RelayHandler] = None,
162
+ **options: Any
163
+ ) -> APIRouter:
164
+ """An APIRouter with every relay route. ``options`` are ``RelayHandler``'s
165
+ (``get_user_id`` is required); or pass a ready ``handler``. Callbacks may
166
+ be sync or async.
167
+
168
+ Callbacks get the Starlette ``Request``; ``on_authenticated(request,
169
+ response, info)`` gets a ``fastapi.Response`` whose headers (e.g.
170
+ ``response.set_cookie(...)``) are copied onto the JSON response - the same
171
+ pattern as a FastAPI ``response: Response`` parameter - or it may write
172
+ ``request.session``. ``request.client.host`` is the client IP the rate
173
+ limiter uses: behind a proxy, run uvicorn with ``--proxy-headers``.
174
+ The relay (blocking HTTP calls) runs in the threadpool.
175
+ """
176
+ relay = handler or RelayHandler(client, **options)
177
+ router = APIRouter(prefix=prefix)
178
+
179
+ def make_endpoint(method: str, sub: str) -> Callable[[Request], Awaitable[Response]]:
180
+ async def endpoint(request: Request) -> Response:
181
+ raw = await request.body()
182
+ body = None
183
+ if raw and request.headers.get('content-type', '').split(';')[0].strip() == 'application/json':
184
+ try:
185
+ body = json.loads(raw)
186
+ except ValueError:
187
+ body = None
188
+ native = Response()
189
+ path = sub.replace('<id>', request.path_params.get('cred_id', ''))
190
+ result = await anyio.to_thread.run_sync(lambda: relay.handle(
191
+ method, path, body=body,
192
+ ip=request.client.host if request.client else None,
193
+ user_agent=request.headers.get('user-agent'),
194
+ get_header=lambda name: request.headers.get(name),
195
+ request=request, response=native, run_async=_run_async,
196
+ ))
197
+ if result is None:
198
+ raise HTTPException(status_code=404)
199
+ out = JSONResponse(result.body, status_code=result.status, headers=result.headers)
200
+ out.raw_headers.extend(
201
+ (k, v) for k, v in native.raw_headers if k not in (b'content-length', b'content-type'))
202
+ return out
203
+ return endpoint
204
+
205
+ for method, sub in RELAY_ROUTES:
206
+ router.add_api_route(sub.replace('<id>', '{cred_id}'), make_endpoint(method, sub), methods=[method],
207
+ include_in_schema=False)
208
+ return router
@@ -0,0 +1,154 @@
1
+ """
2
+ Flask: one line per protected route.
3
+
4
+ from touchque.contrib.flask import require_touchque
5
+
6
+ @app.post('/transfer')
7
+ @require_touchque('SEND_MONEY', details=lambda: {'Amount': f'{request.json["amount"]} EUR'})
8
+ def transfer():
9
+ return jsonify(ok=True, touchque=g.touchque)
10
+
11
+ Until the user approves on their phone this answers
12
+ 202 {"touchque": step, "token": ...}. Your page shows the step in its own
13
+ design and sends the same request again with header
14
+ ``X-TouchQue-Token: <token>``. Once approved the view runs ONCE, with
15
+ ``flask.g.touchque`` set to the approval.
16
+
17
+ Relay routes (POST /login, enrollment, offline codes, passkey ceremonies)
18
+ as one Blueprint - see ``touchque.relay.RelayHandler`` for every option:
19
+
20
+ from touchque.contrib.flask import touchque_relay_blueprint
21
+
22
+ def opened(req, resp, info):
23
+ session['user'] = info['externalUsername']
24
+
25
+ app.register_blueprint(touchque_relay_blueprint(
26
+ tq,
27
+ get_user_id=lambda req: session.get('user'),
28
+ get_login_user=lambda req: session.get('password_ok'),
29
+ on_authenticated=opened,
30
+ ), url_prefix='/touchque')
31
+ """
32
+ import functools
33
+ from typing import Any, Callable, Dict, Optional
34
+
35
+ from flask import Blueprint, abort, current_app, g, json, jsonify, request
36
+
37
+ from ..client import TouchQue, get_default_client
38
+ from ..guard import SilentSignIn, guard_input_from_headers, run_guard, silent_input_from_headers
39
+ from ..relay import RELAY_ROUTES, RelayHandler
40
+ from ..steps import LoginDetails
41
+
42
+
43
+ def _default_user() -> Optional[str]:
44
+ u = getattr(g, 'user', None)
45
+ if u is None:
46
+ return None
47
+ if isinstance(u, str):
48
+ return u
49
+ return getattr(u, 'email', None) or getattr(u, 'id', None) or getattr(u, 'username', None)
50
+
51
+
52
+ def require_touchque(
53
+ action: str,
54
+ client: Optional[TouchQue] = None,
55
+ user: Optional[Callable[[], Optional[str]]] = None,
56
+ details: Optional[Callable[[], Optional[LoginDetails]]] = None,
57
+ reference_id: Optional[Callable[[], Optional[str]]] = None,
58
+ silent_sign_in: SilentSignIn = False,
59
+ context: Optional[Callable[[], Optional[Dict[str, Any]]]] = None,
60
+ ) -> Callable:
61
+ """
62
+ ``user``: who is approving. Default: ``flask.g.user`` (string, or an
63
+ object with ``.email``/``.id``/``.username``). For the login step, return
64
+ the user who just passed your password check, e.g.
65
+ ``user=lambda: session.get('password_ok')``.
66
+
67
+ ``silent_sign_in`` (default off): when on, a new attempt whose browser
68
+ sent its device-bound key proof (``X-TouchQue-Browser``, added by
69
+ ``@touchque/web`` with ``browserKey: true``) first asks TouchQue whether
70
+ anything about it is new. If nothing is, your view runs at once with no
71
+ push (``g.touchque['assurance']['method'] == 'silent'``,
72
+ ``g.touchque['reasons']``). Otherwise, and on any error, the usual push
73
+ starts and its approval makes the browser known.
74
+ ``{'max_age_seconds': n}`` sets your own limit on how long ago the last
75
+ phone approval in that browser may be.
76
+
77
+ ``context``: for your TouchQue Actions only (``event.context``), e.g.
78
+ ``context=lambda: {'roles': g.user.roles}``. Never shown on the phone.
79
+ """
80
+ user_of = user or _default_user
81
+ tq = client
82
+
83
+ def decorator(view: Callable) -> Callable:
84
+ @functools.wraps(view)
85
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
86
+ c = tq or get_default_client()
87
+ uid = user_of()
88
+ result = run_guard(
89
+ c, uid, action,
90
+ details=details() if details else None,
91
+ reference_id=reference_id() if reference_id else None,
92
+ context=context() if context else None,
93
+ ip=request.remote_addr, user_agent=request.headers.get('User-Agent'),
94
+ **guard_input_from_headers(lambda name: request.headers.get(name)),
95
+ **silent_input_from_headers(silent_sign_in, lambda name: request.headers.get(name)),
96
+ )
97
+ if 'approved' in result:
98
+ g.touchque = result['approved']
99
+ return view(*args, **kwargs)
100
+ resp = jsonify(result['body'])
101
+ resp.status_code = result['status']
102
+ resp.headers['Cache-Control'] = 'no-store'
103
+ retry = result['body']['touchque'].get('retryAfter')
104
+ if retry:
105
+ resp.headers['Retry-After'] = str(retry)
106
+ return resp
107
+ return wrapped
108
+ return decorator
109
+
110
+
111
+ def touchque_relay_blueprint(
112
+ client: Optional[TouchQue] = None,
113
+ name: str = 'touchque_relay',
114
+ url_prefix: Optional[str] = None,
115
+ handler: Optional[RelayHandler] = None,
116
+ **options: Any
117
+ ) -> Blueprint:
118
+ """A Blueprint with every relay route. ``options`` are ``RelayHandler``'s
119
+ (``get_user_id`` is required); or pass a ready ``handler``.
120
+
121
+ Callbacks get ``flask.request``; ``on_authenticated(request, response,
122
+ info)`` gets the Flask response that will be sent, so it may call
123
+ ``response.set_cookie(...)`` or write ``flask.session``.
124
+ ``request.remote_addr`` is the client IP the rate limiter uses: behind a
125
+ proxy, wrap the app in werkzeug's ``ProxyFix``.
126
+ """
127
+ relay = handler or RelayHandler(client, **options)
128
+ bp = Blueprint(name, __name__, url_prefix=url_prefix)
129
+
130
+ def make_view(method: str, sub: str) -> Callable:
131
+ def view(cred_id: Optional[str] = None) -> Any:
132
+ response = current_app.response_class(mimetype='application/json')
133
+ result = relay.handle(
134
+ method, sub.replace('<id>', cred_id or ''),
135
+ body=request.get_json(silent=True),
136
+ ip=request.remote_addr, user_agent=request.headers.get('User-Agent'),
137
+ get_header=lambda name_: request.headers.get(name_),
138
+ request=request._get_current_object(), # type: ignore[attr-defined]
139
+ response=response,
140
+ )
141
+ if result is None:
142
+ abort(404)
143
+ response.set_data(json.dumps(result.body))
144
+ response.status_code = result.status
145
+ for key, value in result.headers.items():
146
+ response.headers[key] = value
147
+ return response
148
+ return view
149
+
150
+ for method, sub in RELAY_ROUTES:
151
+ endpoint = method.lower() + sub.replace('/', '_').replace('<id>', 'id')
152
+ bp.add_url_rule(sub.replace('<id>', '<cred_id>'), endpoint=endpoint,
153
+ view_func=make_view(method, sub), methods=[method])
154
+ return bp