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,65 @@
|
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
from urllib.parse import quote
|
|
3
|
+
from ..http_client import HttpClient
|
|
4
|
+
|
|
5
|
+
class Auth:
|
|
6
|
+
def __init__(self, http: HttpClient):
|
|
7
|
+
self._http = http
|
|
8
|
+
|
|
9
|
+
def generate_secret(self, external_username: str) -> Dict[str, Any]:
|
|
10
|
+
"""Generate a new setup secret for a user."""
|
|
11
|
+
return self._http.post('/auth/generate-secret', {
|
|
12
|
+
'externalUsername': external_username
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def reset_secret(self, external_username: str) -> Dict[str, Any]:
|
|
20
|
+
"""Reset (regenerate) a user's secret."""
|
|
21
|
+
return self._http.post('/auth/secret/reset', {
|
|
22
|
+
'externalUsername': external_username
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
def validate_secret(self, secret: str) -> Dict[str, Any]:
|
|
26
|
+
"""Validate a setup secret code."""
|
|
27
|
+
return self._http.post('/auth/secret/validate', {
|
|
28
|
+
'secret': secret
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
def get_user(self, external_username: str) -> Dict[str, Any]:
|
|
32
|
+
"""
|
|
33
|
+
Look up a user's link status without sending a push. Useful while
|
|
34
|
+
polling during enrollment: ``used`` flips true and ``deviceId`` is set
|
|
35
|
+
once the mobile app scans the setup secret.
|
|
36
|
+
|
|
37
|
+
Returns ``{ externalUsername, deviceId, used, frozen, createdAt, expireAt, device }``.
|
|
38
|
+
Raises ``TouchQueAPIException`` (404) if the user is unknown.
|
|
39
|
+
|
|
40
|
+
``device`` is the linked phone's posture for device trust, or ``None`` before a
|
|
41
|
+
phone is linked. It is self-reported by the TouchQue app::
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
"platform": "ios" | "android" | None,
|
|
45
|
+
"model": str | None, # e.g. "iPhone15,3"
|
|
46
|
+
"osVersion": str | None, # e.g. "18.1"
|
|
47
|
+
"securityPatch": str | None, # Android "YYYY-MM-DD", None on iOS
|
|
48
|
+
"appVersion": str | None,
|
|
49
|
+
"screenLock": bool | None,
|
|
50
|
+
"biometrics": "face" | "touch" | "fingerprint" | "iris" | "none" | None,
|
|
51
|
+
"jailbroken": bool, # jailbroken or rooted, by the app's checks
|
|
52
|
+
"compromised": bool, # jailbroken/rooted or tampered (sticky for a cool-down)
|
|
53
|
+
"hardwareKey": bool, # approvals are signed by a Secure Enclave / Keystore key
|
|
54
|
+
"attestation": "apple-appattest" | "android-key" | None,
|
|
55
|
+
"signed": bool, # the latest report was signed by the hardware key
|
|
56
|
+
"reportedAt": str | None, # ISO date-time of the latest report
|
|
57
|
+
"lastSeenAt": str | None,
|
|
58
|
+
"linkedAt": str | None,
|
|
59
|
+
"state": "healthy" | "at_risk" | "compromised" | "unknown",
|
|
60
|
+
"issues": [str], # jailbroken, tampered, integrity_failed, no_report,
|
|
61
|
+
# stale_report, no_screen_lock, keys_lost,
|
|
62
|
+
# no_hardware_key, debugger, developer_mode
|
|
63
|
+
}
|
|
64
|
+
"""
|
|
65
|
+
return self._http.get(f"/users/{quote(external_username, safe='')}")
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Device risk (a paid add-on, off until your workspace turns it on): a visitor
|
|
2
|
+
ID for every browser on your sign-up, password-reset, sign-in or approval page.
|
|
3
|
+
|
|
4
|
+
The page identifies with ``@touchque/web`` (``deviceRisk.identify({ siteKey,
|
|
5
|
+
purpose })`` gives ``{ visitorId, requestId, confidence }``) and sends you the
|
|
6
|
+
requestId. Never trust the page's copy: read the identification here
|
|
7
|
+
(``get_event``) or check it for an account (``check``). TouchQue keeps keyed
|
|
8
|
+
hashes only, with a key of your workspace alone, and its risk engine decides.
|
|
9
|
+
|
|
10
|
+
Every signal only ever asks for more: ``stepUp`` means an extra check on this
|
|
11
|
+
page (an emailed code, a passkey, a number-matched approval), never a refusal.
|
|
12
|
+
For sign-ins, pass the requestId to ``login.request`` / ``risk.check`` as
|
|
13
|
+
``device_risk_request_id`` instead. Collect it only on these pages and name it
|
|
14
|
+
in your privacy notice.
|
|
15
|
+
|
|
16
|
+
With the script tag (device.js), the form arrives with ``touchque_request_id``::
|
|
17
|
+
|
|
18
|
+
r = tq.device_risk.check(request.form.get('touchque_request_id'), event='sign_in', external_username=email)
|
|
19
|
+
|
|
20
|
+
Pass it as it came. Missing, empty, made up or expired, it is still a verdict
|
|
21
|
+
(``report``: ``missing``, ``unknown``, ``expired``; the signal
|
|
22
|
+
``device_identification_missing``), never an exception: a page without the
|
|
23
|
+
tag, or with it blocked, only gets the visitor a step-up.
|
|
24
|
+
"""
|
|
25
|
+
import re
|
|
26
|
+
from typing import Any, Dict, Optional
|
|
27
|
+
|
|
28
|
+
from ..exceptions import TouchQueConfigException
|
|
29
|
+
from ..http_client import HttpClient
|
|
30
|
+
|
|
31
|
+
#: The pages a device report may come from.
|
|
32
|
+
DEVICE_RISK_EVENTS = ('sign_in', 'sign_up', 'password_reset', 'approval')
|
|
33
|
+
#: What the page had sent: ``ok``, ``replayed``, or no usable identification.
|
|
34
|
+
DEVICE_RISK_REPORTS = ('ok', 'replayed', 'missing', 'unknown', 'expired', 'invalid')
|
|
35
|
+
|
|
36
|
+
_REQUEST_ID = re.compile(r'^dre_[A-Za-z0-9_-]{22}$')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_device_risk_check(raw: Any) -> Dict[str, Any]:
|
|
40
|
+
"""Reads a ``POST /device-risk/check`` answer, failing toward asking:
|
|
41
|
+
``stepUp`` is True unless it says exactly False; junk signals are dropped."""
|
|
42
|
+
body = raw if isinstance(raw, dict) else {}
|
|
43
|
+
signals = []
|
|
44
|
+
for s in body.get('signals') or []:
|
|
45
|
+
if isinstance(s, dict) and isinstance(s.get('code'), str):
|
|
46
|
+
item = {
|
|
47
|
+
'code': s['code'],
|
|
48
|
+
'text': s['text'] if isinstance(s.get('text'), str) else '',
|
|
49
|
+
'strength': s['strength'] if s.get('strength') in ('passkey', 'none') else 'number_match',
|
|
50
|
+
}
|
|
51
|
+
if isinstance(s.get('enforced'), bool):
|
|
52
|
+
item['enforced'] = s['enforced']
|
|
53
|
+
if isinstance(s.get('evidence'), dict):
|
|
54
|
+
item['evidence'] = s['evidence']
|
|
55
|
+
signals.append(item)
|
|
56
|
+
out = {
|
|
57
|
+
'report': body.get('report') if body.get('report') in DEVICE_RISK_REPORTS else 'invalid',
|
|
58
|
+
'requestId': body.get('requestId') if isinstance(body.get('requestId'), str) else None,
|
|
59
|
+
'visitorId': body.get('visitorId') if isinstance(body.get('visitorId'), str) else None,
|
|
60
|
+
'confidence': body.get('confidence') if isinstance(body.get('confidence'), (int, float)) and not isinstance(body.get('confidence'), bool) else None,
|
|
61
|
+
'visitor': body.get('visitor') if isinstance(body.get('visitor'), dict) else None,
|
|
62
|
+
'counts': body.get('counts') if isinstance(body.get('counts'), dict) else None,
|
|
63
|
+
# The address (never coordinates), the visitor's counts, one answer to "a bot?" (detected | suspected | not_detected).
|
|
64
|
+
'network': body.get('network') if isinstance(body.get('network'), dict) else None,
|
|
65
|
+
'velocity': body.get('velocity') if isinstance(body.get('velocity'), dict) else None,
|
|
66
|
+
'bot': body.get('bot') if isinstance(body.get('bot'), dict) else None,
|
|
67
|
+
'signals': signals,
|
|
68
|
+
'stepUp': body.get('stepUp') is not False,
|
|
69
|
+
'wouldStepUp': body.get('wouldStepUp') is True,
|
|
70
|
+
'requires': body.get('requires') if body.get('requires') in ('number_match', 'passkey') else 'none',
|
|
71
|
+
'mode': 'enforce' if body.get('mode') == 'enforce' else 'shadow',
|
|
72
|
+
# The engine's state for this check; 'unavailable' when the risk engine could not be reached and the verdict is a safe default.
|
|
73
|
+
'engine': body.get('engine') if isinstance(body.get('engine'), str) else None,
|
|
74
|
+
}
|
|
75
|
+
if isinstance(body.get('caseCode'), str):
|
|
76
|
+
out['caseCode'] = body['caseCode']
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class DeviceRisk:
|
|
81
|
+
def __init__(self, http: HttpClient):
|
|
82
|
+
self._http = http
|
|
83
|
+
|
|
84
|
+
def check(
|
|
85
|
+
self,
|
|
86
|
+
request_id: Any = None,
|
|
87
|
+
event: str = 'sign_in',
|
|
88
|
+
external_username: Optional[str] = None,
|
|
89
|
+
client_ip: Optional[str] = None,
|
|
90
|
+
device_risk: Optional[str] = None,
|
|
91
|
+
) -> Dict[str, Any]:
|
|
92
|
+
"""Checks the page's identification for an account: binds it (one
|
|
93
|
+
account per identification, 15 minutes) and asks the risk engine. Pass
|
|
94
|
+
the form field as it came: None, empty or made up is still a verdict
|
|
95
|
+
(``report``: ``missing``, ``unknown``), never an exception. 409
|
|
96
|
+
``device_risk_off`` until your workspace turns Device risk on.
|
|
97
|
+
|
|
98
|
+
:param request_id: the ``touchque_request_id`` form field (the script tag), or ``deviceRisk.identify()``'s requestId.
|
|
99
|
+
:param event: ``sign_in``, ``sign_up``, ``password_reset`` or ``approval``.
|
|
100
|
+
:param external_username: the account the page is for, when there is one.
|
|
101
|
+
:param client_ip: the end user's address (default: where the browser identified from).
|
|
102
|
+
:param device_risk: instead of request_id, the page's report (``deviceRisk.collect()``).
|
|
103
|
+
|
|
104
|
+
Example::
|
|
105
|
+
|
|
106
|
+
r = tq.device_risk.check(request.form.get('touchque_request_id'), event='sign_up', external_username=email, client_ip=ip)
|
|
107
|
+
if r['stepUp']:
|
|
108
|
+
ask_for_email_code() # never refuse on it alone
|
|
109
|
+
"""
|
|
110
|
+
if event not in DEVICE_RISK_EVENTS:
|
|
111
|
+
raise TouchQueConfigException(f"device_risk.check() event must be one of {', '.join(DEVICE_RISK_EVENTS)}")
|
|
112
|
+
payload: Dict[str, Any] = {}
|
|
113
|
+
if isinstance(request_id, str) and request_id:
|
|
114
|
+
payload['requestId'] = request_id[:64]
|
|
115
|
+
elif isinstance(device_risk, str) and device_risk:
|
|
116
|
+
payload['deviceRisk'] = device_risk
|
|
117
|
+
payload['event'] = event
|
|
118
|
+
if external_username:
|
|
119
|
+
payload['externalUsername'] = external_username
|
|
120
|
+
if client_ip:
|
|
121
|
+
payload['clientIp'] = client_ip
|
|
122
|
+
return parse_device_risk_check(self._http.post('/device-risk/check', payload))
|
|
123
|
+
|
|
124
|
+
def search_events(self, from_: Optional[str] = None, to: Optional[str] = None, signal: Optional[str] = None,
|
|
125
|
+
step_up: Optional[bool] = None, kind: Optional[str] = None, origin: Optional[str] = None,
|
|
126
|
+
ip: Optional[str] = None, account: Optional[str] = None, visitor_id: Optional[str] = None,
|
|
127
|
+
request_id: Optional[str] = None, cursor: Optional[str] = None, limit: Optional[int] = None) -> Dict[str, Any]:
|
|
128
|
+
"""Your workspace's identifications, newest first, one page at a time:
|
|
129
|
+
a date range (``from_``/``to``: ``YYYY-MM-DD`` or ISO times; 7 days by
|
|
130
|
+
default, at most 90, within your retention) and any of the filters.
|
|
131
|
+
``ip`` is an address or a CIDR range and is never returned. Pass
|
|
132
|
+
``nextCursor`` back as ``cursor`` until it is ``None``; do not page by
|
|
133
|
+
moving the dates. A search that would read too much is a 422
|
|
134
|
+
``search_too_broad``: narrow the range or add a filter.
|
|
135
|
+
|
|
136
|
+
Returns ``{ events, nextCursor, from, to }``.
|
|
137
|
+
|
|
138
|
+
Example::
|
|
139
|
+
|
|
140
|
+
cursor = None
|
|
141
|
+
while True:
|
|
142
|
+
page = tq.device_risk.search_events(signal='device_tampered', cursor=cursor)
|
|
143
|
+
for e in page['events']:
|
|
144
|
+
print(e['at'], e['visitorId'], e['caseCode'])
|
|
145
|
+
cursor = page['nextCursor']
|
|
146
|
+
if not cursor:
|
|
147
|
+
break
|
|
148
|
+
"""
|
|
149
|
+
if limit is not None and (not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 100):
|
|
150
|
+
raise TouchQueConfigException('device_risk.search_events() limit must be 1 to 100')
|
|
151
|
+
params: Dict[str, Any] = {}
|
|
152
|
+
for key, value in (('from', from_), ('to', to), ('signal', signal), ('kind', kind), ('origin', origin), ('ip', ip),
|
|
153
|
+
('account', account), ('visitorId', visitor_id), ('requestId', request_id), ('cursor', cursor)):
|
|
154
|
+
if isinstance(value, str) and value:
|
|
155
|
+
params[key] = value
|
|
156
|
+
if isinstance(step_up, bool):
|
|
157
|
+
params['stepUp'] = 'true' if step_up else 'false'
|
|
158
|
+
if limit is not None:
|
|
159
|
+
params['limit'] = limit
|
|
160
|
+
raw = self._http.get('/device-risk/events', params) or {}
|
|
161
|
+
cursor_out = raw.get('nextCursor')
|
|
162
|
+
return {
|
|
163
|
+
'events': raw.get('events') if isinstance(raw.get('events'), list) else [],
|
|
164
|
+
'nextCursor': cursor_out if isinstance(cursor_out, str) and cursor_out else None,
|
|
165
|
+
'from': raw.get('from') or '',
|
|
166
|
+
'to': raw.get('to') or '',
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
def get_event(self, request_id: str) -> Dict[str, Any]:
|
|
170
|
+
"""The trusted identification behind a request_id: the visitor, how sure,
|
|
171
|
+
what the page showed, the risk engine's verdict. Read-only."""
|
|
172
|
+
if not isinstance(request_id, str) or not _REQUEST_ID.match(request_id):
|
|
173
|
+
raise TouchQueConfigException('device_risk.get_event() needs a request_id (dre_...)')
|
|
174
|
+
return self._http.get(f'/device-risk/events/{request_id}')
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""TouchQue Login Management API (``client.identity``): your customers'
|
|
2
|
+
accounts, roles, business-customer organizations, apps, APIs, connections and
|
|
3
|
+
the tenant itself, from your server with your workspace key.
|
|
4
|
+
|
|
5
|
+
found = client.identity.users.list(q="ayse")
|
|
6
|
+
client.identity.users.block(user_id, reason="chargeback")
|
|
7
|
+
job = client.identity.imports.start(users=[{"email": e, "passwordHash": "$2b$10$..."}])
|
|
8
|
+
client.identity.imports.wait(job["id"])
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
from ..http_client import HttpClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _id(value: str) -> str:
|
|
19
|
+
return quote(str(value), safe='')
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _clean(d: Dict[str, Any]) -> Dict[str, Any]:
|
|
23
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class IdentityUsers:
|
|
27
|
+
def __init__(self, http: HttpClient):
|
|
28
|
+
self._http = http
|
|
29
|
+
|
|
30
|
+
def list(self, q: Optional[str] = None, blocked: Optional[bool] = None, email_verified: Optional[bool] = None,
|
|
31
|
+
connection: Optional[str] = None, organization: Optional[str] = None, limit: Optional[int] = None,
|
|
32
|
+
cursor: Optional[str] = None) -> Dict[str, Any]:
|
|
33
|
+
return self._http.get('/identity/users', _clean({
|
|
34
|
+
'q': q, 'blocked': blocked, 'emailVerified': email_verified, 'connection': connection,
|
|
35
|
+
'organization': organization, 'limit': limit, 'cursor': cursor,
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
def get(self, user_id: str) -> Dict[str, Any]:
|
|
39
|
+
return self._http.get(f'/identity/users/{_id(user_id)}')
|
|
40
|
+
|
|
41
|
+
def create(self, email: str, **fields: Any) -> Dict[str, Any]:
|
|
42
|
+
"""fields: password, passwordHash (bcrypt/argon2), customPasswordHash, emailVerified,
|
|
43
|
+
name, givenName, familyName, phone, locale, userMetadata, appMetadata, sendVerificationEmail."""
|
|
44
|
+
return self._http.post('/identity/users', {'email': email, **fields})
|
|
45
|
+
|
|
46
|
+
def update(self, user_id: str, **changes: Any) -> Dict[str, Any]:
|
|
47
|
+
"""Metadata is merged; a key set to None is removed."""
|
|
48
|
+
return self._http.patch(f'/identity/users/{_id(user_id)}', changes)
|
|
49
|
+
|
|
50
|
+
def delete(self, user_id: str) -> Dict[str, Any]:
|
|
51
|
+
"""Erasure (KVKK/GDPR): the account and every workspace row that names it."""
|
|
52
|
+
return self._http.delete(f'/identity/users/{_id(user_id)}')
|
|
53
|
+
|
|
54
|
+
def block(self, user_id: str, reason: Optional[str] = None) -> Dict[str, Any]:
|
|
55
|
+
return self._http.post(f'/identity/users/{_id(user_id)}/block', _clean({'reason': reason}))
|
|
56
|
+
|
|
57
|
+
def unblock(self, user_id: str) -> Dict[str, Any]:
|
|
58
|
+
return self._http.post(f'/identity/users/{_id(user_id)}/unblock', {})
|
|
59
|
+
|
|
60
|
+
def export(self, user_id: str) -> Dict[str, Any]:
|
|
61
|
+
"""Everything about the person (KVKK/GDPR access request); never hashes."""
|
|
62
|
+
return self._http.get(f'/identity/users/{_id(user_id)}/export')
|
|
63
|
+
|
|
64
|
+
def sessions(self, user_id: str) -> Dict[str, Any]:
|
|
65
|
+
return self._http.get(f'/identity/users/{_id(user_id)}/sessions')
|
|
66
|
+
|
|
67
|
+
def end_session(self, user_id: str, session_id: str) -> Dict[str, Any]:
|
|
68
|
+
"""Signs the person out of one session (the others stay)."""
|
|
69
|
+
return self._http.delete(f'/identity/users/{_id(user_id)}/sessions/{_id(session_id)}')
|
|
70
|
+
|
|
71
|
+
def sign_out_everywhere(self, user_id: str) -> Dict[str, Any]:
|
|
72
|
+
return self._http.delete(f'/identity/users/{_id(user_id)}/sessions')
|
|
73
|
+
|
|
74
|
+
def reset_mfa(self, user_id: str) -> Dict[str, Any]:
|
|
75
|
+
"""Removes passkeys, unlinks the TouchQue app, turns email codes off, signs out everywhere."""
|
|
76
|
+
return self._http.delete(f'/identity/users/{_id(user_id)}/mfa')
|
|
77
|
+
|
|
78
|
+
def connected_accounts(self, user_id: str) -> Dict[str, Any]:
|
|
79
|
+
"""Accounts at providers whose tokens the vault keeps for the user (connections with tokenVault on)."""
|
|
80
|
+
return self._http.get(f'/identity/users/{_id(user_id)}/connected-accounts')
|
|
81
|
+
|
|
82
|
+
def disconnect_account(self, user_id: str, account_id: str) -> Dict[str, Any]:
|
|
83
|
+
"""Deletes the stored tokens: apps stop using the account for the user; signing in with it stays."""
|
|
84
|
+
return self._http.delete(f'/identity/users/{_id(user_id)}/connected-accounts/{_id(account_id)}')
|
|
85
|
+
|
|
86
|
+
def unlink_identity(self, user_id: str, identity_id: str, force: bool = False) -> Dict[str, Any]:
|
|
87
|
+
return self._http.delete(f'/identity/users/{_id(user_id)}/identities/{_id(identity_id)}' + ('?force=true' if force else ''))
|
|
88
|
+
|
|
89
|
+
def send_verification_email(self, user_id: str, return_to: Optional[str] = None) -> Dict[str, Any]:
|
|
90
|
+
return self._http.post(f'/identity/users/{_id(user_id)}/verification-email', _clean({'returnTo': return_to}))
|
|
91
|
+
|
|
92
|
+
def password_reset(self, user_id: str, send_email: bool = True, ttl_sec: Optional[int] = None) -> Dict[str, Any]:
|
|
93
|
+
"""send_email=False: the link comes back for you to deliver."""
|
|
94
|
+
return self._http.post(f'/identity/users/{_id(user_id)}/password-reset', _clean({'sendEmail': send_email, 'ttlSec': ttl_sec}))
|
|
95
|
+
|
|
96
|
+
def roles(self, user_id: str) -> Dict[str, Any]:
|
|
97
|
+
return self._http.get(f'/identity/users/{_id(user_id)}/roles')
|
|
98
|
+
|
|
99
|
+
def add_roles(self, user_id: str, role_ids: List[str], organization_id: Optional[str] = None) -> Dict[str, Any]:
|
|
100
|
+
return self._http.post(f'/identity/users/{_id(user_id)}/roles', _clean({'roleIds': role_ids, 'organizationId': organization_id}))
|
|
101
|
+
|
|
102
|
+
def remove_role(self, user_id: str, role_id: str, organization_id: Optional[str] = None) -> Dict[str, Any]:
|
|
103
|
+
query = f'?organizationId={_id(organization_id)}' if organization_id else ''
|
|
104
|
+
return self._http.delete(f'/identity/users/{_id(user_id)}/roles/{_id(role_id)}{query}')
|
|
105
|
+
|
|
106
|
+
def logs(self, user_id: str, limit: Optional[int] = None) -> Dict[str, Any]:
|
|
107
|
+
return self._http.get(f'/identity/users/{_id(user_id)}/logs', _clean({'limit': limit}))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class IdentityJobs:
|
|
111
|
+
def __init__(self, http: HttpClient, kind: str):
|
|
112
|
+
self._http = http
|
|
113
|
+
self._kind = kind
|
|
114
|
+
|
|
115
|
+
def start(self, users: Optional[List[Dict[str, Any]]] = None, upsert: Optional[bool] = None, format: Optional[str] = None) -> Dict[str, Any]:
|
|
116
|
+
"""imports: users (and upsert); exports: format 'csv' or 'json'."""
|
|
117
|
+
return self._http.post(f'/identity/users-{self._kind}', _clean({'users': users, 'upsert': upsert, 'format': format}))
|
|
118
|
+
|
|
119
|
+
def get(self, job_id: str) -> Dict[str, Any]:
|
|
120
|
+
return self._http.get(f'/identity/users-{self._kind}/{_id(job_id)}')
|
|
121
|
+
|
|
122
|
+
def file(self, job_id: str) -> str:
|
|
123
|
+
"""Exports: the finished file (CSV or JSON lines), as text."""
|
|
124
|
+
return self._http.get_text(f'/identity/users-{self._kind}/{_id(job_id)}/file')
|
|
125
|
+
|
|
126
|
+
def wait(self, job_id: str, interval: float = 1.0, timeout: float = 600.0) -> Dict[str, Any]:
|
|
127
|
+
end = time.time() + timeout
|
|
128
|
+
while True:
|
|
129
|
+
job = self.get(job_id)
|
|
130
|
+
if job.get('status') != 'running' or time.time() > end:
|
|
131
|
+
return job
|
|
132
|
+
time.sleep(interval)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class _Crud:
|
|
136
|
+
def __init__(self, http: HttpClient, base: str, list_key: str):
|
|
137
|
+
self._http = http
|
|
138
|
+
self._base = base
|
|
139
|
+
self._list_key = list_key
|
|
140
|
+
|
|
141
|
+
def list(self, **params: Any) -> List[Dict[str, Any]]:
|
|
142
|
+
return self._http.get(self._base, _clean(params) or None).get(self._list_key, [])
|
|
143
|
+
|
|
144
|
+
def get(self, item_id: str) -> Dict[str, Any]:
|
|
145
|
+
return self._http.get(f'{self._base}/{_id(item_id)}')
|
|
146
|
+
|
|
147
|
+
def create(self, **body: Any) -> Dict[str, Any]:
|
|
148
|
+
return self._http.post(self._base, body)
|
|
149
|
+
|
|
150
|
+
def update(self, item_id: str, **body: Any) -> Dict[str, Any]:
|
|
151
|
+
return self._http.patch(f'{self._base}/{_id(item_id)}', body)
|
|
152
|
+
|
|
153
|
+
def delete(self, item_id: str) -> Dict[str, Any]:
|
|
154
|
+
return self._http.delete(f'{self._base}/{_id(item_id)}')
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class IdentityOrganizations(_Crud):
|
|
158
|
+
def __init__(self, http: HttpClient):
|
|
159
|
+
super().__init__(http, '/identity/organizations', 'organizations')
|
|
160
|
+
|
|
161
|
+
def members(self, org_id: str) -> Dict[str, Any]:
|
|
162
|
+
return self._http.get(f'{self._base}/{_id(org_id)}/members')
|
|
163
|
+
|
|
164
|
+
def add_members(self, org_id: str, user_ids: List[str]) -> Dict[str, Any]:
|
|
165
|
+
return self._http.post(f'{self._base}/{_id(org_id)}/members', {'userIds': user_ids})
|
|
166
|
+
|
|
167
|
+
def remove_member(self, org_id: str, user_id: str) -> Dict[str, Any]:
|
|
168
|
+
return self._http.delete(f'{self._base}/{_id(org_id)}/members/{_id(user_id)}')
|
|
169
|
+
|
|
170
|
+
def set_connections(self, org_id: str, connections: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
171
|
+
return self._http.put(f'{self._base}/{_id(org_id)}/connections', {'connections': connections})
|
|
172
|
+
|
|
173
|
+
def invitations(self, org_id: str) -> Dict[str, Any]:
|
|
174
|
+
return self._http.get(f'{self._base}/{_id(org_id)}/invitations')
|
|
175
|
+
|
|
176
|
+
def invite(self, org_id: str, email: str, client_id: str, role_ids: Optional[List[str]] = None,
|
|
177
|
+
inviter_name: Optional[str] = None, send_email: Optional[bool] = None) -> Dict[str, Any]:
|
|
178
|
+
"""The link opens the app's sign-in route (initiateLoginUri) with organization and invitation."""
|
|
179
|
+
return self._http.post(f'{self._base}/{_id(org_id)}/invitations', _clean({
|
|
180
|
+
'email': email, 'clientId': client_id, 'roleIds': role_ids, 'inviterName': inviter_name, 'sendEmail': send_email,
|
|
181
|
+
}))
|
|
182
|
+
|
|
183
|
+
def revoke_invitation(self, org_id: str, invitation_id: str) -> Dict[str, Any]:
|
|
184
|
+
return self._http.delete(f'{self._base}/{_id(org_id)}/invitations/{_id(invitation_id)}')
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class IdentityClients(_Crud):
|
|
188
|
+
def __init__(self, http: HttpClient):
|
|
189
|
+
super().__init__(http, '/identity/clients', 'clients')
|
|
190
|
+
|
|
191
|
+
def rotate_secret(self, client_id: str) -> Dict[str, Any]:
|
|
192
|
+
"""The new secret is in the answer, this once."""
|
|
193
|
+
return self._http.post(f'{self._base}/{_id(client_id)}/rotate-secret', {})
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Identity:
|
|
197
|
+
def __init__(self, http: HttpClient):
|
|
198
|
+
self._http = http
|
|
199
|
+
self.users = IdentityUsers(http)
|
|
200
|
+
self.imports = IdentityJobs(http, 'imports')
|
|
201
|
+
self.exports = IdentityJobs(http, 'exports')
|
|
202
|
+
self.roles = _Crud(http, '/identity/roles', 'roles')
|
|
203
|
+
self.organizations = IdentityOrganizations(http)
|
|
204
|
+
self.clients = IdentityClients(http)
|
|
205
|
+
self.apis = _Crud(http, '/identity/apis', 'apis')
|
|
206
|
+
self.connections = _Crud(http, '/identity/connections', 'connections')
|
|
207
|
+
self.custom_domains = _Crud(http, '/identity/custom-domains', 'domains')
|
|
208
|
+
|
|
209
|
+
def get_tenant(self) -> Dict[str, Any]:
|
|
210
|
+
return self._http.get('/identity/tenant')
|
|
211
|
+
|
|
212
|
+
def update_tenant(self, **body: Any) -> Dict[str, Any]:
|
|
213
|
+
"""Turns TouchQue Login on (slug, displayName) or changes its settings."""
|
|
214
|
+
return self._http.put('/identity/tenant', body)
|
|
215
|
+
|
|
216
|
+
def rotate_signing_key(self) -> Dict[str, Any]:
|
|
217
|
+
return self._http.post('/identity/tenant/signing-keys/rotate', {})
|
|
218
|
+
|
|
219
|
+
def set_approver_app_name(self, name: Optional[str]) -> Dict[str, Any]:
|
|
220
|
+
"""The phone app the hosted pages tell customers to approve on ("Approve on the Acme app").
|
|
221
|
+
|
|
222
|
+
None: your brand name when the Mobile SDK is set up, else TouchQue (``approverApp`` in the tenant shows which).
|
|
223
|
+
"""
|
|
224
|
+
return self._http.put('/identity/tenant', {'branding': {'approverAppName': name}})
|
|
225
|
+
|
|
226
|
+
def set_custom_domain_only(self, on: bool) -> Dict[str, Any]:
|
|
227
|
+
"""Serve sign-in only on the primary custom domain: {slug}.login.touchque.com redirects page loads there and
|
|
228
|
+
refuses other calls. Turning it on needs a verified primary domain with a working certificate."""
|
|
229
|
+
return self._http.put('/identity/tenant', {'customDomainOnly': on})
|
|
230
|
+
|
|
231
|
+
def check_domain_certificate(self, domain_id: str) -> Dict[str, Any]:
|
|
232
|
+
"""Checks a verified custom domain's certificate at the sign-in edge now (``certificate``: pending | active | error)."""
|
|
233
|
+
return self._http.post(f'/identity/custom-domains/{_id(domain_id)}/check-certificate', {})
|
|
234
|
+
|
|
235
|
+
def outbox(self, to: Optional[str] = None, limit: Optional[int] = None) -> Dict[str, Any]:
|
|
236
|
+
"""The delivery log. Message text only in the local dev inbox."""
|
|
237
|
+
return self._http.get('/identity/outbox', _clean({'to': to, 'limit': limit}))
|
|
238
|
+
|
|
239
|
+
def get_attack_protection(self) -> Dict[str, Any]:
|
|
240
|
+
"""Wrong passwords, suspicious addresses, bot challenge, breached passwords, new-browser notices, the risk engine."""
|
|
241
|
+
return self._http.get('/identity/attack-protection')
|
|
242
|
+
|
|
243
|
+
def set_attack_protection(self, **settings: Any) -> Dict[str, Any]:
|
|
244
|
+
"""Partial; the bot challenge's secret is write-only."""
|
|
245
|
+
return self._http.put('/identity/attack-protection', settings)
|
|
246
|
+
|
|
247
|
+
def attack_events(self, limit: Optional[int] = None) -> Dict[str, Any]:
|
|
248
|
+
"""What attack protection caught lately."""
|
|
249
|
+
return self._http.get('/identity/attack-protection/events', _clean({'limit': limit}))
|
|
250
|
+
|
|
251
|
+
def get_delivery(self) -> Dict[str, Any]:
|
|
252
|
+
"""The email and SMS providers; secrets never come back (secretsSet)."""
|
|
253
|
+
return self._http.get('/identity/delivery')
|
|
254
|
+
|
|
255
|
+
def set_delivery(self, channel: str, **provider: Any) -> Dict[str, Any]:
|
|
256
|
+
"""Your own provider: email smtp, ses, postmark, sendgrid; sms twilio, netgsm.
|
|
257
|
+
provider='postmark', from_='login@acme.com', serverToken=... (from_ is sent as "from").
|
|
258
|
+
A secret left out keeps its stored value."""
|
|
259
|
+
body = {('from' if k == 'from_' else k): v for k, v in provider.items()}
|
|
260
|
+
return self._http.put(f'/identity/delivery/{_id(channel)}', body)
|
|
261
|
+
|
|
262
|
+
def remove_delivery(self, channel: str) -> Dict[str, Any]:
|
|
263
|
+
"""Back to TouchQue's transport."""
|
|
264
|
+
return self._http.delete(f'/identity/delivery/{_id(channel)}')
|
|
265
|
+
|
|
266
|
+
def test_delivery(self, channel: str, to: str) -> Dict[str, Any]:
|
|
267
|
+
"""One test message through the provider in use."""
|
|
268
|
+
return self._http.post(f'/identity/delivery/{_id(channel)}/test', {'to': to})
|