touchque-authenticator 4.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. touchque/__init__.py +148 -0
  2. touchque/agent.py +314 -0
  3. touchque/agent_card.py +190 -0
  4. touchque/agent_frameworks.py +215 -0
  5. touchque/approval_proof.py +104 -0
  6. touchque/approval_token.py +134 -0
  7. touchque/client.py +105 -0
  8. touchque/code_reply.py +75 -0
  9. touchque/config.py +56 -0
  10. touchque/contrib/__init__.py +4 -0
  11. touchque/contrib/django.py +175 -0
  12. touchque/contrib/fastapi.py +208 -0
  13. touchque/contrib/flask.py +154 -0
  14. touchque/cross_app_access.py +222 -0
  15. touchque/exceptions.py +170 -0
  16. touchque/guard.py +290 -0
  17. touchque/hosted_login.py +474 -0
  18. touchque/http_client.py +156 -0
  19. touchque/mcp_auth.py +256 -0
  20. touchque/my_account.py +173 -0
  21. touchque/org_agent.py +262 -0
  22. touchque/rate_limiter.py +56 -0
  23. touchque/relay.py +643 -0
  24. touchque/resources/__init__.py +1 -0
  25. touchque/resources/action_types.py +52 -0
  26. touchque/resources/actions.py +234 -0
  27. touchque/resources/agents.py +236 -0
  28. touchque/resources/assist.py +115 -0
  29. touchque/resources/auth.py +65 -0
  30. touchque/resources/device_risk.py +174 -0
  31. touchque/resources/identity.py +268 -0
  32. touchque/resources/login.py +296 -0
  33. touchque/resources/mobile.py +130 -0
  34. touchque/resources/offline.py +138 -0
  35. touchque/resources/qr_pairings.py +93 -0
  36. touchque/resources/recovery.py +118 -0
  37. touchque/resources/risk.py +141 -0
  38. touchque/resources/safe_guard.py +99 -0
  39. touchque/resources/webauthn.py +115 -0
  40. touchque/resources/webhook.py +258 -0
  41. touchque/safe_guard.py +308 -0
  42. touchque/security_events.py +205 -0
  43. touchque/steps.py +239 -0
  44. touchque_authenticator-4.2.0.dist-info/METADATA +630 -0
  45. touchque_authenticator-4.2.0.dist-info/RECORD +48 -0
  46. touchque_authenticator-4.2.0.dist-info/WHEEL +5 -0
  47. touchque_authenticator-4.2.0.dist-info/licenses/LICENSE +21 -0
  48. touchque_authenticator-4.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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})