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.
- touchque/__init__.py +148 -0
- touchque/agent.py +314 -0
- touchque/agent_card.py +190 -0
- touchque/agent_frameworks.py +215 -0
- touchque/approval_proof.py +104 -0
- touchque/approval_token.py +134 -0
- touchque/client.py +105 -0
- touchque/code_reply.py +75 -0
- touchque/config.py +56 -0
- touchque/contrib/__init__.py +4 -0
- touchque/contrib/django.py +175 -0
- touchque/contrib/fastapi.py +208 -0
- touchque/contrib/flask.py +154 -0
- touchque/cross_app_access.py +222 -0
- touchque/exceptions.py +170 -0
- touchque/guard.py +290 -0
- touchque/hosted_login.py +474 -0
- touchque/http_client.py +156 -0
- touchque/mcp_auth.py +256 -0
- touchque/my_account.py +173 -0
- touchque/org_agent.py +262 -0
- touchque/rate_limiter.py +56 -0
- touchque/relay.py +643 -0
- touchque/resources/__init__.py +1 -0
- touchque/resources/action_types.py +52 -0
- touchque/resources/actions.py +234 -0
- touchque/resources/agents.py +236 -0
- touchque/resources/assist.py +115 -0
- touchque/resources/auth.py +65 -0
- touchque/resources/device_risk.py +174 -0
- touchque/resources/identity.py +268 -0
- touchque/resources/login.py +296 -0
- touchque/resources/mobile.py +130 -0
- touchque/resources/offline.py +138 -0
- touchque/resources/qr_pairings.py +93 -0
- touchque/resources/recovery.py +118 -0
- touchque/resources/risk.py +141 -0
- touchque/resources/safe_guard.py +99 -0
- touchque/resources/webauthn.py +115 -0
- touchque/resources/webhook.py +258 -0
- touchque/safe_guard.py +308 -0
- touchque/security_events.py +205 -0
- touchque/steps.py +239 -0
- touchque_authenticator-4.2.0.dist-info/METADATA +630 -0
- touchque_authenticator-4.2.0.dist-info/RECORD +48 -0
- touchque_authenticator-4.2.0.dist-info/WHEEL +5 -0
- touchque_authenticator-4.2.0.dist-info/licenses/LICENSE +21 -0
- touchque_authenticator-4.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Never Locked Out: move a user whose phone is lost, dead or replaced to a new
|
|
3
|
+
phone without a help desk.
|
|
4
|
+
|
|
5
|
+
1. Let users name colleagues who can vouch for them (``set_vouchers``), behind
|
|
6
|
+
an approval on their phone.
|
|
7
|
+
2. When the phone is gone, after the password (or your IdP): ``start``. Show
|
|
8
|
+
``bindUri`` as a QR for the TouchQue app on the new phone, then poll
|
|
9
|
+
``status`` and show each voucher's ``code``: the user reads it to the
|
|
10
|
+
colleague on a call, who types it on their own phone.
|
|
11
|
+
3. After the cooling-off nobody vetoed, the account is on the new phone
|
|
12
|
+
(``status == 'COMPLETED'``); critical actions wait 24 h (probation).
|
|
13
|
+
|
|
14
|
+
Admins can stop a running recovery with ``veto``.
|
|
15
|
+
"""
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
from urllib.parse import quote
|
|
18
|
+
|
|
19
|
+
from ..exceptions import TouchQueConfigException
|
|
20
|
+
from ..http_client import HttpClient
|
|
21
|
+
|
|
22
|
+
OPEN_STATUSES = ('AWAITING_DEVICE', 'VOUCHING', 'COOLING')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_recovery_open(recovery: Dict[str, Any]) -> bool:
|
|
26
|
+
"""A recovery still waiting for the new phone, the vouchers or the cooling-off."""
|
|
27
|
+
return isinstance(recovery, dict) and recovery.get('status') in OPEN_STATUSES
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Recovery:
|
|
31
|
+
"""Never Locked Out: vouchers, start, status, list and veto."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, http: HttpClient):
|
|
34
|
+
self._http = http
|
|
35
|
+
|
|
36
|
+
def vouchers(self, external_username: str) -> Dict[str, Any]:
|
|
37
|
+
"""The colleagues a user named, and whether each can vouch today."""
|
|
38
|
+
if not external_username:
|
|
39
|
+
raise TouchQueConfigException('recovery.vouchers() needs the user (external_username)')
|
|
40
|
+
return self._http.get('/recovery/vouchers', {'externalUsername': external_username})
|
|
41
|
+
|
|
42
|
+
def set_vouchers(self, external_username: str, vouchers: List[str]) -> Dict[str, Any]:
|
|
43
|
+
"""Replaces the user's vouchers (at most 5 are kept). Ask for the
|
|
44
|
+
user's phone first: whoever sets them chooses who may move the account."""
|
|
45
|
+
if not external_username:
|
|
46
|
+
raise TouchQueConfigException('recovery.set_vouchers() needs the user (external_username)')
|
|
47
|
+
if not isinstance(vouchers, list):
|
|
48
|
+
raise TouchQueConfigException('recovery.set_vouchers() needs a list of vouchers')
|
|
49
|
+
return self._http.post('/recovery/vouchers', {'externalUsername': external_username, 'vouchers': vouchers})
|
|
50
|
+
|
|
51
|
+
def start(
|
|
52
|
+
self,
|
|
53
|
+
external_username: str,
|
|
54
|
+
client_ip: Optional[str] = None,
|
|
55
|
+
user_agent: Optional[str] = None,
|
|
56
|
+
display_name: Optional[str] = None,
|
|
57
|
+
fallback_vouchers: Optional[List[str]] = None,
|
|
58
|
+
required: Optional[int] = None,
|
|
59
|
+
cool_off_minutes: Optional[int] = None,
|
|
60
|
+
details: Optional[List[Dict[str, str]]] = None,
|
|
61
|
+
notice_details: Optional[List[Dict[str, str]]] = None,
|
|
62
|
+
) -> Dict[str, Any]:
|
|
63
|
+
"""Starts moving the user to a new phone. Call it only after the first
|
|
64
|
+
factor. Raises TouchQueAPIException with ``code`` ``not_enough_vouchers``,
|
|
65
|
+
``recovery_in_progress``, ``blocked``, ``frozen`` or ``rate_limited``::
|
|
66
|
+
|
|
67
|
+
r = tq.recovery.start(user, client_ip=ip, display_name='Ada')
|
|
68
|
+
show_qr(r['bindUri']) # the new phone scans it
|
|
69
|
+
"""
|
|
70
|
+
if not external_username:
|
|
71
|
+
raise TouchQueConfigException('recovery.start() needs the user (external_username)')
|
|
72
|
+
payload: Dict[str, Any] = {'externalUsername': external_username}
|
|
73
|
+
if client_ip:
|
|
74
|
+
payload['clientIp'] = client_ip
|
|
75
|
+
if user_agent:
|
|
76
|
+
payload['userAgent'] = user_agent
|
|
77
|
+
if display_name:
|
|
78
|
+
payload['displayName'] = display_name
|
|
79
|
+
if fallback_vouchers:
|
|
80
|
+
payload['fallbackVouchers'] = list(fallback_vouchers)
|
|
81
|
+
if required is not None:
|
|
82
|
+
payload['required'] = required
|
|
83
|
+
if cool_off_minutes is not None:
|
|
84
|
+
payload['coolOffMinutes'] = cool_off_minutes
|
|
85
|
+
if details:
|
|
86
|
+
payload['details'] = details
|
|
87
|
+
if notice_details:
|
|
88
|
+
payload['noticeDetails'] = notice_details
|
|
89
|
+
return self._http.post('/recovery/start', payload)
|
|
90
|
+
|
|
91
|
+
def status(self, recovery_id: str) -> Dict[str, Any]:
|
|
92
|
+
"""The recovery now, with its receipt chain. Poll it while the user waits."""
|
|
93
|
+
if not recovery_id:
|
|
94
|
+
raise TouchQueConfigException('recovery.status() needs the recovery id')
|
|
95
|
+
return self._http.get(f'/recovery/{quote(recovery_id, safe="")}')
|
|
96
|
+
|
|
97
|
+
def list(self, external_username: Optional[str] = None, open: bool = False) -> List[Dict[str, Any]]:
|
|
98
|
+
"""A user's recoveries, or (``open=True``) every running one of your workspace."""
|
|
99
|
+
if not external_username and not open:
|
|
100
|
+
raise TouchQueConfigException('recovery.list() needs external_username or open=True')
|
|
101
|
+
params: Dict[str, Any] = {}
|
|
102
|
+
if external_username:
|
|
103
|
+
params['externalUsername'] = external_username
|
|
104
|
+
if open:
|
|
105
|
+
params['open'] = 'true'
|
|
106
|
+
res = self._http.get('/recovery', params)
|
|
107
|
+
return res.get('recoveries', []) if isinstance(res, dict) else []
|
|
108
|
+
|
|
109
|
+
def veto(self, recovery_id: str, by: Optional[str] = None, reason: Optional[str] = None) -> Dict[str, Any]:
|
|
110
|
+
"""Stops a running recovery (e.g. an admin who does not believe it). Does not freeze the user."""
|
|
111
|
+
if not recovery_id:
|
|
112
|
+
raise TouchQueConfigException('recovery.veto() needs the recovery id')
|
|
113
|
+
payload: Dict[str, Any] = {}
|
|
114
|
+
if by:
|
|
115
|
+
payload['by'] = by
|
|
116
|
+
if reason:
|
|
117
|
+
payload['reason'] = reason
|
|
118
|
+
return self._http.post(f'/recovery/{quote(recovery_id, safe="")}/veto', payload)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Silent sign-ins: ask TouchQue's risk engine whether a sign-in needs the phone
|
|
3
|
+
at all, and manage the browsers it knows for a user.
|
|
4
|
+
|
|
5
|
+
A browser becomes known when the user approves a request on the phone (or
|
|
6
|
+
with a passkey) that carried the browser's device-bound key proof
|
|
7
|
+
(``login.request(..., browser_proof=...)``). Later, ``risk.check`` with a
|
|
8
|
+
fresh proof from the same browser answers ``prompt: False`` when nothing about
|
|
9
|
+
the sign-in is new. ``risk.check`` sends nothing to the phone.
|
|
10
|
+
"""
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from ..exceptions import TouchQueConfigException
|
|
14
|
+
from ..http_client import HttpClient
|
|
15
|
+
|
|
16
|
+
_ASSURANCE = ('high', 'normal', 'low')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_risk_check(raw: Any) -> Dict[str, Any]:
|
|
20
|
+
"""Reads a ``POST /risk/check`` answer, failing closed.
|
|
21
|
+
|
|
22
|
+
``prompt`` is ``False`` ONLY when the answer says exactly ``prompt: false``
|
|
23
|
+
(a JSON boolean). Anything else, including a missing, ``null``, ``0`` or
|
|
24
|
+
``"false"`` value or an answer that is not a JSON object, asks. Malformed
|
|
25
|
+
``reasons`` become ``[]`` (entries without a string ``code`` are dropped, a
|
|
26
|
+
missing ``text`` is ``''``), an unknown or missing ``assurance`` is
|
|
27
|
+
``'normal'``, and ``silentMode`` is ``'shadow'`` or ``'enforce'``.
|
|
28
|
+
"""
|
|
29
|
+
body = raw if isinstance(raw, dict) else {}
|
|
30
|
+
reasons: List[Dict[str, str]] = []
|
|
31
|
+
if isinstance(body.get('reasons'), list):
|
|
32
|
+
for r in body['reasons']:
|
|
33
|
+
if isinstance(r, dict) and isinstance(r.get('code'), str):
|
|
34
|
+
text = r.get('text')
|
|
35
|
+
reasons.append({'code': r['code'], 'text': text if isinstance(text, str) else ''})
|
|
36
|
+
assurance = body.get('assurance')
|
|
37
|
+
out = {
|
|
38
|
+
# `is not False`: only the boolean False skips the push (0, None, 'false' ask).
|
|
39
|
+
'prompt': body.get('prompt') is not False,
|
|
40
|
+
'reasons': reasons,
|
|
41
|
+
'assurance': assurance if isinstance(assurance, str) and assurance in _ASSURANCE else 'normal',
|
|
42
|
+
'silentMode': 'shadow' if body.get('silentMode') == 'shadow' else 'enforce',
|
|
43
|
+
}
|
|
44
|
+
# "Why?": the check's case code (K7P-2QX), to show the user.
|
|
45
|
+
if isinstance(body.get('caseCode'), str):
|
|
46
|
+
out['caseCode'] = body['caseCode']
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Risk:
|
|
51
|
+
"""Silent sign-ins: should this sign-in ask the phone, and which browsers
|
|
52
|
+
TouchQue knows for a user."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, http: HttpClient):
|
|
55
|
+
self._http = http
|
|
56
|
+
|
|
57
|
+
def check(
|
|
58
|
+
self,
|
|
59
|
+
external_username: str,
|
|
60
|
+
type: str = 'LOGIN',
|
|
61
|
+
client_ip: Optional[str] = None,
|
|
62
|
+
user_agent: Optional[str] = None,
|
|
63
|
+
browser_proof: Optional[str] = None,
|
|
64
|
+
max_age_seconds: Optional[int] = None,
|
|
65
|
+
context: Optional[Dict[str, Any]] = None,
|
|
66
|
+
client_signals: Optional[str] = None,
|
|
67
|
+
device_risk: Optional[str] = None,
|
|
68
|
+
device_risk_request_id: Optional[str] = None,
|
|
69
|
+
) -> Dict[str, Any]:
|
|
70
|
+
"""Should this sign-in ask the phone? Sends nothing to the phone.
|
|
71
|
+
|
|
72
|
+
Returns ``{'prompt', 'reasons', 'assurance', 'silentMode'}``. Skip the
|
|
73
|
+
push only when ``prompt`` is ``False``; on any error, ask as usual::
|
|
74
|
+
|
|
75
|
+
browser_proof = request.headers.get('X-TouchQue-Browser')
|
|
76
|
+
risk = tq.risk.check(user, client_ip=ip, browser_proof=browser_proof)
|
|
77
|
+
if risk['prompt'] is False:
|
|
78
|
+
return sign_in(user) # known browser, nothing new
|
|
79
|
+
tq.login.request(user, type='LOGIN', browser_proof=browser_proof)
|
|
80
|
+
|
|
81
|
+
:param type: the action the user is about to do (default ``'LOGIN'``).
|
|
82
|
+
A critical action always asks.
|
|
83
|
+
:param client_ip: the end user's IP address, compared with the networks
|
|
84
|
+
of the user's earlier approved sign-ins.
|
|
85
|
+
:param browser_proof: the browser's device-bound key proof
|
|
86
|
+
(``X-TouchQue-Browser``), passed through untouched.
|
|
87
|
+
:param max_age_seconds: your own limit on how long ago the last phone
|
|
88
|
+
approval in this browser may be (integer, ``0`` = always ask).
|
|
89
|
+
TouchQue never goes beyond its own limit (7 days).
|
|
90
|
+
:param context: for your TouchQue Actions only (``event.context``).
|
|
91
|
+
:param client_signals: the end user's page check from ``@touchque/web``
|
|
92
|
+
(``startClientSignals().encode()``): software driving the browser is never silent.
|
|
93
|
+
:param device_risk: the page's device report from ``@touchque/web``
|
|
94
|
+
(``deviceRisk.collect({ purpose: 'sign_in' })``): a device signal is never silent.
|
|
95
|
+
:param device_risk_request_id: the page's identification (``deviceRisk.identify()``), preferred.
|
|
96
|
+
"""
|
|
97
|
+
if not external_username:
|
|
98
|
+
raise TouchQueConfigException('risk.check() needs the user (external_username)')
|
|
99
|
+
payload: Dict[str, Any] = {'externalUsername': external_username}
|
|
100
|
+
if type:
|
|
101
|
+
payload['type'] = type
|
|
102
|
+
if client_ip:
|
|
103
|
+
payload['clientIp'] = client_ip
|
|
104
|
+
if user_agent:
|
|
105
|
+
payload['userAgent'] = user_agent
|
|
106
|
+
if browser_proof:
|
|
107
|
+
payload['browserProof'] = browser_proof
|
|
108
|
+
if client_signals:
|
|
109
|
+
payload['clientSignals'] = client_signals
|
|
110
|
+
if device_risk:
|
|
111
|
+
payload['deviceRisk'] = device_risk
|
|
112
|
+
if device_risk_request_id:
|
|
113
|
+
payload['deviceRiskRequestId'] = device_risk_request_id
|
|
114
|
+
if max_age_seconds is not None:
|
|
115
|
+
payload['maxAgeSeconds'] = max_age_seconds
|
|
116
|
+
if context:
|
|
117
|
+
payload['context'] = context
|
|
118
|
+
return parse_risk_check(self._http.post('/risk/check', payload))
|
|
119
|
+
|
|
120
|
+
def browsers(self, external_username: str) -> Dict[str, Any]:
|
|
121
|
+
"""The browsers TouchQue knows for a user, for an "approved browsers"
|
|
122
|
+
list in your settings page: ``{'browsers': [{'thumbprint', 'label',
|
|
123
|
+
'createdAt', 'lastMfaAt', 'lastMfaMethod', 'lastSeenAt',
|
|
124
|
+
'lastSilentAt'}]}``. Pass a ``thumbprint`` to ``forget_browsers`` to
|
|
125
|
+
forget that one."""
|
|
126
|
+
if not external_username:
|
|
127
|
+
raise TouchQueConfigException('risk.browsers() needs the user (external_username)')
|
|
128
|
+
res = self._http.get('/risk/browsers', {'externalUsername': external_username})
|
|
129
|
+
browsers = res.get('browsers') if isinstance(res, dict) else None
|
|
130
|
+
return {'browsers': browsers if isinstance(browsers, list) else []}
|
|
131
|
+
|
|
132
|
+
def forget_browsers(self, external_username: str, thumbprint: Optional[str] = None) -> Dict[str, Any]:
|
|
133
|
+
"""Forgets one browser (``thumbprint``) or all of a user's browsers:
|
|
134
|
+
their next sign-in asks the phone again. Call it after a password reset
|
|
135
|
+
or when the user signs out everywhere. Returns ``{'forgotten': n}``."""
|
|
136
|
+
if not external_username:
|
|
137
|
+
raise TouchQueConfigException('risk.forget_browsers() needs the user (external_username)')
|
|
138
|
+
payload: Dict[str, Any] = {'externalUsername': external_username}
|
|
139
|
+
if thumbprint:
|
|
140
|
+
payload['thumbprint'] = thumbprint
|
|
141
|
+
return self._http.post('/risk/browsers/forget', payload)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Safe Guard: a Safe transaction approved on the approver's phone for TouchQueSafeGuard (the
|
|
2
|
+
guard on your Safe). You send the raw transaction; the phone gets the raw fields only, decodes
|
|
3
|
+
them itself, shows its own rows and signs, with a key only its biometrics unlock, a 105-byte
|
|
4
|
+
approval of the hash it computed. TouchQue never holds a key that can approve.
|
|
5
|
+
|
|
6
|
+
1. ``request(external_username, type, safe_tx)`` asks the phone (same pipeline as
|
|
7
|
+
``login.request``: risk engine, push limit, Vishing Shield, case code, always biometrics).
|
|
8
|
+
2. ``wait_for_approval(request_id)`` (or poll ``status``) until the phone answered.
|
|
9
|
+
3. Check ``safeTx['approval']`` yourself with ``touchque.verify_safe_approval``: it needs your
|
|
10
|
+
own transaction and the approver's key from the guard, not this response.
|
|
11
|
+
4. ``purpose == 'approve'``: ``with_safe_approvals(owner_signatures, approvals)`` as the
|
|
12
|
+
signatures of ``execTransaction``. ``purpose == 'schedule'``: send
|
|
13
|
+
``safe_schedule_call(safe, governance_hash, approvals)`` to the guard; the change runs once its
|
|
14
|
+
waiting time is over.
|
|
15
|
+
"""
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Dict, Mapping, Optional
|
|
18
|
+
from urllib.parse import quote
|
|
19
|
+
|
|
20
|
+
from ..exceptions import TouchQueConfigException, TouchQueRejectedException, TouchQueTimeoutException
|
|
21
|
+
from ..http_client import HttpClient
|
|
22
|
+
|
|
23
|
+
_NUMBERS = ('value', 'safeTxGas', 'baseGas', 'gasPrice', 'nonce')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _wire(safe_tx: Mapping[str, Any]) -> Dict[str, Any]:
|
|
27
|
+
"""Large integers go as decimal strings, as the API takes them."""
|
|
28
|
+
out = dict(safe_tx)
|
|
29
|
+
if isinstance(out.get('chainId'), int):
|
|
30
|
+
out['chainId'] = str(out['chainId'])
|
|
31
|
+
tx = dict(out['tx'])
|
|
32
|
+
for name in _NUMBERS:
|
|
33
|
+
if isinstance(tx.get(name), int) and not isinstance(tx.get(name), bool):
|
|
34
|
+
tx[name] = str(tx[name])
|
|
35
|
+
out['tx'] = tx
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SafeGuard:
|
|
40
|
+
def __init__(self, http: HttpClient):
|
|
41
|
+
self._http = http
|
|
42
|
+
|
|
43
|
+
def request(self, external_username: str, type: str, safe_tx: Mapping[str, Any], reference_id: Optional[str] = None,
|
|
44
|
+
expires_in: Optional[int] = None, client_ip: Optional[str] = None, user_agent: Optional[str] = None) -> Dict[str, Any]:
|
|
45
|
+
"""Asks the approver's phone to approve a Safe transaction.
|
|
46
|
+
|
|
47
|
+
``type``: an action type from your dashboard. ``safe_tx``: ``{"chainId", "safe", "guard",
|
|
48
|
+
"tx": {"to", "value", "data", "operation", "safeTxGas", "baseGas", "gasPrice", "gasToken",
|
|
49
|
+
"refundReceiver", "nonce"}, "config": {"threshold", "govThreshold", "govDelay", "exitDelay",
|
|
50
|
+
"recipientLock"}, "batchTargets", "recipients", "approverIndex", "locale"}``.
|
|
51
|
+
``expires_in``: seconds, 30 to 300 (default 300)::
|
|
52
|
+
|
|
53
|
+
sent = tq.safe_guard.request('treasurer@acme.com', 'safe_payment', {
|
|
54
|
+
'chainId': 1, 'safe': SAFE, 'guard': GUARD,
|
|
55
|
+
'tx': {'to': to, 'value': 0, 'data': data, 'operation': 0, 'nonce': 42},
|
|
56
|
+
'config': {'threshold': 2, 'govThreshold': 3, 'govDelay': 172800, 'exitDelay': 259200},
|
|
57
|
+
'approverIndex': 0,
|
|
58
|
+
})
|
|
59
|
+
"""
|
|
60
|
+
if not external_username:
|
|
61
|
+
raise TouchQueConfigException('safe_guard.request() needs external_username.')
|
|
62
|
+
if not type:
|
|
63
|
+
raise TouchQueConfigException('safe_guard.request() needs type: an action type from your dashboard.')
|
|
64
|
+
if not isinstance(safe_tx, Mapping) or not isinstance(safe_tx.get('tx'), Mapping):
|
|
65
|
+
raise TouchQueConfigException('safe_guard.request() needs safe_tx: {"chainId", "safe", "guard", "tx", "config", "approverIndex"}.')
|
|
66
|
+
body: Dict[str, Any] = {'externalUsername': external_username, 'type': type, 'safeTx': _wire(safe_tx)}
|
|
67
|
+
if reference_id:
|
|
68
|
+
body['referenceId'] = reference_id
|
|
69
|
+
if expires_in is not None:
|
|
70
|
+
body['expiresIn'] = expires_in
|
|
71
|
+
if client_ip:
|
|
72
|
+
body['clientIp'] = client_ip
|
|
73
|
+
if user_agent:
|
|
74
|
+
body['userAgent'] = user_agent
|
|
75
|
+
return self._http.post('/safe-guard/approvals', body)
|
|
76
|
+
|
|
77
|
+
def status(self, request_id: str) -> Dict[str, Any]:
|
|
78
|
+
"""The request's status and, once approved, the approval (``safeTx['approval']``, 105 bytes hex)."""
|
|
79
|
+
if not request_id:
|
|
80
|
+
raise TouchQueConfigException('safe_guard.status() needs a request_id.')
|
|
81
|
+
return self._http.get(f'/safe-guard/approvals/{quote(request_id, safe="")}')
|
|
82
|
+
|
|
83
|
+
def wait_for_approval(self, request_id: str, timeout_ms: int = 300000, poll_interval_ms: int = 1500) -> Dict[str, Any]:
|
|
84
|
+
"""Polls until the phone approved and returns the status with the approval.
|
|
85
|
+
|
|
86
|
+
Raises ``TouchQueRejectedException`` when the person said no, ``TouchQueTimeoutException``
|
|
87
|
+
when it expired or ``timeout_ms`` (default 5 minutes, the request's own life) ran out."""
|
|
88
|
+
start = time.monotonic()
|
|
89
|
+
while True:
|
|
90
|
+
status = self.status(request_id)
|
|
91
|
+
if status.get('status') == 'CONFIRMED':
|
|
92
|
+
return status
|
|
93
|
+
if status.get('status') == 'REJECTED':
|
|
94
|
+
raise TouchQueRejectedException(f'Request {request_id} was rejected by the user.')
|
|
95
|
+
if status.get('status') == 'EXPIRED':
|
|
96
|
+
raise TouchQueTimeoutException(f'Request {request_id} expired before it was approved.')
|
|
97
|
+
if (time.monotonic() - start) * 1000 > timeout_ms:
|
|
98
|
+
raise TouchQueTimeoutException(f'Safe Guard approval timed out after {timeout_ms}ms')
|
|
99
|
+
time.sleep(poll_interval_ms / 1000)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from typing import Dict, Any, Optional
|
|
2
|
+
from urllib.parse import quote
|
|
3
|
+
from ..http_client import HttpClient
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class WebAuthn:
|
|
7
|
+
"""
|
|
8
|
+
WebAuthn / FIDO2 (passkey) — a phishing-resistant approval path alongside
|
|
9
|
+
the push + device flow (``login.verify``).
|
|
10
|
+
|
|
11
|
+
Server-to-server, like every other resource here: the passkey ceremony runs
|
|
12
|
+
in the browser (``navigator.credentials.create()`` / ``.get()``, or
|
|
13
|
+
``@touchque/web``), your backend collects that JSON and relays it through
|
|
14
|
+
these methods. There is no client-side ceremony in this SDK.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, http: HttpClient):
|
|
18
|
+
self._http = http
|
|
19
|
+
|
|
20
|
+
# ── registration ────────────────────────────────────────────────────────
|
|
21
|
+
def register_options(self, external_username: str, discoverable: bool = False,
|
|
22
|
+
approval_request_id: Optional[str] = None) -> Dict[str, Any]:
|
|
23
|
+
"""Step 1: options for ``navigator.credentials.create()``.
|
|
24
|
+
|
|
25
|
+
``discoverable=True`` registers a resident credential with forced user
|
|
26
|
+
verification — the shape a passwordless-primary login authenticates
|
|
27
|
+
against. Omit for a classic second-factor credential.
|
|
28
|
+
|
|
29
|
+
``approval_request_id`` is required when the user has a linked phone
|
|
30
|
+
(the API answers 403 ``enrollment_approval_required`` otherwise): a
|
|
31
|
+
``PASSKEY_ENROLL`` request the user approved on the phone, at most 10
|
|
32
|
+
minutes old, used once. The relay's /passkey/register/options runs
|
|
33
|
+
that approval for you.
|
|
34
|
+
"""
|
|
35
|
+
body: Dict[str, Any] = {'externalUsername': external_username}
|
|
36
|
+
if discoverable:
|
|
37
|
+
body['discoverable'] = True
|
|
38
|
+
if approval_request_id:
|
|
39
|
+
body['approvalRequestId'] = approval_request_id
|
|
40
|
+
return self._http.post('/webauthn/register/options', body)
|
|
41
|
+
|
|
42
|
+
def register_verify(self, external_username: str, response: Dict[str, Any], label: Optional[str] = None) -> Dict[str, Any]:
|
|
43
|
+
"""Step 2: verify the browser's registration response and store the credential.
|
|
44
|
+
|
|
45
|
+
Returns ``{ verified, credentialId }``.
|
|
46
|
+
"""
|
|
47
|
+
body: Dict[str, Any] = {'externalUsername': external_username, 'response': response}
|
|
48
|
+
if label:
|
|
49
|
+
body['label'] = label
|
|
50
|
+
return self._http.post('/webauthn/register/verify', body)
|
|
51
|
+
|
|
52
|
+
# ── second-factor authentication (against a pending LoginRequest) ────────
|
|
53
|
+
def authenticate_options(self, request_id: str) -> Dict[str, Any]:
|
|
54
|
+
"""Step 1: options for ``navigator.credentials.get()``, scoped to a
|
|
55
|
+
pending ``request_id`` from ``login.request()``."""
|
|
56
|
+
return self._http.post('/webauthn/login/options', {'requestId': request_id})
|
|
57
|
+
|
|
58
|
+
def authenticate_verify(self, request_id: str, response: Dict[str, Any]) -> Dict[str, Any]:
|
|
59
|
+
"""Step 2: verify the assertion — approves the LoginRequest on success.
|
|
60
|
+
|
|
61
|
+
Returns ``{ success, message }``.
|
|
62
|
+
"""
|
|
63
|
+
return self._http.post('/webauthn/login/verify', {'requestId': request_id, 'response': response})
|
|
64
|
+
|
|
65
|
+
# ── passwordless-primary login (no password, no prior login.request) ────
|
|
66
|
+
def primary_availability(self) -> Dict[str, Any]:
|
|
67
|
+
"""Whether passkey sign-in (``TenantPolicy.passwordlessLoginEnabled``)
|
|
68
|
+
is on for your workspace. Ask before your sign-in page shows its passkey
|
|
69
|
+
button and passkey autofill: when it is off, ``primary_options`` answers
|
|
70
|
+
403 ``passwordless_login_disabled``. Read-only.
|
|
71
|
+
|
|
72
|
+
Returns ``{ enabled, userVerification }`` (``userVerification`` is
|
|
73
|
+
``'required'``, ``'preferred'``, or ``None`` when off).
|
|
74
|
+
"""
|
|
75
|
+
raw = self._http.get('/webauthn/authenticate/primary/availability') or {}
|
|
76
|
+
enabled = raw.get('enabled') is True
|
|
77
|
+
uv = raw.get('userVerification')
|
|
78
|
+
return {'enabled': enabled, 'userVerification': uv if enabled and uv in ('required', 'preferred') else None}
|
|
79
|
+
|
|
80
|
+
def primary_options(self, external_username: Optional[str] = None) -> Dict[str, Any]:
|
|
81
|
+
"""Step 1 of a passwordless-primary login. Requires
|
|
82
|
+
``TenantPolicy.passwordlessLoginEnabled``. A 404 ``no_passkey_registered``
|
|
83
|
+
means the user has no passkey — fall back to password login.
|
|
84
|
+
|
|
85
|
+
Without ``external_username`` the options accept any passkey of your
|
|
86
|
+
site (passkey autofill, where nobody has typed an email yet), and
|
|
87
|
+
``primary_verify`` names the user the passkey belongs to.
|
|
88
|
+
|
|
89
|
+
Returns ``{ attemptId, options }``.
|
|
90
|
+
"""
|
|
91
|
+
body = {'externalUsername': external_username} if external_username else {}
|
|
92
|
+
return self._http.post('/webauthn/authenticate/primary/options', body)
|
|
93
|
+
|
|
94
|
+
def primary_verify(self, attempt_id: str, response: Dict[str, Any]) -> Dict[str, Any]:
|
|
95
|
+
"""Step 2 of a passwordless-primary login.
|
|
96
|
+
|
|
97
|
+
On ``success=True`` the returned ``requestId`` is a CONFIRMED
|
|
98
|
+
LoginRequest. On ``success=False`` with ``requiresStepUp=True``, start
|
|
99
|
+
the normal ``login.request()`` / number-match flow instead.
|
|
100
|
+
"""
|
|
101
|
+
return self._http.post('/webauthn/authenticate/primary/verify', {'attemptId': attempt_id, 'response': response})
|
|
102
|
+
|
|
103
|
+
# ── credential management ──────────────────────────────────────────────
|
|
104
|
+
def list_credentials(self, external_username: str) -> Dict[str, Any]:
|
|
105
|
+
"""List a user's registered credentials (metadata only, no key material)."""
|
|
106
|
+
return self._http.get('/webauthn/credentials', params={'externalUsername': external_username})
|
|
107
|
+
|
|
108
|
+
def delete_credential(self, credential_record_id: str, external_username: Optional[str] = None) -> Dict[str, Any]:
|
|
109
|
+
"""Remove a registered credential. Pass ``external_username`` to scope
|
|
110
|
+
the deletion to that user (recommended: without it, any credential id
|
|
111
|
+
your API key can reach is deletable). Returns ``{ deleted: bool }``."""
|
|
112
|
+
path = f"/webauthn/credentials/{quote(credential_record_id, safe='')}"
|
|
113
|
+
if external_username:
|
|
114
|
+
path += f"?externalUsername={quote(external_username, safe='')}"
|
|
115
|
+
return self._http.delete(path)
|