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,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)