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
touchque/__init__.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import logging as _logging
|
|
2
|
+
|
|
3
|
+
from .config import Config
|
|
4
|
+
from .client import TouchQue, get_default_client
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
TouchQueException,
|
|
7
|
+
TouchQueAPIException,
|
|
8
|
+
TouchQueFeatureException,
|
|
9
|
+
TouchQueFeatureNotInPlanException,
|
|
10
|
+
TouchQueFeatureReadOnlyException,
|
|
11
|
+
TouchQueFeatureOffException,
|
|
12
|
+
TouchQueNetworkException,
|
|
13
|
+
TouchQueTimeoutException,
|
|
14
|
+
TouchQueRejectedException,
|
|
15
|
+
TouchQueWebhookSignatureException,
|
|
16
|
+
TouchQueWebhookReplayException,
|
|
17
|
+
TouchQueConfigException,
|
|
18
|
+
TouchQuePasskeyRequiredError,
|
|
19
|
+
TouchQueApprovalError,
|
|
20
|
+
TouchQueApprovalMismatchError,
|
|
21
|
+
)
|
|
22
|
+
from .resources.webhook import ReplayCache, MemoryReplayCache, WebhookReasons, WebhookEvents
|
|
23
|
+
from .steps import normalize_details, details_digest
|
|
24
|
+
from .approval_proof import verify_approval_proof
|
|
25
|
+
from .code_reply import new_code_reply_key, open_code_reply
|
|
26
|
+
from .safe_guard import (
|
|
27
|
+
safe_approval_message, safe_display_digest, safe_governance_hash, safe_schedule_call, safe_tx_hash, verify_safe_approval,
|
|
28
|
+
with_safe_approvals,
|
|
29
|
+
)
|
|
30
|
+
from .guard import run_guard, guard_input_from_headers, silent_input_from_headers, BROWSER_HEADER
|
|
31
|
+
from .resources.risk import parse_risk_check
|
|
32
|
+
from .resources.recovery import is_recovery_open
|
|
33
|
+
from .resources.mobile import is_mobile_push_expired, mobile_push_from_webhook
|
|
34
|
+
from .resources.assist import is_assist_check_passed
|
|
35
|
+
from .resources.qr_pairings import QR_PAIRING_REFUSAL_REASONS, qr_pairing_uri
|
|
36
|
+
from .resources.device_risk import DEVICE_RISK_EVENTS, DEVICE_RISK_REPORTS, parse_device_risk_check
|
|
37
|
+
from .resources.action_types import SECOND_THOUGHT_QUESTIONS
|
|
38
|
+
from .relay import RelayHandler, RelayResponse, RELAY_ROUTES
|
|
39
|
+
from .rate_limiter import FixedWindowLimiter
|
|
40
|
+
from .agent import TouchQueAgent, generate_agent_key, jwk_thumbprint
|
|
41
|
+
from .agent_frameworks import (
|
|
42
|
+
ApprovalPolicy, approve_interruptions_with_touchque, current_approval, guard_mcp_session, require_agent_approval,
|
|
43
|
+
touchque_can_use_tool, with_touchque_approval,
|
|
44
|
+
)
|
|
45
|
+
from .org_agent import TouchQueOrgAgent, file_token, github_actions_token
|
|
46
|
+
from .mcp_auth import McpAuth, McpAuthError
|
|
47
|
+
from .cross_app_access import (
|
|
48
|
+
ID_JAG_TOKEN_TYPE, IdJagError, IdJagVerifier, redeem_id_jag, request_id_jag,
|
|
49
|
+
)
|
|
50
|
+
from .security_events import (
|
|
51
|
+
SECURITY_EVENT_TYPES, SecurityEvent, SecurityEventError, SecurityEventVerifier, poll_security_events,
|
|
52
|
+
)
|
|
53
|
+
from .agent_card import AgentCardError, canonical_json, sign_agent_card, verify_agent_card
|
|
54
|
+
from .approval_token import matches_authorization_details, verify_approval_token_with
|
|
55
|
+
from .hosted_login import HostedLogin, HostedLoginError, AuthorizationRequest, TokenSet, UserApprovalRequest, CONNECTION_TOKEN_TYPE
|
|
56
|
+
from .my_account import MyAccount, MyAccountError
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
'TouchQue',
|
|
60
|
+
'Config',
|
|
61
|
+
'get_default_client',
|
|
62
|
+
'TouchQueException',
|
|
63
|
+
'TouchQueAPIException',
|
|
64
|
+
'TouchQueFeatureException',
|
|
65
|
+
'TouchQueFeatureNotInPlanException',
|
|
66
|
+
'TouchQueFeatureReadOnlyException',
|
|
67
|
+
'TouchQueFeatureOffException',
|
|
68
|
+
'TouchQueNetworkException',
|
|
69
|
+
'TouchQueTimeoutException',
|
|
70
|
+
'TouchQueRejectedException',
|
|
71
|
+
'TouchQueWebhookSignatureException',
|
|
72
|
+
'TouchQueWebhookReplayException',
|
|
73
|
+
'ReplayCache',
|
|
74
|
+
'MemoryReplayCache',
|
|
75
|
+
'WebhookReasons',
|
|
76
|
+
'WebhookEvents',
|
|
77
|
+
'TouchQueConfigException',
|
|
78
|
+
'TouchQuePasskeyRequiredError',
|
|
79
|
+
'normalize_details',
|
|
80
|
+
'details_digest',
|
|
81
|
+
'verify_approval_proof',
|
|
82
|
+
'new_code_reply_key',
|
|
83
|
+
'open_code_reply',
|
|
84
|
+
'safe_tx_hash',
|
|
85
|
+
'safe_governance_hash',
|
|
86
|
+
'safe_approval_message',
|
|
87
|
+
'safe_display_digest',
|
|
88
|
+
'verify_safe_approval',
|
|
89
|
+
'with_safe_approvals',
|
|
90
|
+
'safe_schedule_call',
|
|
91
|
+
'run_guard',
|
|
92
|
+
'guard_input_from_headers',
|
|
93
|
+
'silent_input_from_headers',
|
|
94
|
+
'BROWSER_HEADER',
|
|
95
|
+
'parse_risk_check',
|
|
96
|
+
'is_recovery_open',
|
|
97
|
+
'is_mobile_push_expired',
|
|
98
|
+
'mobile_push_from_webhook',
|
|
99
|
+
'is_assist_check_passed',
|
|
100
|
+
'QR_PAIRING_REFUSAL_REASONS',
|
|
101
|
+
'qr_pairing_uri',
|
|
102
|
+
'DEVICE_RISK_EVENTS',
|
|
103
|
+
'DEVICE_RISK_REPORTS',
|
|
104
|
+
'parse_device_risk_check',
|
|
105
|
+
'SECOND_THOUGHT_QUESTIONS',
|
|
106
|
+
'RelayHandler',
|
|
107
|
+
'RelayResponse',
|
|
108
|
+
'RELAY_ROUTES',
|
|
109
|
+
'FixedWindowLimiter',
|
|
110
|
+
'TouchQueAgent',
|
|
111
|
+
'ApprovalPolicy',
|
|
112
|
+
'with_touchque_approval',
|
|
113
|
+
'current_approval',
|
|
114
|
+
'require_agent_approval',
|
|
115
|
+
'approve_interruptions_with_touchque',
|
|
116
|
+
'touchque_can_use_tool',
|
|
117
|
+
'guard_mcp_session',
|
|
118
|
+
'generate_agent_key',
|
|
119
|
+
'TouchQueOrgAgent',
|
|
120
|
+
'github_actions_token',
|
|
121
|
+
'file_token',
|
|
122
|
+
'McpAuth',
|
|
123
|
+
'McpAuthError',
|
|
124
|
+
'ID_JAG_TOKEN_TYPE',
|
|
125
|
+
'IdJagError',
|
|
126
|
+
'IdJagVerifier',
|
|
127
|
+
'SECURITY_EVENT_TYPES', 'SecurityEvent', 'SecurityEventError', 'SecurityEventVerifier', 'poll_security_events',
|
|
128
|
+
'request_id_jag',
|
|
129
|
+
'redeem_id_jag',
|
|
130
|
+
'AgentCardError',
|
|
131
|
+
'canonical_json',
|
|
132
|
+
'sign_agent_card',
|
|
133
|
+
'verify_agent_card',
|
|
134
|
+
'jwk_thumbprint',
|
|
135
|
+
'HostedLogin',
|
|
136
|
+
'CONNECTION_TOKEN_TYPE',
|
|
137
|
+
'HostedLoginError',
|
|
138
|
+
'UserApprovalRequest',
|
|
139
|
+
'MyAccount',
|
|
140
|
+
'MyAccountError',
|
|
141
|
+
'AuthorizationRequest',
|
|
142
|
+
'TokenSet',
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
# The SDK's own problems (a misconfigured guard, a failed on_challenge, a relay without
|
|
146
|
+
# get_login_user) go to logging.getLogger('touchque'). Silent by default: nothing is printed
|
|
147
|
+
# unless your app configures logging (logging.basicConfig, or a handler on 'touchque').
|
|
148
|
+
_logging.getLogger('touchque').addHandler(_logging.NullHandler())
|
touchque/agent.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""The agent side: an AI agent calls TouchQue with its OWN key, never with your
|
|
2
|
+
API key. ``TouchQueAgent`` signs a private_key_jwt assertion (RFC 7523) for a
|
|
3
|
+
10-minute token bound to its key with DPoP (RFC 9449), and proves the key on
|
|
4
|
+
every call, so a stolen token is useless on its own.
|
|
5
|
+
|
|
6
|
+
agent = TouchQueAgent() # TQ_AGENT_ID, TQ_AGENT_KEY (a JWK as JSON, or PEM)
|
|
7
|
+
me = agent.me()
|
|
8
|
+
|
|
9
|
+
Needs the optional ``cryptography`` package: ``pip install "touchque-authenticator[agent]"``.
|
|
10
|
+
"""
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from typing import Any, Dict, List, Optional, Union
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
from .exceptions import (
|
|
25
|
+
TouchQueAPIException, TouchQueApprovalError, TouchQueConfigException, TouchQueNetworkException, TouchQueTimeoutException,
|
|
26
|
+
api_exception,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = ['TouchQueAgent', 'generate_agent_key', 'jwk_thumbprint', 'AGENT_ASSERTION_TYPE']
|
|
30
|
+
|
|
31
|
+
AGENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
|
32
|
+
_AGENT_ID = re.compile(r'^agt_[a-z2-9]{20}$')
|
|
33
|
+
_LOCAL_HOSTS = {'localhost', '127.0.0.1', '::1'}
|
|
34
|
+
_ENDED = {'rejected', 'blocked', 'revoked', 'expired', 'stale', 'redeemed'}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _b64url(data: bytes) -> str:
|
|
38
|
+
return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii')
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _b64url_decode(text: str) -> bytes:
|
|
42
|
+
return base64.urlsafe_b64decode(text + '=' * (-len(text) % 4))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _json(obj: Dict[str, Any]) -> str:
|
|
46
|
+
return json.dumps(obj, separators=(',', ':'))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def jwk_thumbprint(jwk: Dict[str, Any]) -> str:
|
|
50
|
+
"""RFC 7638 thumbprint of a P-256 or Ed25519 public key (the default kid)."""
|
|
51
|
+
if jwk.get('kty') == 'EC':
|
|
52
|
+
members = {'crv': jwk['crv'], 'kty': 'EC', 'x': jwk['x'], 'y': jwk['y']}
|
|
53
|
+
else:
|
|
54
|
+
members = {'crv': jwk['crv'], 'kty': jwk['kty'], 'x': jwk['x']}
|
|
55
|
+
return _b64url(hashlib.sha256(_json(members).encode('utf-8')).digest())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _crypto():
|
|
59
|
+
try:
|
|
60
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
61
|
+
from cryptography.hazmat.primitives.asymmetric import ec, ed25519
|
|
62
|
+
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
|
63
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
64
|
+
raise ImportError('TouchQueAgent needs the cryptography package: pip install "touchque-authenticator[agent]"') from exc
|
|
65
|
+
return hashes, serialization, ec, ed25519, decode_dss_signature
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _public_jwk(key) -> Dict[str, Any]:
|
|
69
|
+
hashes, serialization, ec, ed25519, _ = _crypto()
|
|
70
|
+
if isinstance(key, ed25519.Ed25519PrivateKey):
|
|
71
|
+
raw = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
|
72
|
+
return {'kty': 'OKP', 'crv': 'Ed25519', 'x': _b64url(raw)}
|
|
73
|
+
numbers = key.public_key().public_numbers()
|
|
74
|
+
return {'kty': 'EC', 'crv': 'P-256', 'x': _b64url(numbers.x.to_bytes(32, 'big')), 'y': _b64url(numbers.y.to_bytes(32, 'big'))}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def generate_agent_key(alg: str = 'ES256') -> Dict[str, Dict[str, Any]]:
|
|
78
|
+
"""A new key pair for an agent: ES256 (P-256, default) or EdDSA (Ed25519).
|
|
79
|
+
Returns ``{'private_jwk': ..., 'public_jwk': ...}``; kid = the RFC 7638
|
|
80
|
+
thumbprint. Register ``public_jwk``; keep ``private_jwk`` where the agent runs."""
|
|
81
|
+
hashes, serialization, ec, ed25519, _ = _crypto()
|
|
82
|
+
if alg == 'EdDSA':
|
|
83
|
+
key = ed25519.Ed25519PrivateKey.generate()
|
|
84
|
+
d = key.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption())
|
|
85
|
+
elif alg == 'ES256':
|
|
86
|
+
key = ec.generate_private_key(ec.SECP256R1())
|
|
87
|
+
d = key.private_numbers().private_value.to_bytes(32, 'big')
|
|
88
|
+
else:
|
|
89
|
+
raise TouchQueConfigException('alg must be ES256 or EdDSA')
|
|
90
|
+
public = _public_jwk(key)
|
|
91
|
+
kid = jwk_thumbprint(public)
|
|
92
|
+
return {
|
|
93
|
+
'private_jwk': dict(public, d=_b64url(d), kid=kid, alg=alg),
|
|
94
|
+
'public_jwk': dict(public, kid=kid, alg=alg),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _load_private_key(value: Union[Dict[str, Any], str, Any]):
|
|
99
|
+
hashes, serialization, ec, ed25519, _ = _crypto()
|
|
100
|
+
try:
|
|
101
|
+
if isinstance(value, str) and value.strip().startswith('{'):
|
|
102
|
+
value = json.loads(value)
|
|
103
|
+
if isinstance(value, dict):
|
|
104
|
+
d = _b64url_decode(value['d'])
|
|
105
|
+
if value.get('kty') == 'OKP' and value.get('crv') == 'Ed25519':
|
|
106
|
+
return ed25519.Ed25519PrivateKey.from_private_bytes(d)
|
|
107
|
+
if value.get('kty') == 'EC' and value.get('crv') == 'P-256':
|
|
108
|
+
return ec.derive_private_key(int.from_bytes(d, 'big'), ec.SECP256R1())
|
|
109
|
+
raise ValueError('unsupported key')
|
|
110
|
+
if isinstance(value, (str, bytes)):
|
|
111
|
+
data = value.encode('utf-8') if isinstance(value, str) else value
|
|
112
|
+
return serialization.load_pem_private_key(data, password=None)
|
|
113
|
+
if isinstance(value, (ec.EllipticCurvePrivateKey, ed25519.Ed25519PrivateKey)):
|
|
114
|
+
return value
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
raise TouchQueConfigException("private_key must be the agent's private key: a JWK (dict or JSON), a PEM string or a cryptography key") from exc
|
|
117
|
+
raise TouchQueConfigException("private_key must be the agent's private key: a JWK (dict or JSON), a PEM string or a cryptography key")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TouchQueAgent:
|
|
121
|
+
"""An AI agent's own TouchQue client (see the module docstring)."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, agent_id: Optional[str] = None, private_key: Union[Dict[str, Any], str, Any, None] = None,
|
|
124
|
+
kid: Optional[str] = None, base_url: Optional[str] = None, scope: Optional[str] = None,
|
|
125
|
+
timeout: int = 10000):
|
|
126
|
+
agent_id = agent_id if agent_id is not None else os.environ.get('TQ_AGENT_ID', '')
|
|
127
|
+
if not _AGENT_ID.match(agent_id or ''):
|
|
128
|
+
raise TouchQueConfigException('Set TQ_AGENT_ID (or pass agent_id): the agt_... id from client.agents.create()')
|
|
129
|
+
key_input = private_key if private_key is not None else os.environ.get('TQ_AGENT_KEY')
|
|
130
|
+
if not key_input:
|
|
131
|
+
raise TouchQueConfigException("Set TQ_AGENT_KEY (or pass private_key): the agent's private key")
|
|
132
|
+
hashes, serialization, ec, ed25519, _ = _crypto()
|
|
133
|
+
self.agent_id = agent_id
|
|
134
|
+
self._key = _load_private_key(key_input)
|
|
135
|
+
if isinstance(self._key, ec.EllipticCurvePrivateKey) and not isinstance(self._key.curve, ec.SECP256R1):
|
|
136
|
+
raise TouchQueConfigException('An EC agent key must be on P-256')
|
|
137
|
+
if not isinstance(self._key, (ec.EllipticCurvePrivateKey, ed25519.Ed25519PrivateKey)):
|
|
138
|
+
raise TouchQueConfigException('The agent key must be EC P-256 or Ed25519')
|
|
139
|
+
self._alg = 'EdDSA' if isinstance(self._key, ed25519.Ed25519PrivateKey) else 'ES256'
|
|
140
|
+
self._public_jwk = _public_jwk(self._key)
|
|
141
|
+
given_kid = None
|
|
142
|
+
if isinstance(key_input, str) and key_input.strip().startswith('{'):
|
|
143
|
+
given_kid = json.loads(key_input).get('kid')
|
|
144
|
+
elif isinstance(key_input, dict):
|
|
145
|
+
given_kid = key_input.get('kid')
|
|
146
|
+
self._kid = kid or given_kid or jwk_thumbprint(self._public_jwk)
|
|
147
|
+
self.base_url = (base_url or os.environ.get('TQ_API_URL') or 'https://api.touchque.com').rstrip('/')
|
|
148
|
+
parsed = urlparse(self.base_url)
|
|
149
|
+
if parsed.scheme != 'https' and not (parsed.scheme == 'http' and (parsed.hostname or '') in _LOCAL_HOSTS):
|
|
150
|
+
raise TouchQueConfigException(f'Refusing a plaintext base_url for "{parsed.hostname}": use https://')
|
|
151
|
+
self._scope = scope
|
|
152
|
+
self._timeout = timeout / 1000.0
|
|
153
|
+
self._token: Optional[Dict[str, Any]] = None
|
|
154
|
+
self._lock = threading.Lock()
|
|
155
|
+
|
|
156
|
+
# ── signing ──────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def _sign(self, header: Dict[str, Any], payload: Dict[str, Any]) -> str:
|
|
159
|
+
hashes, serialization, ec, ed25519, decode_dss_signature = _crypto()
|
|
160
|
+
signing_input = f"{_b64url(_json(dict(header, alg=self._alg)).encode())}.{_b64url(_json(payload).encode())}"
|
|
161
|
+
if self._alg == 'EdDSA':
|
|
162
|
+
sig = self._key.sign(signing_input.encode('ascii'))
|
|
163
|
+
else:
|
|
164
|
+
r, s = decode_dss_signature(self._key.sign(signing_input.encode('ascii'), ec.ECDSA(hashes.SHA256())))
|
|
165
|
+
sig = r.to_bytes(32, 'big') + s.to_bytes(32, 'big')
|
|
166
|
+
return f'{signing_input}.{_b64url(sig)}'
|
|
167
|
+
|
|
168
|
+
def _dpop(self, method: str, url: str, access_token: Optional[str] = None) -> str:
|
|
169
|
+
claims: Dict[str, Any] = {'htm': method, 'htu': url, 'iat': int(time.time()), 'jti': str(uuid.uuid4())}
|
|
170
|
+
if access_token:
|
|
171
|
+
claims['ath'] = _b64url(hashlib.sha256(access_token.encode('ascii')).digest())
|
|
172
|
+
return self._sign({'typ': 'dpop+jwt', 'jwk': self._public_jwk}, claims)
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _error(response: 'requests.Response') -> TouchQueAPIException:
|
|
176
|
+
try:
|
|
177
|
+
data = response.json()
|
|
178
|
+
except ValueError:
|
|
179
|
+
data = {}
|
|
180
|
+
retry_after = data.get('retry_after', data.get('retryAfter'))
|
|
181
|
+
return api_exception(
|
|
182
|
+
data.get('error_description') or data.get('error') or 'Unknown API Error',
|
|
183
|
+
status=response.status_code,
|
|
184
|
+
code=data.get('code') or data.get('error'),
|
|
185
|
+
reason=data.get('reason'),
|
|
186
|
+
retry_after=retry_after if isinstance(retry_after, int) else None,
|
|
187
|
+
data=data,
|
|
188
|
+
case_code=data.get('caseCode') if isinstance(data.get('caseCode'), str) else None,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# ── token ────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
def _fetch_token(self) -> str:
|
|
194
|
+
url = f'{self.base_url}/agents/token'
|
|
195
|
+
now = int(time.time())
|
|
196
|
+
assertion = self._sign({'typ': 'JWT', 'kid': self._kid},
|
|
197
|
+
{'iss': self.agent_id, 'sub': self.agent_id, 'aud': url, 'iat': now, 'exp': now + 60, 'jti': str(uuid.uuid4())})
|
|
198
|
+
form = {'grant_type': 'client_credentials', 'client_assertion_type': AGENT_ASSERTION_TYPE, 'client_assertion': assertion}
|
|
199
|
+
if self._scope:
|
|
200
|
+
form['scope'] = self._scope
|
|
201
|
+
try:
|
|
202
|
+
response = requests.post(url, data=form, headers={'DPoP': self._dpop('POST', url)}, timeout=self._timeout, allow_redirects=False)
|
|
203
|
+
except requests.exceptions.RequestException as e:
|
|
204
|
+
raise TouchQueNetworkException(f'Network Error: {e}') from e
|
|
205
|
+
if response.status_code != 200:
|
|
206
|
+
raise self._error(response)
|
|
207
|
+
data = response.json()
|
|
208
|
+
self._token = {'value': data['access_token'], 'expires_at': time.time() + int(data.get('expires_in', 600))}
|
|
209
|
+
return self._token['value']
|
|
210
|
+
|
|
211
|
+
def access_token(self) -> str:
|
|
212
|
+
"""The agent's current token (renewed a minute before it expires)."""
|
|
213
|
+
with self._lock:
|
|
214
|
+
if self._token and self._token['expires_at'] - time.time() > 60:
|
|
215
|
+
return self._token['value']
|
|
216
|
+
return self._fetch_token()
|
|
217
|
+
|
|
218
|
+
def _request(self, method: str, path: str, body: Optional[Dict[str, Any]] = None, retried: bool = False) -> Dict[str, Any]:
|
|
219
|
+
token = self.access_token()
|
|
220
|
+
url = f'{self.base_url}{path}'
|
|
221
|
+
headers = {'Authorization': f'DPoP {token}', 'DPoP': self._dpop(method, url.split('?')[0], token)}
|
|
222
|
+
if body is not None:
|
|
223
|
+
headers['Content-Type'] = 'application/json'
|
|
224
|
+
try:
|
|
225
|
+
response = requests.request(method, url, data=_json(body) if body is not None else None, headers=headers,
|
|
226
|
+
timeout=self._timeout, allow_redirects=False)
|
|
227
|
+
except requests.exceptions.RequestException as e:
|
|
228
|
+
raise TouchQueNetworkException(f'Network Error: {e}') from e
|
|
229
|
+
if response.status_code == 401 and not retried:
|
|
230
|
+
try:
|
|
231
|
+
code = response.json().get('code')
|
|
232
|
+
except ValueError:
|
|
233
|
+
code = None
|
|
234
|
+
if code == 'invalid_token':
|
|
235
|
+
self._token = None
|
|
236
|
+
return self._request(method, path, body, retried=True)
|
|
237
|
+
if response.status_code >= 300:
|
|
238
|
+
raise self._error(response)
|
|
239
|
+
return response.json()
|
|
240
|
+
|
|
241
|
+
def me(self) -> Dict[str, Any]:
|
|
242
|
+
"""The agent as TouchQue sees it: its action types, owner, review date and workspace."""
|
|
243
|
+
return self._request('GET', '/agents/me')
|
|
244
|
+
|
|
245
|
+
def request_approval(self, user: str, type: str, authorization_details: List[Dict[str, Any]], reason: Optional[str] = None,
|
|
246
|
+
presence: Optional[str] = None, expires_in: Optional[int] = None, locale: Optional[str] = None,
|
|
247
|
+
reference_id: Optional[str] = None, client_ip: Optional[str] = None,
|
|
248
|
+
user_agent: Optional[str] = None, mandate_id: Optional[str] = None) -> Dict[str, Any]:
|
|
249
|
+
"""Asks the person to approve on their TouchQue phone. Show
|
|
250
|
+
``challengeCode`` in your conversation (presence ``interactive``, the
|
|
251
|
+
default): the person types it on the phone. TouchQue draws the phone's
|
|
252
|
+
rows from ``authorization_details``; ``reason`` is shown as unverified.
|
|
253
|
+
``requiresPasskey`` (over the policy's requirePasskeyAbove): the person
|
|
254
|
+
approves with a passkey on your page instead, not on the phone."""
|
|
255
|
+
body: Dict[str, Any] = {'externalUsername': user, 'type': type, 'authorizationDetails': authorization_details}
|
|
256
|
+
for key, value in (('reason', reason), ('presence', presence), ('expiresIn', expires_in), ('locale', locale),
|
|
257
|
+
('referenceId', reference_id), ('clientIp', client_ip), ('userAgent', user_agent), ('mandateId', mandate_id)):
|
|
258
|
+
if value is not None:
|
|
259
|
+
body[key] = value
|
|
260
|
+
return self._request('POST', '/agents/approvals', body)
|
|
261
|
+
|
|
262
|
+
def request_mandate(self, user: str, type: str, max_count: int, max_total: Dict[str, str], per_request: Optional[str] = None,
|
|
263
|
+
recipients: Optional[List[str]] = None, valid_for: Optional[int] = None, reason: Optional[str] = None,
|
|
264
|
+
expires_in: Optional[int] = None, locale: Optional[str] = None) -> Dict[str, Any]:
|
|
265
|
+
"""Asks the person for a mandate: one approval on the phone for a bounded
|
|
266
|
+
series of requests (how many, how much in total and per request, to whom,
|
|
267
|
+
until when). Show ``challengeCode``; once ``active``, pass ``mandate_id`` to
|
|
268
|
+
``request_approval`` and each request within it is approved without a push."""
|
|
269
|
+
body: Dict[str, Any] = {'externalUsername': user, 'type': type, 'maxCount': max_count, 'maxTotal': max_total}
|
|
270
|
+
for key, value in (('perRequest', per_request), ('recipients', recipients), ('validFor', valid_for), ('reason', reason),
|
|
271
|
+
('expiresIn', expires_in), ('locale', locale)):
|
|
272
|
+
if value is not None:
|
|
273
|
+
body[key] = value
|
|
274
|
+
return self._request('POST', '/agents/mandates', body)
|
|
275
|
+
|
|
276
|
+
def get_connection_token(self, user: str, connection: str, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
277
|
+
"""The token vault: a person's own access token at another service, for a connection that
|
|
278
|
+
names this agent (``client.agents.vault``): ``{'access_token', 'token_type': 'Bearer',
|
|
279
|
+
'expires_in'?, 'scope', 'connection'}``. TouchQue refreshes it; the refresh token never
|
|
280
|
+
leaves. TouchQueAPIError not_connected when nothing is kept for them."""
|
|
281
|
+
body: Dict[str, Any] = {'externalUsername': user, 'connection': connection}
|
|
282
|
+
if scope:
|
|
283
|
+
body['scope'] = scope
|
|
284
|
+
return self._request('POST', '/agents/vault/token', body)
|
|
285
|
+
|
|
286
|
+
def get_mandate(self, mandate_id: str) -> Dict[str, Any]:
|
|
287
|
+
"""A mandate's state and what is left."""
|
|
288
|
+
return self._request('GET', f'/agents/mandates/{mandate_id}')
|
|
289
|
+
|
|
290
|
+
def revoke_mandate(self, mandate_id: str) -> Dict[str, Any]:
|
|
291
|
+
"""Gives up a mandate (when the task is done early, for example)."""
|
|
292
|
+
return self._request('POST', f'/agents/mandates/{mandate_id}/revoke', {})
|
|
293
|
+
|
|
294
|
+
def get_approval(self, approval_id: str) -> Dict[str, Any]:
|
|
295
|
+
"""Where an approval request is; ``approvalToken`` once approved."""
|
|
296
|
+
return self._request('GET', f'/agents/approvals/{approval_id}')
|
|
297
|
+
|
|
298
|
+
def wait_for_approval(self, approval_id: str, timeout: float = 600, interval: float = 2) -> Dict[str, Any]:
|
|
299
|
+
"""Polls until the person approves (returns the state holding
|
|
300
|
+
``approvalToken``). Raises TouchQueApprovalError when it ends otherwise
|
|
301
|
+
and TouchQueTimeoutException after ``timeout`` seconds. An approval given
|
|
302
|
+
during a phone call stays ``provisional`` until the phone confirms it, and
|
|
303
|
+
four eyes stay ``awaiting_second`` until the second person answers."""
|
|
304
|
+
interval = max(0.5, interval)
|
|
305
|
+
end = time.time() + timeout
|
|
306
|
+
while True:
|
|
307
|
+
state = self.get_approval(approval_id)
|
|
308
|
+
if state.get('status') == 'approved' and state.get('approvalToken'):
|
|
309
|
+
return state
|
|
310
|
+
if state.get('status') in _ENDED:
|
|
311
|
+
raise TouchQueApprovalError(approval_id, state['status'], state.get('reason'))
|
|
312
|
+
if time.time() + interval > end:
|
|
313
|
+
raise TouchQueTimeoutException(f"TouchQue: approval '{approval_id}' was not approved within {timeout}s")
|
|
314
|
+
time.sleep(interval)
|
touchque/agent_card.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""A2A agent card signatures (A2A 1.0 §8.4).
|
|
2
|
+
|
|
3
|
+
Before your agent trusts another agent's card (its name, URL, skills), check
|
|
4
|
+
that whoever controls its keys signed exactly this card. Each entry of
|
|
5
|
+
``signatures`` is a JWS (RFC 7515) over the card in RFC 8785 canonical form
|
|
6
|
+
without ``signatures``; the protected header names alg, kid and optionally
|
|
7
|
+
jku (where the keys are)::
|
|
8
|
+
|
|
9
|
+
card = requests.get('https://agent.example/.well-known/agent-card.json').json()
|
|
10
|
+
verify_agent_card(card, card_url='https://agent.example/.well-known/agent-card.json')
|
|
11
|
+
|
|
12
|
+
Keys come from ``keys`` (a JWKS you trust) or from the header's jku, but jku
|
|
13
|
+
is only followed on the card's own host (or a host in ``trusted_jku_hosts``):
|
|
14
|
+
a card that points at keys on a host of its choosing proves nothing. Signers
|
|
15
|
+
must leave out fields at their default values (A2A §8.4.1); the card is
|
|
16
|
+
checked as received.
|
|
17
|
+
|
|
18
|
+
Needs the optional ``cryptography`` package: ``pip install "touchque-authenticator[agent]"``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from decimal import Decimal
|
|
23
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Union
|
|
24
|
+
from urllib.parse import urlparse
|
|
25
|
+
|
|
26
|
+
import requests
|
|
27
|
+
|
|
28
|
+
from .mcp_auth import _b64d, _b64e, _json_part, _public_key, _verify
|
|
29
|
+
|
|
30
|
+
__all__ = ['AgentCardError', 'canonical_json', 'verify_agent_card', 'sign_agent_card']
|
|
31
|
+
|
|
32
|
+
_ALGS = ('RS256', 'ES256', 'EdDSA')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AgentCardError(Exception):
|
|
36
|
+
"""``code``: unsigned | malformed | untrusted_key | invalid_signature | keys_unavailable."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, code: str, message: str):
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.code = code
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _es_number(value: float) -> str:
|
|
44
|
+
"""A number as ECMAScript's Number::toString writes it (RFC 8785 §3.2.2.3)."""
|
|
45
|
+
if value != value or value in (float('inf'), float('-inf')):
|
|
46
|
+
raise AgentCardError('malformed', 'a number is not finite')
|
|
47
|
+
if value == 0:
|
|
48
|
+
return '0'
|
|
49
|
+
sign = '-' if value < 0 else ''
|
|
50
|
+
t = Decimal(repr(abs(value))).normalize().as_tuple()
|
|
51
|
+
digits = ''.join(str(d) for d in t.digits)
|
|
52
|
+
k = len(digits)
|
|
53
|
+
n = t.exponent + k
|
|
54
|
+
if k <= n <= 21:
|
|
55
|
+
out = digits + '0' * (n - k)
|
|
56
|
+
elif 0 < n <= 21:
|
|
57
|
+
out = digits[:n] + '.' + digits[n:]
|
|
58
|
+
elif -6 < n <= 0:
|
|
59
|
+
out = '0.' + '0' * (-n) + digits
|
|
60
|
+
else:
|
|
61
|
+
e = n - 1
|
|
62
|
+
out = digits[0] + ('.' + digits[1:] if k > 1 else '') + 'e' + ('+' if e >= 0 else '-') + str(abs(e))
|
|
63
|
+
return sign + out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def canonical_json(value: Any) -> str:
|
|
67
|
+
"""RFC 8785 (JSON Canonicalization Scheme): sorted keys (UTF-16 order), no whitespace, ECMAScript numbers."""
|
|
68
|
+
if value is None:
|
|
69
|
+
return 'null'
|
|
70
|
+
if value is True:
|
|
71
|
+
return 'true'
|
|
72
|
+
if value is False:
|
|
73
|
+
return 'false'
|
|
74
|
+
if isinstance(value, str):
|
|
75
|
+
return json.dumps(value, ensure_ascii=False)
|
|
76
|
+
if isinstance(value, int):
|
|
77
|
+
return str(value)
|
|
78
|
+
if isinstance(value, float):
|
|
79
|
+
return _es_number(value)
|
|
80
|
+
if isinstance(value, (list, tuple)):
|
|
81
|
+
return '[' + ','.join(canonical_json(v) for v in value) + ']'
|
|
82
|
+
if isinstance(value, Mapping):
|
|
83
|
+
keys = sorted(value.keys(), key=lambda k: k.encode('utf-16-be'))
|
|
84
|
+
return '{' + ','.join(json.dumps(k, ensure_ascii=False) + ':' + canonical_json(value[k]) for k in keys) + '}'
|
|
85
|
+
raise AgentCardError('malformed', f'cannot canonicalize {type(value).__name__}')
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _payload(card: Mapping[str, Any]) -> str:
|
|
89
|
+
rest = {k: v for k, v in card.items() if k != 'signatures'}
|
|
90
|
+
return _b64e(canonical_json(rest).encode('utf-8'))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _host_of(url: Optional[str]) -> Optional[str]:
|
|
94
|
+
if not url:
|
|
95
|
+
return None
|
|
96
|
+
u = urlparse(url)
|
|
97
|
+
local = u.hostname in ('localhost', '127.0.0.1', '::1')
|
|
98
|
+
if u.scheme != 'https' and not (u.scheme == 'http' and local):
|
|
99
|
+
return None
|
|
100
|
+
return u.netloc
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def verify_agent_card(card: Union[Mapping[str, Any], str], card_url: Optional[str] = None,
|
|
104
|
+
keys: Union[Iterable[Mapping[str, Any]], Mapping[str, Any], None] = None,
|
|
105
|
+
trusted_jku_hosts: Optional[List[str]] = None) -> Dict[str, str]:
|
|
106
|
+
"""Checks the card's signatures: ``{'kid', 'alg', 'key_source': 'keys' | 'jku'}`` or AgentCardError."""
|
|
107
|
+
try:
|
|
108
|
+
c = json.loads(card) if isinstance(card, str) else card
|
|
109
|
+
except ValueError:
|
|
110
|
+
raise AgentCardError('malformed', 'the card is not JSON')
|
|
111
|
+
if not isinstance(c, Mapping):
|
|
112
|
+
raise AgentCardError('malformed', 'the card is not a JSON object')
|
|
113
|
+
sigs = c.get('signatures')
|
|
114
|
+
if not isinstance(sigs, list) or not sigs:
|
|
115
|
+
raise AgentCardError('unsigned', 'the card has no signatures')
|
|
116
|
+
given = list((keys.get('keys') or []) if isinstance(keys, Mapping) else (keys or []))
|
|
117
|
+
card_host = _host_of(card_url)
|
|
118
|
+
payload = _payload(c)
|
|
119
|
+
last = AgentCardError('invalid_signature', 'no signature verifies')
|
|
120
|
+
for s in sigs:
|
|
121
|
+
if not isinstance(s, Mapping) or not isinstance(s.get('protected'), str) or not isinstance(s.get('signature'), str):
|
|
122
|
+
continue
|
|
123
|
+
try:
|
|
124
|
+
head = _json_part(s['protected'])
|
|
125
|
+
except ValueError:
|
|
126
|
+
continue
|
|
127
|
+
alg = head.get('alg')
|
|
128
|
+
if alg not in _ALGS or not isinstance(head.get('kid'), str) or 'crit' in head:
|
|
129
|
+
continue
|
|
130
|
+
if 'typ' in head and head['typ'] != 'JOSE':
|
|
131
|
+
continue
|
|
132
|
+
jwk = next((k for k in given if k.get('kid') == head['kid']), None)
|
|
133
|
+
source = 'keys'
|
|
134
|
+
if jwk is None and isinstance(head.get('jku'), str):
|
|
135
|
+
jku_host = _host_of(head['jku'])
|
|
136
|
+
if not jku_host or (jku_host != card_host and jku_host not in (trusted_jku_hosts or [])):
|
|
137
|
+
last = AgentCardError('untrusted_key', f"jku {head['jku']} is not on the card's host")
|
|
138
|
+
continue
|
|
139
|
+
try:
|
|
140
|
+
r = requests.get(head['jku'], timeout=5, allow_redirects=False)
|
|
141
|
+
if r.status_code != 200:
|
|
142
|
+
raise RuntimeError(f'it answered {r.status_code}')
|
|
143
|
+
jwk = next((k for k in r.json().get('keys', []) if k.get('kid') == head['kid']), None)
|
|
144
|
+
except Exception as err:
|
|
145
|
+
last = AgentCardError('keys_unavailable', f"the keys at {head['jku']} could not be read: {err}")
|
|
146
|
+
continue
|
|
147
|
+
source = 'jku'
|
|
148
|
+
if jwk is None:
|
|
149
|
+
if last.code == 'invalid_signature':
|
|
150
|
+
last = AgentCardError('untrusted_key', f"no trusted key {head['kid']}")
|
|
151
|
+
continue
|
|
152
|
+
try:
|
|
153
|
+
key = _public_key(jwk)
|
|
154
|
+
except (ValueError, KeyError):
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
sig = _b64d(s['signature'])
|
|
158
|
+
except ValueError: # not base64url (e.g. non-ASCII): a signature that does not verify
|
|
159
|
+
sig = b''
|
|
160
|
+
if _verify(f"{s['protected']}.{payload}".encode(), sig, alg, key):
|
|
161
|
+
return {'kid': head['kid'], 'alg': alg, 'key_source': source}
|
|
162
|
+
last = AgentCardError('invalid_signature', 'the signature does not match the card')
|
|
163
|
+
raise last
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def sign_agent_card(card: Mapping[str, Any], private_key, kid: str, jku: Optional[str] = None) -> Dict[str, Any]:
|
|
167
|
+
"""Signs a card you publish: returns it with one more entry in ``signatures`` (EC P-256, Ed25519 or RSA key)."""
|
|
168
|
+
from cryptography.hazmat.primitives import hashes
|
|
169
|
+
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa
|
|
170
|
+
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
|
171
|
+
if isinstance(private_key, ec.EllipticCurvePrivateKey):
|
|
172
|
+
alg = 'ES256'
|
|
173
|
+
elif isinstance(private_key, ed25519.Ed25519PrivateKey):
|
|
174
|
+
alg = 'EdDSA'
|
|
175
|
+
elif isinstance(private_key, rsa.RSAPrivateKey):
|
|
176
|
+
alg = 'RS256'
|
|
177
|
+
else:
|
|
178
|
+
raise AgentCardError('malformed', 'the key must be EC P-256, Ed25519 or RSA')
|
|
179
|
+
header = {'alg': alg, 'typ': 'JOSE', 'kid': kid, **({'jku': jku} if jku else {})}
|
|
180
|
+
protected = _b64e(json.dumps(header, separators=(',', ':')).encode())
|
|
181
|
+
data = f'{protected}.{_payload(card)}'.encode()
|
|
182
|
+
if alg == 'ES256':
|
|
183
|
+
r, s = decode_dss_signature(private_key.sign(data, ec.ECDSA(hashes.SHA256())))
|
|
184
|
+
sig = r.to_bytes(32, 'big') + s.to_bytes(32, 'big')
|
|
185
|
+
elif alg == 'EdDSA':
|
|
186
|
+
sig = private_key.sign(data)
|
|
187
|
+
else:
|
|
188
|
+
sig = private_key.sign(data, padding.PKCS1v15(), hashes.SHA256())
|
|
189
|
+
prior = list(card.get('signatures') or [])
|
|
190
|
+
return {**card, 'signatures': prior + [{'protected': protected, 'signature': _b64e(sig)}]}
|