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/mcp_auth.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""For an MCP server whose authorization server is TouchQue SSO (Organization
|
|
2
|
+
→ MCP servers in the dashboard; issuer https://sso.touchque.com/mcp):
|
|
3
|
+
|
|
4
|
+
- ``protected_resource_metadata()`` is its RFC 9728 document, served at the
|
|
5
|
+
paths ``metadata_paths()`` gives, so MCP clients find TouchQue and ask for a
|
|
6
|
+
token for exactly this server;
|
|
7
|
+
- ``verify(method, url, headers)`` checks a request's token: signed by
|
|
8
|
+
TouchQue (its JWKS), this issuer, audience = this server's URL (RFC 8707),
|
|
9
|
+
not expired, and for a DPoP-bound token a fresh proof for this request;
|
|
10
|
+
``McpAuthError`` carries the status (401, 403) and the WWW-Authenticate
|
|
11
|
+
header to send;
|
|
12
|
+
- ``require_tool(claims, tool)`` raises 403 insufficient_scope naming the
|
|
13
|
+
scope a tool needs, so the client asks the member for more;
|
|
14
|
+
- ``verify_token(token)`` checks a bearer token alone (FastMCP's
|
|
15
|
+
TokenVerifier gets only the token).
|
|
16
|
+
|
|
17
|
+
auth = McpAuth(issuer='https://sso.touchque.com/mcp', resource='https://mcp.acme.com/mcp',
|
|
18
|
+
tools={'search_tickets': 'tickets:read', 'delete_ticket': 'tickets:delete'})
|
|
19
|
+
|
|
20
|
+
Needs the optional ``cryptography`` package: ``pip install "touchque-authenticator[agent]"``.
|
|
21
|
+
"""
|
|
22
|
+
import base64
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
28
|
+
from urllib.parse import urlparse
|
|
29
|
+
|
|
30
|
+
import requests
|
|
31
|
+
|
|
32
|
+
from .exceptions import TouchQueConfigException
|
|
33
|
+
|
|
34
|
+
__all__ = ['McpAuth', 'McpAuthError']
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _b64d(text: str) -> bytes:
|
|
38
|
+
return base64.urlsafe_b64decode(text + '=' * (-len(text) % 4))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _b64e(data: bytes) -> str:
|
|
42
|
+
return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii')
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _json_part(part: str) -> Dict[str, Any]:
|
|
46
|
+
value = json.loads(_b64d(part))
|
|
47
|
+
if not isinstance(value, dict):
|
|
48
|
+
raise ValueError('not an object')
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class McpAuthError(Exception):
|
|
53
|
+
"""A refused request: send ``status`` with the ``www_authenticate`` header."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, status: int, code: str, message: str, www_authenticate: str):
|
|
56
|
+
super().__init__(message)
|
|
57
|
+
self.status = status
|
|
58
|
+
self.code = code
|
|
59
|
+
self.www_authenticate = www_authenticate
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _public_key(jwk: Mapping[str, Any]):
|
|
63
|
+
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa
|
|
64
|
+
if jwk.get('kty') == 'RSA':
|
|
65
|
+
return rsa.RSAPublicNumbers(int.from_bytes(_b64d(jwk['e']), 'big'), int.from_bytes(_b64d(jwk['n']), 'big')).public_key()
|
|
66
|
+
if jwk.get('kty') == 'EC' and jwk.get('crv') == 'P-256':
|
|
67
|
+
return ec.EllipticCurvePublicNumbers(int.from_bytes(_b64d(jwk['x']), 'big'), int.from_bytes(_b64d(jwk['y']), 'big'), ec.SECP256R1()).public_key()
|
|
68
|
+
if jwk.get('kty') == 'OKP' and jwk.get('crv') == 'Ed25519':
|
|
69
|
+
return ed25519.Ed25519PublicKey.from_public_bytes(_b64d(jwk['x']))
|
|
70
|
+
raise ValueError('unsupported key')
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _verify(signing_input: bytes, sig: bytes, alg: str, key) -> bool:
|
|
74
|
+
from cryptography.exceptions import InvalidSignature
|
|
75
|
+
from cryptography.hazmat.primitives import hashes
|
|
76
|
+
from cryptography.hazmat.primitives.asymmetric import ec, padding
|
|
77
|
+
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
|
78
|
+
try:
|
|
79
|
+
if alg == 'RS256':
|
|
80
|
+
key.verify(sig, signing_input, padding.PKCS1v15(), hashes.SHA256())
|
|
81
|
+
elif alg == 'ES256':
|
|
82
|
+
if len(sig) != 64:
|
|
83
|
+
return False
|
|
84
|
+
key.verify(encode_dss_signature(int.from_bytes(sig[:32], 'big'), int.from_bytes(sig[32:], 'big')), signing_input, ec.ECDSA(hashes.SHA256()))
|
|
85
|
+
elif alg == 'EdDSA':
|
|
86
|
+
key.verify(sig, signing_input)
|
|
87
|
+
else:
|
|
88
|
+
return False
|
|
89
|
+
return True
|
|
90
|
+
except (InvalidSignature, ValueError, TypeError):
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _thumbprint(jwk: Mapping[str, Any]) -> str:
|
|
95
|
+
if jwk.get('kty') == 'EC':
|
|
96
|
+
members = {'crv': jwk['crv'], 'kty': 'EC', 'x': jwk['x'], 'y': jwk['y']}
|
|
97
|
+
elif jwk.get('kty') == 'RSA':
|
|
98
|
+
members = {'e': jwk['e'], 'kty': 'RSA', 'n': jwk['n']}
|
|
99
|
+
else:
|
|
100
|
+
members = {'crv': jwk['crv'], 'kty': jwk['kty'], 'x': jwk['x']}
|
|
101
|
+
return _b64e(hashlib.sha256(json.dumps(members, separators=(',', ':')).encode()).digest())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class McpAuth:
|
|
105
|
+
"""See the module docstring."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, issuer: str, resource: str, tools: Optional[Dict[str, str]] = None,
|
|
108
|
+
resource_name: Optional[str] = None, clock_skew: int = 60):
|
|
109
|
+
if not issuer or not resource:
|
|
110
|
+
raise TouchQueConfigException("McpAuth needs issuer (TouchQue SSO's MCP issuer) and resource (this server's URL)")
|
|
111
|
+
self.issuer = issuer.rstrip('/')
|
|
112
|
+
self.resource = resource
|
|
113
|
+
self.tools = dict(tools or {})
|
|
114
|
+
self.resource_name = resource_name
|
|
115
|
+
self.skew = clock_skew
|
|
116
|
+
self._keys: Dict[str, Any] = {}
|
|
117
|
+
self._keys_at = 0.0
|
|
118
|
+
self._tried_at = 0.0
|
|
119
|
+
self._proofs: Dict[str, float] = {}
|
|
120
|
+
self._lock = threading.Lock()
|
|
121
|
+
|
|
122
|
+
def metadata_paths(self) -> List[str]:
|
|
123
|
+
path = urlparse(self.resource).path.rstrip('/')
|
|
124
|
+
return ['/.well-known/oauth-protected-resource'] + ([f'/.well-known/oauth-protected-resource{path}'] if path else [])
|
|
125
|
+
|
|
126
|
+
def metadata_url(self) -> str:
|
|
127
|
+
u = urlparse(self.resource)
|
|
128
|
+
return f'{u.scheme}://{u.netloc}{self.metadata_paths()[-1]}'
|
|
129
|
+
|
|
130
|
+
def protected_resource_metadata(self) -> Dict[str, Any]:
|
|
131
|
+
doc: Dict[str, Any] = {
|
|
132
|
+
'resource': self.resource, 'authorization_servers': [self.issuer], 'bearer_methods_supported': ['header'],
|
|
133
|
+
'scopes_supported': list(dict.fromkeys(self.tools.values())), 'dpop_signing_alg_values_supported': ['ES256', 'EdDSA'],
|
|
134
|
+
}
|
|
135
|
+
if self.resource_name:
|
|
136
|
+
doc['resource_name'] = self.resource_name
|
|
137
|
+
return doc
|
|
138
|
+
|
|
139
|
+
def _fail(self, status: int, code: str, message: str, scope: Optional[str] = None) -> McpAuthError:
|
|
140
|
+
parts = [f'resource_metadata="{self.metadata_url()}"']
|
|
141
|
+
if code != 'missing_token':
|
|
142
|
+
parts.append(f'error="{code}"')
|
|
143
|
+
parts.append('error_description="{}"'.format(message.replace('"', "'")))
|
|
144
|
+
if scope:
|
|
145
|
+
parts.append(f'scope="{scope}"')
|
|
146
|
+
return McpAuthError(status, code, message, 'Bearer ' + ', '.join(parts))
|
|
147
|
+
|
|
148
|
+
def _load_keys(self) -> None:
|
|
149
|
+
u = urlparse(self.issuer)
|
|
150
|
+
meta = requests.get(f'{u.scheme}://{u.netloc}/.well-known/oauth-authorization-server{u.path.rstrip("/")}', timeout=5).json()
|
|
151
|
+
if str(meta.get('issuer', '')).rstrip('/') != self.issuer or not meta.get('jwks_uri'):
|
|
152
|
+
raise ValueError('the authorization server metadata does not match the issuer')
|
|
153
|
+
jwks = requests.get(meta['jwks_uri'], timeout=5).json()
|
|
154
|
+
self._keys = {k['kid']: _public_key(k) for k in jwks.get('keys', []) if k.get('kid')}
|
|
155
|
+
self._keys_at = time.time()
|
|
156
|
+
|
|
157
|
+
def _key_for(self, kid: str):
|
|
158
|
+
with self._lock:
|
|
159
|
+
if not self._keys_at or time.time() - self._keys_at > 600:
|
|
160
|
+
self._load_keys()
|
|
161
|
+
if kid not in self._keys and time.time() - self._tried_at > 60:
|
|
162
|
+
self._tried_at = time.time()
|
|
163
|
+
self._load_keys()
|
|
164
|
+
return self._keys.get(kid)
|
|
165
|
+
|
|
166
|
+
def _claims(self, token: str) -> Dict[str, Any]:
|
|
167
|
+
try:
|
|
168
|
+
h, p, s = token.split('.')
|
|
169
|
+
head, claims, sig = _json_part(h), _json_part(p), _b64d(s)
|
|
170
|
+
except ValueError:
|
|
171
|
+
raise self._fail(401, 'invalid_token', 'The token is malformed')
|
|
172
|
+
if head.get('typ') != 'at+jwt' or head.get('alg') not in ('RS256', 'ES256'):
|
|
173
|
+
raise self._fail(401, 'invalid_token', 'Not a TouchQue access token')
|
|
174
|
+
try:
|
|
175
|
+
key = self._key_for(str(head.get('kid', '')))
|
|
176
|
+
except Exception:
|
|
177
|
+
raise self._fail(401, 'invalid_token', "The authorization server's keys could not be read")
|
|
178
|
+
if key is None or not _verify(f'{h}.{p}'.encode('ascii'), sig, head['alg'], key):
|
|
179
|
+
raise self._fail(401, 'invalid_token', 'The token signature does not verify')
|
|
180
|
+
if str(claims.get('iss', '')).rstrip('/') != self.issuer:
|
|
181
|
+
raise self._fail(401, 'invalid_token', 'The token is from another issuer')
|
|
182
|
+
aud = claims.get('aud')
|
|
183
|
+
if self.resource not in (aud if isinstance(aud, list) else [aud]):
|
|
184
|
+
raise self._fail(401, 'invalid_token', 'The token is for another server')
|
|
185
|
+
if not isinstance(claims.get('exp'), (int, float)) or claims['exp'] + self.skew <= time.time():
|
|
186
|
+
raise self._fail(401, 'invalid_token', 'The token expired')
|
|
187
|
+
return claims
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def _view(claims: Dict[str, Any]) -> Dict[str, Any]:
|
|
191
|
+
return {
|
|
192
|
+
'sub': str(claims.get('sub')), 'email': claims.get('email'), 'client_id': claims.get('client_id', ''),
|
|
193
|
+
'scopes': [x for x in str(claims.get('scope', '')).split(' ') if x], 'org_id': claims.get('org_id'),
|
|
194
|
+
'expires_at': claims['exp'], 'dpop_bound': bool((claims.get('cnf') or {}).get('jkt')), 'claims': claims,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
def verify_token(self, token: str) -> Dict[str, Any]:
|
|
198
|
+
"""A bearer token alone (FastMCP's TokenVerifier): a DPoP-bound one is refused here."""
|
|
199
|
+
claims = self._claims(token)
|
|
200
|
+
if (claims.get('cnf') or {}).get('jkt'):
|
|
201
|
+
raise self._fail(401, 'invalid_token', 'This token is bound to a DPoP key: use verify() with the request')
|
|
202
|
+
return self._view(claims)
|
|
203
|
+
|
|
204
|
+
def verify(self, method: str, url: str, headers: Mapping[str, str]) -> Dict[str, Any]:
|
|
205
|
+
"""A request's token, checked: ``{'sub', 'email', 'client_id', 'scopes', 'org_id', 'expires_at', 'dpop_bound', 'claims'}``."""
|
|
206
|
+
lower = {k.lower(): v for k, v in headers.items()}
|
|
207
|
+
scheme, _, token = str(lower.get('authorization', '')).strip().partition(' ')
|
|
208
|
+
if scheme.lower() not in ('bearer', 'dpop') or token.count('.') != 2:
|
|
209
|
+
raise self._fail(401, 'missing_token', 'A TouchQue access token is required')
|
|
210
|
+
claims = self._claims(token.strip())
|
|
211
|
+
jkt = (claims.get('cnf') or {}).get('jkt')
|
|
212
|
+
if jkt:
|
|
213
|
+
if scheme.lower() != 'dpop':
|
|
214
|
+
raise self._fail(401, 'invalid_token', 'This token is bound to a DPoP key: send it as DPoP with a proof')
|
|
215
|
+
self._check_proof(lower.get('dpop'), method, url, token.strip(), jkt)
|
|
216
|
+
return self._view(claims)
|
|
217
|
+
|
|
218
|
+
def _check_proof(self, proof: Optional[str], method: str, url: str, token: str, jkt: str) -> None:
|
|
219
|
+
try:
|
|
220
|
+
if not proof:
|
|
221
|
+
raise ValueError('missing')
|
|
222
|
+
h, p, s = proof.split('.')
|
|
223
|
+
head, claims = _json_part(h), _json_part(p)
|
|
224
|
+
jwk = head.get('jwk') or {}
|
|
225
|
+
if head.get('typ') != 'dpop+jwt' or 'd' in jwk or _thumbprint(jwk) != jkt:
|
|
226
|
+
raise ValueError('not the bound key')
|
|
227
|
+
if not _verify(f'{h}.{p}'.encode('ascii'), _b64d(s), head.get('alg', ''), _public_key(jwk)):
|
|
228
|
+
raise ValueError('signature')
|
|
229
|
+
u = urlparse(url)
|
|
230
|
+
if claims.get('htm') != method.upper() or claims.get('htu') != f'{u.scheme}://{u.netloc}{u.path}':
|
|
231
|
+
raise ValueError('htm/htu')
|
|
232
|
+
if abs(claims.get('iat', 0) - time.time()) > self.skew:
|
|
233
|
+
raise ValueError('iat')
|
|
234
|
+
if claims.get('ath') != _b64e(hashlib.sha256(token.encode('ascii')).digest()):
|
|
235
|
+
raise ValueError('ath')
|
|
236
|
+
now = time.time()
|
|
237
|
+
with self._lock:
|
|
238
|
+
for k in [k for k, until in self._proofs.items() if until < now]:
|
|
239
|
+
del self._proofs[k]
|
|
240
|
+
if not claims.get('jti') or claims['jti'] in self._proofs:
|
|
241
|
+
raise ValueError('jti used')
|
|
242
|
+
self._proofs[claims['jti']] = now + 2 * self.skew
|
|
243
|
+
except McpAuthError:
|
|
244
|
+
raise
|
|
245
|
+
except Exception as e:
|
|
246
|
+
raise self._fail(401, 'invalid_dpop_proof', f'DPoP proof: {e}')
|
|
247
|
+
|
|
248
|
+
def require_scope(self, claims: Mapping[str, Any], scope: str) -> None:
|
|
249
|
+
if scope not in claims.get('scopes', []):
|
|
250
|
+
raise self._fail(403, 'insufficient_scope', f'This needs the {scope} scope', scope)
|
|
251
|
+
|
|
252
|
+
def require_tool(self, claims: Mapping[str, Any], tool: str) -> None:
|
|
253
|
+
scope = self.tools.get(tool)
|
|
254
|
+
if not scope:
|
|
255
|
+
raise self._fail(403, 'insufficient_scope', f'No scope is set for the tool {tool}')
|
|
256
|
+
self.require_scope(claims, scope)
|
touchque/my_account.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""TouchQue Login My Account API (/me/v1) from your server: the signed-in
|
|
2
|
+
customer's own profile, sign-in methods, devices, apps and data, called with
|
|
3
|
+
their access token for audience https://<domain>/me/.
|
|
4
|
+
|
|
5
|
+
me = login.my_account_token(session["refresh_token"], scope="read:me update:me") # keep me.refresh_token
|
|
6
|
+
account = login.my_account(me.access_token)
|
|
7
|
+
account.get()["profile"]
|
|
8
|
+
|
|
9
|
+
Security changes, the data export and deletion need a recent sign-in; when it
|
|
10
|
+
is too old a MyAccountError with step_up True is raised: send the person
|
|
11
|
+
through authorization_url(max_age=0) again.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
from urllib.parse import quote, urlsplit
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
from .config import _LOCAL_HOSTS
|
|
21
|
+
from .exceptions import TouchQueException
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MyAccountError(TouchQueException):
|
|
25
|
+
def __init__(self, code: str, status: int, description: Optional[str] = None, max_age: Optional[int] = None):
|
|
26
|
+
super().__init__(f"{code}: {description}" if description else code)
|
|
27
|
+
self.code = code
|
|
28
|
+
self.status = status
|
|
29
|
+
self.description = description
|
|
30
|
+
# True when the sign-in is too old for this change (RFC 9470).
|
|
31
|
+
self.step_up = code == "insufficient_user_authentication"
|
|
32
|
+
self.max_age = max_age
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _seg(value: str) -> str:
|
|
36
|
+
return quote(value, safe="")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _issuer_of(domain: str) -> str:
|
|
40
|
+
"""https://<domain>. Plaintext http only on this machine (localhost, 127.0.0.1, ::1,
|
|
41
|
+
*.localhost), as for the API base_url: codes, tokens and secrets would travel in the clear."""
|
|
42
|
+
raw = domain.rstrip("/")
|
|
43
|
+
issuer = raw if raw.startswith(("http://", "https://")) else f"https://{raw}"
|
|
44
|
+
host = (urlsplit(issuer).hostname or "").lower()
|
|
45
|
+
if issuer.startswith("http://") and host not in _LOCAL_HOSTS and not host.endswith(".localhost"):
|
|
46
|
+
raise ValueError(f'refusing a plaintext http:// issuer for "{host}": use https://')
|
|
47
|
+
return issuer
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MyAccount:
|
|
51
|
+
def __init__(self, domain: str, access_token: str, session: Optional[requests.Session] = None, timeout: float = 10.0):
|
|
52
|
+
if not domain or not access_token:
|
|
53
|
+
raise MyAccountError("config", 0, "domain and access_token are required")
|
|
54
|
+
try:
|
|
55
|
+
self.issuer = _issuer_of(domain)
|
|
56
|
+
except ValueError as err:
|
|
57
|
+
raise MyAccountError("config", 0, str(err))
|
|
58
|
+
self._token = access_token
|
|
59
|
+
self._http = session or requests.Session()
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
|
|
62
|
+
def account_page_url(self, section: Optional[str] = None) -> str:
|
|
63
|
+
"""The hosted account page, optionally at 'security', 'devices' or 'privacy'."""
|
|
64
|
+
return f"{self.issuer}/account" + (f"/{section}" if section else "")
|
|
65
|
+
|
|
66
|
+
def _call(self, method: str, path: str, body: Any = None) -> Any:
|
|
67
|
+
headers = {"Authorization": f"Bearer {self._token}", "Accept": "application/json"}
|
|
68
|
+
res = self._http.request(method, f"{self.issuer}/me/v1{path}", headers=headers, json=body, timeout=self.timeout)
|
|
69
|
+
if res.status_code == 204:
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
data = res.json()
|
|
73
|
+
except ValueError:
|
|
74
|
+
data = {}
|
|
75
|
+
if res.status_code >= 400:
|
|
76
|
+
challenge = res.headers.get("WWW-Authenticate", "") if getattr(res, "headers", None) else ""
|
|
77
|
+
m = re.search(r'max_age="?(\d+)"?', challenge)
|
|
78
|
+
raise MyAccountError(data.get("error") or f"http_{res.status_code}", res.status_code, data.get("error_description"), int(m.group(1)) if m else None)
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
def get(self) -> Dict[str, Any]:
|
|
82
|
+
return self._call("GET", "")
|
|
83
|
+
|
|
84
|
+
def get_profile(self) -> Dict[str, Any]:
|
|
85
|
+
return self._call("GET", "/profile")
|
|
86
|
+
|
|
87
|
+
def update_profile(self, **changes: Any) -> Dict[str, Any]:
|
|
88
|
+
"""name, given_name, family_name, nickname, locale, user_metadata (merged; None removes a key)."""
|
|
89
|
+
names = {"given_name": "givenName", "family_name": "familyName", "user_metadata": "userMetadata"}
|
|
90
|
+
return self._call("PATCH", "/profile", {names.get(k, k): v for k, v in changes.items()})
|
|
91
|
+
|
|
92
|
+
def authentication_methods(self) -> Dict[str, Any]:
|
|
93
|
+
return self._call("GET", "/authentication-methods")
|
|
94
|
+
|
|
95
|
+
def rename_passkey(self, passkey_id: str, label: str) -> None:
|
|
96
|
+
self._call("PATCH", f"/authentication-methods/passkeys/{_seg(passkey_id)}", {"label": label})
|
|
97
|
+
|
|
98
|
+
def remove_passkey(self, passkey_id: str) -> None:
|
|
99
|
+
"""Needs a recent sign-in."""
|
|
100
|
+
self._call("DELETE", f"/authentication-methods/passkeys/{_seg(passkey_id)}")
|
|
101
|
+
|
|
102
|
+
def unlink_touchque_app(self) -> None:
|
|
103
|
+
"""Needs a recent sign-in."""
|
|
104
|
+
self._call("DELETE", "/authentication-methods/touchque")
|
|
105
|
+
|
|
106
|
+
def set_email_codes(self, enabled: bool) -> Dict[str, Any]:
|
|
107
|
+
"""Email codes as a second step. Needs a recent sign-in."""
|
|
108
|
+
return self._call("PUT", "/authentication-methods/email", {"enabled": enabled})
|
|
109
|
+
|
|
110
|
+
def start_sms_enrollment(self, phone: str) -> Dict[str, Any]:
|
|
111
|
+
"""Texts a code to a new number for text-message codes. Needs a recent sign-in."""
|
|
112
|
+
return self._call("POST", "/authentication-methods/sms", {"phone": phone})
|
|
113
|
+
|
|
114
|
+
def verify_sms_enrollment(self, code: str) -> Dict[str, Any]:
|
|
115
|
+
"""The code from that text: text-message codes are on. Needs a recent sign-in."""
|
|
116
|
+
return self._call("POST", "/authentication-methods/sms/verify", {"code": code})
|
|
117
|
+
|
|
118
|
+
def remove_sms_codes(self) -> None:
|
|
119
|
+
"""Needs a recent sign-in."""
|
|
120
|
+
self._call("DELETE", "/authentication-methods/sms")
|
|
121
|
+
|
|
122
|
+
def unlink_identity(self, identity_id: str) -> None:
|
|
123
|
+
"""Needs a recent sign-in."""
|
|
124
|
+
self._call("DELETE", f"/identities/{_seg(identity_id)}")
|
|
125
|
+
|
|
126
|
+
def stop_app_access(self, access_id: str) -> None:
|
|
127
|
+
"""Apps stop using a linked account for the person (token vault: the identity's appAccess id);
|
|
128
|
+
signing in with it stays."""
|
|
129
|
+
self._call("DELETE", f"/app-access/{_seg(access_id)}")
|
|
130
|
+
|
|
131
|
+
def set_password(self, password: str) -> Dict[str, Any]:
|
|
132
|
+
"""Sets or changes the password; other sessions end. Needs a recent sign-in."""
|
|
133
|
+
return self._call("PUT", "/password", {"password": password})
|
|
134
|
+
|
|
135
|
+
def remove_password(self) -> None:
|
|
136
|
+
"""Needs a recent sign-in and another way to sign in."""
|
|
137
|
+
self._call("DELETE", "/password")
|
|
138
|
+
|
|
139
|
+
def change_email(self, email: str) -> Dict[str, Any]:
|
|
140
|
+
"""A confirmation link goes to the new address. Needs a recent sign-in."""
|
|
141
|
+
return self._call("POST", "/email", {"email": email})
|
|
142
|
+
|
|
143
|
+
def send_verification_email(self) -> Dict[str, Any]:
|
|
144
|
+
return self._call("POST", "/email/verification")
|
|
145
|
+
|
|
146
|
+
def sessions(self) -> Dict[str, Any]:
|
|
147
|
+
return self._call("GET", "/sessions")
|
|
148
|
+
|
|
149
|
+
def end_session(self, session_id: str) -> None:
|
|
150
|
+
self._call("DELETE", f"/sessions/{_seg(session_id)}")
|
|
151
|
+
|
|
152
|
+
def end_other_sessions(self) -> Dict[str, Any]:
|
|
153
|
+
return self._call("DELETE", "/sessions")
|
|
154
|
+
|
|
155
|
+
def apps(self) -> Dict[str, Any]:
|
|
156
|
+
return self._call("GET", "/apps")
|
|
157
|
+
|
|
158
|
+
def revoke_app(self, client_id: str) -> Dict[str, Any]:
|
|
159
|
+
return self._call("DELETE", f"/apps/{_seg(client_id)}")
|
|
160
|
+
|
|
161
|
+
def consents(self) -> Dict[str, Any]:
|
|
162
|
+
return self._call("GET", "/consents")
|
|
163
|
+
|
|
164
|
+
def set_marketing(self, granted: bool) -> Dict[str, Any]:
|
|
165
|
+
return self._call("PUT", "/consents/marketing", {"granted": granted})
|
|
166
|
+
|
|
167
|
+
def export_data(self) -> Dict[str, Any]:
|
|
168
|
+
"""Everything about the person (KVKK/GDPR). Needs a recent sign-in."""
|
|
169
|
+
return self._call("GET", "/export")
|
|
170
|
+
|
|
171
|
+
def delete_account(self) -> Dict[str, Any]:
|
|
172
|
+
"""Erases the account at once. Needs delete:me and a recent sign-in."""
|
|
173
|
+
return self._call("DELETE", "")
|