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,205 @@
|
|
|
1
|
+
"""Shared Signals (OpenID Shared Signals Framework 1.0): Security Event Tokens.
|
|
2
|
+
|
|
3
|
+
An identity provider sends a SET (RFC 8417) when something happens to a
|
|
4
|
+
person's account after sign-in: CAEP session-revoked, credential-change,
|
|
5
|
+
device-compliance-change; RISC account-disabled, credential-compromise.
|
|
6
|
+
TouchQue SSO sends them for an organization's members to the receivers its
|
|
7
|
+
admin adds (Organization > Continuous access). Your app ends the person's
|
|
8
|
+
sessions or asks them to sign in again.
|
|
9
|
+
|
|
10
|
+
Push delivery (RFC 8935), the SET is the request body (application/secevent+jwt)::
|
|
11
|
+
|
|
12
|
+
verifier = SecurityEventVerifier(issuer='https://sso.touchque.com', audience='https://app.example.com')
|
|
13
|
+
|
|
14
|
+
@app.post('/ssf')
|
|
15
|
+
def ssf():
|
|
16
|
+
try:
|
|
17
|
+
event = verifier.verify(request.get_data(as_text=True))
|
|
18
|
+
except SecurityEventError as e:
|
|
19
|
+
return {'err': e.code, 'description': str(e)}, 400
|
|
20
|
+
if event.name == 'session-revoked':
|
|
21
|
+
end_sessions_of(event.subject.get('email'))
|
|
22
|
+
return '', 202
|
|
23
|
+
|
|
24
|
+
Poll delivery (RFC 8936): ``poll_security_events(url=..., token=..., ack=[...])``.
|
|
25
|
+
|
|
26
|
+
A SET does not expire: it says what happened. Keep the jtis you handled and
|
|
27
|
+
skip a repeat. Needs the optional ``cryptography`` package:
|
|
28
|
+
``pip install "touchque-authenticator[agent]"``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import re
|
|
32
|
+
import threading
|
|
33
|
+
import time
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import Any, Dict, List, Optional
|
|
36
|
+
|
|
37
|
+
import requests
|
|
38
|
+
|
|
39
|
+
from .exceptions import TouchQueConfigException
|
|
40
|
+
from .mcp_auth import _json_part, _b64d, _public_key, _verify
|
|
41
|
+
from .cross_app_access import _get_json, _safe_url
|
|
42
|
+
|
|
43
|
+
SET_TYP = 'secevent+jwt'
|
|
44
|
+
_NAMESPACE = re.compile(r'^https://schemas\.openid\.net/secevent/(caep|risc|ssf)/event-type/([a-z0-9-]+)$')
|
|
45
|
+
|
|
46
|
+
#: The event types TouchQue sends, by their short name.
|
|
47
|
+
SECURITY_EVENT_TYPES = {
|
|
48
|
+
'session-revoked': 'https://schemas.openid.net/secevent/caep/event-type/session-revoked',
|
|
49
|
+
'credential-change': 'https://schemas.openid.net/secevent/caep/event-type/credential-change',
|
|
50
|
+
'device-compliance-change': 'https://schemas.openid.net/secevent/caep/event-type/device-compliance-change',
|
|
51
|
+
'account-disabled': 'https://schemas.openid.net/secevent/risc/event-type/account-disabled',
|
|
52
|
+
'credential-compromise': 'https://schemas.openid.net/secevent/risc/event-type/credential-compromise',
|
|
53
|
+
'verification': 'https://schemas.openid.net/secevent/ssf/event-type/verification',
|
|
54
|
+
'stream-updated': 'https://schemas.openid.net/secevent/ssf/event-type/stream-updated',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SecurityEventError(Exception):
|
|
59
|
+
"""A refused SET. ``code`` is the RFC 8935 error to answer with (HTTP 400 ``{"err": code}``):
|
|
60
|
+
invalid_request, invalid_key, invalid_issuer or invalid_audience; temporarily_unavailable
|
|
61
|
+
when the issuer's keys could not be read (answer 503: the transmitter tries again)."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, code: str, message: str):
|
|
64
|
+
super().__init__(message)
|
|
65
|
+
self.code = code
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class SecurityEvent:
|
|
70
|
+
jti: str
|
|
71
|
+
issuer: str
|
|
72
|
+
audience: List[str]
|
|
73
|
+
#: Seconds since the epoch.
|
|
74
|
+
issued_at: int
|
|
75
|
+
#: TouchQue puts the case code of its decision here ("Why?" in the dashboard).
|
|
76
|
+
txn: Optional[str]
|
|
77
|
+
#: The subject as sent (sub_id): {'format': 'email', 'email': ...} for a person.
|
|
78
|
+
subject: Dict[str, Any]
|
|
79
|
+
#: The event type URI.
|
|
80
|
+
type: str
|
|
81
|
+
#: Its short name (session-revoked ...) for CAEP, RISC and SSF types; None for others.
|
|
82
|
+
name: Optional[str]
|
|
83
|
+
#: The event's own claims (event_timestamp, reason_admin, credential_type ...).
|
|
84
|
+
claims: Dict[str, Any]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SecurityEventVerifier:
|
|
88
|
+
"""Checks a SET: signature (RS256, the issuer's keys), typ, issuer, audience, one event."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, issuer: str, audience: str, jwks_uri: Optional[str] = None, jwks: Optional[Dict[str, Any]] = None,
|
|
91
|
+
clock_skew: int = 300):
|
|
92
|
+
if not issuer or not audience:
|
|
93
|
+
raise TouchQueConfigException("SecurityEventVerifier needs issuer (the transmitter) and audience (your stream's aud)")
|
|
94
|
+
self.issuer = issuer
|
|
95
|
+
self.audience = audience
|
|
96
|
+
self.jwks_uri = jwks_uri
|
|
97
|
+
self.skew = clock_skew
|
|
98
|
+
self._pinned = jwks is not None
|
|
99
|
+
self._keys: Dict[str, Any] = {}
|
|
100
|
+
self._keys_at = 0.0
|
|
101
|
+
self._tried_at = 0.0
|
|
102
|
+
self._lock = threading.Lock()
|
|
103
|
+
if jwks is not None:
|
|
104
|
+
self._set_keys(jwks)
|
|
105
|
+
|
|
106
|
+
def _set_keys(self, jwks: Dict[str, Any]) -> None:
|
|
107
|
+
self._keys = {k['kid']: _public_key(k) for k in jwks.get('keys', []) if k.get('kid')}
|
|
108
|
+
self._keys_at = time.time()
|
|
109
|
+
|
|
110
|
+
def _load_keys(self) -> None:
|
|
111
|
+
if not self.jwks_uri:
|
|
112
|
+
meta = _get_json(self.issuer.rstrip('/') + '/.well-known/ssf-configuration')
|
|
113
|
+
if meta.get('issuer') != self.issuer or not isinstance(meta.get('jwks_uri'), str):
|
|
114
|
+
raise RuntimeError("the transmitter's metadata does not match its issuer")
|
|
115
|
+
self.jwks_uri = meta['jwks_uri']
|
|
116
|
+
if not _safe_url(self.jwks_uri):
|
|
117
|
+
raise RuntimeError(f'{self.jwks_uri} must be an https URL')
|
|
118
|
+
self._set_keys(_get_json(self.jwks_uri))
|
|
119
|
+
|
|
120
|
+
def _key_for(self, kid: str):
|
|
121
|
+
if self._pinned:
|
|
122
|
+
return self._keys.get(kid)
|
|
123
|
+
with self._lock:
|
|
124
|
+
if not self._keys_at or time.time() - self._keys_at > 3600:
|
|
125
|
+
self._load_keys()
|
|
126
|
+
if kid not in self._keys and time.time() - self._tried_at > 300:
|
|
127
|
+
self._tried_at = time.time()
|
|
128
|
+
self._load_keys()
|
|
129
|
+
return self._keys.get(kid)
|
|
130
|
+
|
|
131
|
+
def verify(self, token: str) -> SecurityEvent:
|
|
132
|
+
parts = token.strip().split('.') if isinstance(token, str) else []
|
|
133
|
+
if len(parts) != 3:
|
|
134
|
+
raise SecurityEventError('invalid_request', 'not a JWT')
|
|
135
|
+
try:
|
|
136
|
+
head = _json_part(parts[0])
|
|
137
|
+
c = _json_part(parts[1])
|
|
138
|
+
except Exception:
|
|
139
|
+
raise SecurityEventError('invalid_request', 'not a JWT')
|
|
140
|
+
if str(head.get('typ') or '').lower() != SET_TYP:
|
|
141
|
+
raise SecurityEventError('invalid_request', 'not a Security Event Token (typ secevent+jwt)')
|
|
142
|
+
if head.get('alg') != 'RS256' or not isinstance(head.get('kid'), str):
|
|
143
|
+
raise SecurityEventError('invalid_key', 'unsupported signature')
|
|
144
|
+
try:
|
|
145
|
+
key = self._key_for(head['kid'])
|
|
146
|
+
except Exception as e:
|
|
147
|
+
raise SecurityEventError('temporarily_unavailable', f"the transmitter's keys could not be read: {e}")
|
|
148
|
+
try:
|
|
149
|
+
sig = _b64d(parts[2])
|
|
150
|
+
except Exception:
|
|
151
|
+
raise SecurityEventError('invalid_key', 'signature does not verify')
|
|
152
|
+
if key is None or not _verify(f'{parts[0]}.{parts[1]}'.encode('ascii'), sig, 'RS256', key):
|
|
153
|
+
raise SecurityEventError('invalid_key', 'signature does not verify')
|
|
154
|
+
if c.get('iss') != self.issuer:
|
|
155
|
+
raise SecurityEventError('invalid_issuer', 'another issuer')
|
|
156
|
+
aud = c.get('aud')
|
|
157
|
+
audience = [a for a in (aud if isinstance(aud, list) else [aud]) if isinstance(a, str)]
|
|
158
|
+
if self.audience not in audience:
|
|
159
|
+
raise SecurityEventError('invalid_audience', 'not for this receiver')
|
|
160
|
+
iat = c.get('iat')
|
|
161
|
+
if not isinstance(c.get('jti'), str) or not isinstance(iat, int) or isinstance(iat, bool):
|
|
162
|
+
raise SecurityEventError('invalid_request', 'jti and iat are required')
|
|
163
|
+
if iat - self.skew > int(time.time()):
|
|
164
|
+
raise SecurityEventError('invalid_request', 'issued in the future')
|
|
165
|
+
events = c.get('events')
|
|
166
|
+
if not isinstance(events, dict) or len(events) != 1:
|
|
167
|
+
raise SecurityEventError('invalid_request', 'a SET carries exactly one event')
|
|
168
|
+
(type_, claims), = events.items()
|
|
169
|
+
m = _NAMESPACE.match(type_)
|
|
170
|
+
sub = c.get('sub_id')
|
|
171
|
+
return SecurityEvent(
|
|
172
|
+
jti=c['jti'], issuer=c['iss'], audience=audience, issued_at=iat, txn=c['txn'] if isinstance(c.get('txn'), str) else None,
|
|
173
|
+
subject=sub if isinstance(sub, dict) else {}, type=type_, name=m.group(2) if m else None,
|
|
174
|
+
claims=claims if isinstance(claims, dict) else {},
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def poll_security_events(*, url: str, token: str, ack: Optional[List[str]] = None,
|
|
179
|
+
set_errs: Optional[Dict[str, Dict[str, str]]] = None, max_events: Optional[int] = None,
|
|
180
|
+
return_immediately: Optional[bool] = None) -> Dict[str, Any]:
|
|
181
|
+
"""One poll (RFC 8936): acknowledges what you handled and returns
|
|
182
|
+
``{'sets': {jti: token}, 'more_available': bool}``."""
|
|
183
|
+
if not _safe_url(url):
|
|
184
|
+
raise TouchQueConfigException(f'{url} must be an https URL')
|
|
185
|
+
if not token:
|
|
186
|
+
raise TouchQueConfigException("poll_security_events needs the stream's token")
|
|
187
|
+
body: Dict[str, Any] = {}
|
|
188
|
+
if ack is not None:
|
|
189
|
+
body['ack'] = ack
|
|
190
|
+
if set_errs is not None:
|
|
191
|
+
body['setErrs'] = set_errs
|
|
192
|
+
if max_events is not None:
|
|
193
|
+
body['maxEvents'] = max_events
|
|
194
|
+
if return_immediately is not None:
|
|
195
|
+
body['returnImmediately'] = return_immediately
|
|
196
|
+
r = requests.post(url, json=body, timeout=30, allow_redirects=False,
|
|
197
|
+
headers={'Accept': 'application/json', 'Authorization': f'Bearer {token}'})
|
|
198
|
+
try:
|
|
199
|
+
data = r.json()
|
|
200
|
+
except ValueError:
|
|
201
|
+
data = {}
|
|
202
|
+
if r.status_code != 200:
|
|
203
|
+
raise SecurityEventError(str(data.get('error') or 'server_error'), f'{url} answered {r.status_code}')
|
|
204
|
+
sets = data.get('sets')
|
|
205
|
+
return {'sets': sets if isinstance(sets, dict) else {}, 'more_available': data.get('moreAvailable') is True}
|
touchque/steps.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The headless step-up flow shared by every integration (Django, FastAPI,
|
|
3
|
+
Flask, or your own framework): start -> show the step in YOUR UI -> check ->
|
|
4
|
+
complete exactly once.
|
|
5
|
+
|
|
6
|
+
A "step" is plain JSON-serializable data: what state the approval is in and
|
|
7
|
+
whatever the user has to see (the matching number, the enrollment QR code,
|
|
8
|
+
the offline QR code). It never contains API secrets.
|
|
9
|
+
"""
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Dict, List, Optional, Union
|
|
16
|
+
|
|
17
|
+
from .exceptions import TouchQueAPIException, TouchQueConfigException, TouchQueException
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .client import TouchQue
|
|
21
|
+
|
|
22
|
+
LoginDetails = Union[Dict[str, Any], List[Dict[str, Any]]]
|
|
23
|
+
|
|
24
|
+
# States: waiting | approved | rejected | expired | enroll | passkey_required
|
|
25
|
+
# | offline | blocked | frozen | rate_limited
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalize_details(details: Optional[LoginDetails]) -> List[Dict[str, str]]:
|
|
29
|
+
"""Same normalization the API does: a dict or a list of {label, value} -> [{label, value}] strings."""
|
|
30
|
+
if not details:
|
|
31
|
+
return []
|
|
32
|
+
if isinstance(details, list):
|
|
33
|
+
pairs = [(d['label'], d['value']) for d in details]
|
|
34
|
+
else:
|
|
35
|
+
pairs = list(details.items())
|
|
36
|
+
return [{'label': str(label).strip(), 'value': str(value).strip()} for label, value in pairs]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def details_digest(details: Optional[LoginDetails]) -> str:
|
|
40
|
+
pairs = normalize_details(details)
|
|
41
|
+
if not pairs:
|
|
42
|
+
return ''
|
|
43
|
+
canonical = '\x1e'.join(f"{p['label']}\x1f{p['value']}" for p in pairs)
|
|
44
|
+
return hashlib.sha256(canonical.encode('utf-8')).hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _err_code(err: TouchQueAPIException) -> Optional[str]:
|
|
48
|
+
return err.code or (err.data or {}).get('error')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _why(res: Dict[str, Any]) -> Dict[str, Any]:
|
|
52
|
+
""""Why?": the case code (``K7P-2QX``) and plain reasons of a login request's
|
|
53
|
+
answer, for its step. Both are safe for the browser: show the code with the
|
|
54
|
+
step ("Tell your support team: K7P-2QX")."""
|
|
55
|
+
out: Dict[str, Any] = {}
|
|
56
|
+
if isinstance(res.get('caseCode'), str) and res['caseCode']:
|
|
57
|
+
out['caseCode'] = res['caseCode']
|
|
58
|
+
reasons = [{'code': r['code'], 'text': r.get('text') if isinstance(r.get('text'), str) else ''}
|
|
59
|
+
for r in (res.get('reasons') or []) if isinstance(r, dict) and isinstance(r.get('code'), str)]
|
|
60
|
+
if reasons:
|
|
61
|
+
out['reasons'] = reasons
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def start(client: 'TouchQue', action: str, user: str, details: Optional[LoginDetails] = None,
|
|
66
|
+
reference_id: Optional[str] = None, ip: Optional[str] = None, user_agent: Optional[str] = None,
|
|
67
|
+
enroll: bool = True, browser_proof: Optional[str] = None,
|
|
68
|
+
context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
69
|
+
"""Starts an approval for `action` — never waits.
|
|
70
|
+
|
|
71
|
+
``browser_proof`` (silent sign-ins): the browser's device-bound key proof
|
|
72
|
+
(``X-TouchQue-Browser``), passed through untouched. Once the user
|
|
73
|
+
approves, TouchQue knows this browser."""
|
|
74
|
+
if not user:
|
|
75
|
+
raise TouchQueConfigException('start() needs the user id')
|
|
76
|
+
norm = normalize_details(details)
|
|
77
|
+
try:
|
|
78
|
+
res = client.login.request(
|
|
79
|
+
user, type=action, reference_id=reference_id, client_ip=ip, user_agent=user_agent,
|
|
80
|
+
details=norm or None, browser_proof=browser_proof, context=context,
|
|
81
|
+
)
|
|
82
|
+
canary = res.get('canary')
|
|
83
|
+
canary = {'url': canary['url']} if isinstance(canary, dict) and isinstance(canary.get('url'), str) else None
|
|
84
|
+
if res.get('requiresPasskey'):
|
|
85
|
+
step = {'state': 'passkey_required', 'requestId': res.get('requestId'),
|
|
86
|
+
'expiresAt': res.get('expiresAt'), 'details': norm or None, **_why(res)}
|
|
87
|
+
if canary:
|
|
88
|
+
step['canary'] = canary
|
|
89
|
+
return step
|
|
90
|
+
step = {
|
|
91
|
+
'state': 'waiting', 'requestId': res.get('requestId'), 'number': res.get('challengeCode'),
|
|
92
|
+
'expiresAt': res.get('expiresAt'), 'details': norm or None, **_why(res),
|
|
93
|
+
}
|
|
94
|
+
if res.get('telemetryToken'):
|
|
95
|
+
# Only when the workspace collects behavioural signals: lets the browser post
|
|
96
|
+
# them for this one request (@touchque/web does it when given `telemetry`).
|
|
97
|
+
step['telemetryToken'] = res['telemetryToken']
|
|
98
|
+
if canary:
|
|
99
|
+
# Mirror Alarm: the page showing this step loads the canary (@touchque/web does it), so
|
|
100
|
+
# a phishing proxy in front of your site gets the approval refused. Only on this first step.
|
|
101
|
+
step['canary'] = canary
|
|
102
|
+
return step
|
|
103
|
+
except TouchQueAPIException as err:
|
|
104
|
+
code = _err_code(err)
|
|
105
|
+
why = {'caseCode': err.case_code} if err.case_code else {}
|
|
106
|
+
if err.status == 404 and (code == 'device_not_linked' or 'linked device' in str(err).lower()):
|
|
107
|
+
if not enroll:
|
|
108
|
+
return {'state': 'enroll'}
|
|
109
|
+
return _enroll_step(client, user)
|
|
110
|
+
if err.status == 423:
|
|
111
|
+
return {'state': 'frozen', 'retryAfter': err.retry_after, **why}
|
|
112
|
+
if err.status == 429:
|
|
113
|
+
return {'state': 'rate_limited', 'retryAfter': err.retry_after, **why}
|
|
114
|
+
if err.status == 400 and (code == 'unknown_action' or 'invalid action type' in str(err).lower()):
|
|
115
|
+
raise TouchQueConfigException(
|
|
116
|
+
f'Unknown action "{action}". Create it once with client.action_types.define("{action}") '
|
|
117
|
+
'or in the Dashboard (Action Types).'
|
|
118
|
+
) from err
|
|
119
|
+
if err.status == 403:
|
|
120
|
+
reason = (err.reason if code == 'blocked' else None) \
|
|
121
|
+
or ('passkey_not_registered' if code in ('passkey_not_registered',) or (err.data or {}).get('error') == 'phishing_resistant_required' else None) \
|
|
122
|
+
or ('action_disabled' if code == 'action_disabled' else None) \
|
|
123
|
+
or code or 'blocked'
|
|
124
|
+
return {'state': 'blocked', 'reason': reason, **why}
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _enroll_step(client: 'TouchQue', user: str) -> Dict[str, Any]:
|
|
129
|
+
"""First-time linking. Never unlinks a phone: a secret is only re-issued
|
|
130
|
+
when the account is confirmed NOT linked."""
|
|
131
|
+
try:
|
|
132
|
+
gen = client.auth.generate_secret(user)
|
|
133
|
+
return {'state': 'enroll', 'enroll': {
|
|
134
|
+
'qrCodeDataUrl': gen.get('qrCodeDataUrl'), 'recoveryCodes': gen.get('recoveryCodes'),
|
|
135
|
+
'expiresAt': gen.get('expiresAt'),
|
|
136
|
+
}}
|
|
137
|
+
except TouchQueAPIException as err:
|
|
138
|
+
if err.status != 409:
|
|
139
|
+
raise
|
|
140
|
+
try:
|
|
141
|
+
current = client.auth.get_user(user)
|
|
142
|
+
except TouchQueAPIException:
|
|
143
|
+
current = None
|
|
144
|
+
if current and (current.get('used') or current.get('deviceId')):
|
|
145
|
+
return {'state': 'enroll', 'reason': 'already_linked'}
|
|
146
|
+
reset = client.auth.reset_secret(user)
|
|
147
|
+
return {'state': 'enroll', 'enroll': {
|
|
148
|
+
'qrCodeDataUrl': reset.get('qrCodeDataUrl'), 'recoveryCodes': reset.get('recoveryCodes'),
|
|
149
|
+
'expiresAt': reset.get('expiresAt'),
|
|
150
|
+
}}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
_STATE_MAP = {'PENDING': 'waiting', 'CONFIRMED': 'approved', 'REJECTED': 'rejected', 'EXPIRED': 'expired'}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def check(client: 'TouchQue', request_id: str) -> Dict[str, Any]:
|
|
157
|
+
"""Current state of a started approval."""
|
|
158
|
+
s = client.login.status(request_id)
|
|
159
|
+
state = 'passkey_required' if s.get('status') == 'PENDING' and s.get('requiresPasskey') \
|
|
160
|
+
else _STATE_MAP.get(s.get('status'), 'waiting')
|
|
161
|
+
step: Dict[str, Any] = {'state': state, 'requestId': request_id}
|
|
162
|
+
if state == 'approved' and s.get('assurance'):
|
|
163
|
+
step['assurance'] = s['assurance']
|
|
164
|
+
return step
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def complete(client: 'TouchQue', request_id: str, user: str, action: str,
|
|
168
|
+
details: Optional[LoginDetails] = None, reference_id: Optional[str] = None) -> Dict[str, Any]:
|
|
169
|
+
"""Uses an approved request exactly once and checks it is for THIS user,
|
|
170
|
+
action and transaction. Raises TouchQueException if it was already used,
|
|
171
|
+
not approved, or approved for something else."""
|
|
172
|
+
res = client.login.consume(request_id)
|
|
173
|
+
same_user = str(res.get('externalUsername', '')).lower() == str(user).lower()
|
|
174
|
+
same_details = details_digest(details) == details_digest(res.get('details') or [])
|
|
175
|
+
same_ref = (res.get('referenceId') or None) == (reference_id or None)
|
|
176
|
+
if not same_user or res.get('type') != action or not same_details or not same_ref:
|
|
177
|
+
raise TouchQueException('TouchQue: this approval is for a different user, action or transaction.')
|
|
178
|
+
approval = {
|
|
179
|
+
'requestId': request_id,
|
|
180
|
+
'user': res.get('externalUsername'),
|
|
181
|
+
'action': res.get('type'),
|
|
182
|
+
'assurance': res.get('assurance'),
|
|
183
|
+
'confirmedVia': res.get('confirmedVia'),
|
|
184
|
+
'approvalProof': res.get('approvalProof'),
|
|
185
|
+
}
|
|
186
|
+
if 'provisional' in res:
|
|
187
|
+
# Vishing Shield: approved during a call. Let the user in, but treat the session as limited:
|
|
188
|
+
# critical actions are refused until APPROVAL_RECONFIRMED, and APPROVAL_REVOKED means end it.
|
|
189
|
+
approval.update({
|
|
190
|
+
'provisional': bool(res.get('provisional')), 'revoked': bool(res.get('revoked')),
|
|
191
|
+
'revokeReason': res.get('revokeReason'), 'reconfirmedAt': res.get('reconfirmedAt'),
|
|
192
|
+
})
|
|
193
|
+
return approval
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── Guard token ──────────────────────────────────────────────────────────
|
|
197
|
+
# The browser echoes this back while it waits. It is signed with a key derived
|
|
198
|
+
# from the API secret and binds the approval to one user, action and
|
|
199
|
+
# transaction, so it cannot be replayed for another user, amount or route.
|
|
200
|
+
|
|
201
|
+
def _token_key(api_secret: str) -> bytes:
|
|
202
|
+
return hmac.new(api_secret.encode('utf-8'), b'touchque-guard-token-v1', hashlib.sha256).digest()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _b64u(data: bytes) -> str:
|
|
206
|
+
return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii')
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _from_b64u(s: str) -> bytes:
|
|
210
|
+
pad = '=' * (-len(s) % 4)
|
|
211
|
+
return base64.urlsafe_b64decode(s + pad)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def sign_guard_token(api_secret: str, claims: Dict[str, Any]) -> str:
|
|
215
|
+
body = _b64u(json.dumps(claims, separators=(',', ':')).encode('utf-8'))
|
|
216
|
+
mac = _b64u(hmac.new(_token_key(api_secret), body.encode('ascii'), hashlib.sha256).digest())
|
|
217
|
+
return f"v1.{body}.{mac}"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def verify_guard_token(api_secret: str, token: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
221
|
+
if not isinstance(token, str) or len(token) > 4096:
|
|
222
|
+
return None
|
|
223
|
+
parts = token.split('.')
|
|
224
|
+
if len(parts) != 3 or parts[0] != 'v1':
|
|
225
|
+
return None
|
|
226
|
+
_, body, mac = parts
|
|
227
|
+
# A header may carry any latin-1 text; only an ASCII token can be one we signed.
|
|
228
|
+
if not body.isascii() or not mac.isascii():
|
|
229
|
+
return None
|
|
230
|
+
expected = _b64u(hmac.new(_token_key(api_secret), body.encode('ascii'), hashlib.sha256).digest())
|
|
231
|
+
if not hmac.compare_digest(expected, mac):
|
|
232
|
+
return None
|
|
233
|
+
try:
|
|
234
|
+
claims = json.loads(_from_b64u(body))
|
|
235
|
+
except (ValueError, UnicodeDecodeError):
|
|
236
|
+
return None
|
|
237
|
+
if not isinstance(claims.get('exp'), (int, float)) or claims['exp'] < time.time() * 1000:
|
|
238
|
+
return None
|
|
239
|
+
return claims
|