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