tausi 0.1.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.
- tausi/__init__.py +39 -0
- tausi/_http.py +119 -0
- tausi/backend.py +209 -0
- tausi/client.py +298 -0
- tausi/django/__init__.py +189 -0
- tausi/django/rest_framework.py +49 -0
- tausi/errors.py +33 -0
- tausi/flask/__init__.py +146 -0
- tausi/models.py +192 -0
- tausi/pkce.py +17 -0
- tausi/py.typed +0 -0
- tausi/token_store.py +45 -0
- tausi-0.1.0.dist-info/METADATA +235 -0
- tausi-0.1.0.dist-info/RECORD +16 -0
- tausi-0.1.0.dist-info/WHEEL +4 -0
- tausi-0.1.0.dist-info/licenses/LICENSE +21 -0
tausi/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Official Python SDK for Tausi.
|
|
2
|
+
|
|
3
|
+
* :class:`TausiBackend` (secret key ``sk_...``): verify session tokens and manage users.
|
|
4
|
+
* :class:`TausiClient` (publishable key ``pk_...``): sign users in from server-rendered apps, scripts and CLIs.
|
|
5
|
+
* ``tausi.django`` and ``tausi.flask``: framework integrations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._http import SDK_VERSION, HttpResponse, Transport, urllib_transport
|
|
9
|
+
from .backend import TausiBackend, bearer_token
|
|
10
|
+
from .client import TausiClient, oauth_error_message, parse_oauth_redirect, random_device_id
|
|
11
|
+
from .errors import TausiError
|
|
12
|
+
from .models import AuthResult, OAuthStart, Page, Session, User
|
|
13
|
+
from .pkce import create_pkce
|
|
14
|
+
from .token_store import MappingTokenStore, MemoryTokenStore, TokenStore
|
|
15
|
+
|
|
16
|
+
__version__ = SDK_VERSION
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"SDK_VERSION",
|
|
20
|
+
"AuthResult",
|
|
21
|
+
"HttpResponse",
|
|
22
|
+
"MappingTokenStore",
|
|
23
|
+
"MemoryTokenStore",
|
|
24
|
+
"OAuthStart",
|
|
25
|
+
"Page",
|
|
26
|
+
"Session",
|
|
27
|
+
"TausiBackend",
|
|
28
|
+
"TausiClient",
|
|
29
|
+
"TausiError",
|
|
30
|
+
"TokenStore",
|
|
31
|
+
"Transport",
|
|
32
|
+
"User",
|
|
33
|
+
"bearer_token",
|
|
34
|
+
"create_pkce",
|
|
35
|
+
"oauth_error_message",
|
|
36
|
+
"parse_oauth_redirect",
|
|
37
|
+
"random_device_id",
|
|
38
|
+
"urllib_transport",
|
|
39
|
+
]
|
tausi/_http.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from .errors import TausiError
|
|
12
|
+
|
|
13
|
+
SDK_VERSION = "0.1.0"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class HttpResponse:
|
|
18
|
+
status: int
|
|
19
|
+
headers: Dict[str, str] # lower-cased names
|
|
20
|
+
body: bytes = b""
|
|
21
|
+
|
|
22
|
+
def header(self, name: str) -> Optional[str]:
|
|
23
|
+
return self.headers.get(name.lower())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
#: A transport sends one request and returns the raw response (any status).
|
|
27
|
+
#: It raises OSError / TimeoutError for network problems. Swap it out for
|
|
28
|
+
#: tests or to use requests/httpx: ``transport(method, url, headers, body, timeout)``.
|
|
29
|
+
Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], HttpResponse]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
33
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None: # noqa: D401
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_opener = urllib.request.build_opener(_NoRedirect)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float) -> HttpResponse:
|
|
41
|
+
"""Default transport: the standard library, no dependencies."""
|
|
42
|
+
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
43
|
+
try:
|
|
44
|
+
with _opener.open(req, timeout=timeout) as res:
|
|
45
|
+
return HttpResponse(res.status, {k.lower(): v for k, v in res.headers.items()}, res.read())
|
|
46
|
+
except urllib.error.HTTPError as e:
|
|
47
|
+
return HttpResponse(e.code, {k.lower(): v for k, v in (e.headers or {}).items()}, e.read() or b"")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Http:
|
|
52
|
+
base_url: str
|
|
53
|
+
transport: Transport = urllib_transport
|
|
54
|
+
timeout: float = 10.0
|
|
55
|
+
user_agent: str = field(default=f"tausi-python/{SDK_VERSION}")
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
if not self.base_url.startswith(("http://", "https://")):
|
|
59
|
+
raise ValueError("base_url must be an absolute http(s) URL, e.g. https://tausi.app")
|
|
60
|
+
self.base = self.base_url.rstrip("/") + "/api/v1"
|
|
61
|
+
|
|
62
|
+
def request(self, method: str, path: str, headers: Mapping[str, str], body: Any = None) -> Tuple[Any, HttpResponse]:
|
|
63
|
+
"""Sends one request. Returns ``(data, response)`` or raises TausiError."""
|
|
64
|
+
h = {"accept": "application/json", "user-agent": self.user_agent, **headers}
|
|
65
|
+
raw: Optional[bytes] = None
|
|
66
|
+
if body is not None:
|
|
67
|
+
h["content-type"] = "application/json"
|
|
68
|
+
raw = json.dumps(body).encode()
|
|
69
|
+
try:
|
|
70
|
+
res = self.transport(method, self.base + path, h, raw, self.timeout)
|
|
71
|
+
except (TimeoutError, socket.timeout) as e:
|
|
72
|
+
raise TausiError(0, "TIMEOUT", "The authentication server did not answer in time.", details=e) from e
|
|
73
|
+
except urllib.error.URLError as e:
|
|
74
|
+
if isinstance(e.reason, (TimeoutError, socket.timeout)):
|
|
75
|
+
raise TausiError(0, "TIMEOUT", "The authentication server did not answer in time.", details=e) from e
|
|
76
|
+
raise TausiError(0, "NETWORK_ERROR", "Could not reach the authentication server.", details=e) from e
|
|
77
|
+
except OSError as e:
|
|
78
|
+
raise TausiError(0, "NETWORK_ERROR", "Could not reach the authentication server.", details=e) from e
|
|
79
|
+
|
|
80
|
+
payload: Dict[str, Any] = {}
|
|
81
|
+
if res.body:
|
|
82
|
+
try:
|
|
83
|
+
parsed = json.loads(res.body)
|
|
84
|
+
if isinstance(parsed, dict):
|
|
85
|
+
payload = parsed
|
|
86
|
+
except ValueError:
|
|
87
|
+
pass
|
|
88
|
+
if 200 <= res.status < 300:
|
|
89
|
+
return payload.get("data"), res
|
|
90
|
+
|
|
91
|
+
err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
|
|
92
|
+
retry: Optional[int] = None
|
|
93
|
+
try:
|
|
94
|
+
retry = int(res.header("retry-after") or "")
|
|
95
|
+
if retry <= 0:
|
|
96
|
+
retry = None
|
|
97
|
+
except ValueError:
|
|
98
|
+
pass
|
|
99
|
+
raise TausiError(
|
|
100
|
+
status=res.status,
|
|
101
|
+
code=str(err.get("code") or "UNKNOWN"),
|
|
102
|
+
message=str(err.get("message") or f"HTTP {res.status}"),
|
|
103
|
+
details=err.get("details"),
|
|
104
|
+
request_id=payload.get("requestId") or res.header("x-request-id"),
|
|
105
|
+
retry_after=retry,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def query(params: Mapping[str, Any]) -> str:
|
|
110
|
+
clean = {}
|
|
111
|
+
for k, v in params.items():
|
|
112
|
+
if v is None:
|
|
113
|
+
continue
|
|
114
|
+
clean[k] = ("true" if v else "false") if isinstance(v, bool) else str(v)
|
|
115
|
+
return "?" + urllib.parse.urlencode(clean) if clean else ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def seg(s: str) -> str:
|
|
119
|
+
return urllib.parse.quote(s, safe="")
|
tausi/backend.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Dict, Mapping, Optional, Sequence, Union
|
|
6
|
+
|
|
7
|
+
from ._http import Http, Transport, query, seg, urllib_transport
|
|
8
|
+
from .errors import TausiError
|
|
9
|
+
from .models import AuthResult, Page, Session, User
|
|
10
|
+
|
|
11
|
+
_BEARER = re.compile(r"^Bearer\s+(\S+)$", re.IGNORECASE)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def bearer_token(value: Any) -> Optional[str]:
|
|
15
|
+
"""Reads the token from ``Authorization: Bearer <token>``.
|
|
16
|
+
|
|
17
|
+
Accepts the header value itself, a headers mapping, or any request
|
|
18
|
+
object with ``.headers`` (Django, Flask, Starlette...) or ``.META``.
|
|
19
|
+
"""
|
|
20
|
+
header: Optional[str]
|
|
21
|
+
if value is None or isinstance(value, str):
|
|
22
|
+
header = value
|
|
23
|
+
else:
|
|
24
|
+
headers = getattr(value, "headers", None)
|
|
25
|
+
if headers is None and hasattr(value, "META"):
|
|
26
|
+
header = value.META.get("HTTP_AUTHORIZATION")
|
|
27
|
+
else:
|
|
28
|
+
src = headers if headers is not None else value
|
|
29
|
+
header = src.get("Authorization") or src.get("authorization")
|
|
30
|
+
m = _BEARER.match((header or "").strip())
|
|
31
|
+
return m.group(1) if m else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _until(until: Optional[Union[datetime, str]], hours: Optional[float]) -> Dict[str, Any]:
|
|
35
|
+
if (until is None) == (hours is None):
|
|
36
|
+
raise ValueError("Pass either until= or hours=.")
|
|
37
|
+
if hours is not None:
|
|
38
|
+
return {"durationHours": hours}
|
|
39
|
+
if isinstance(until, datetime):
|
|
40
|
+
until = (until if until.tzinfo else until.replace(tzinfo=timezone.utc)).isoformat()
|
|
41
|
+
return {"until": until}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Users:
|
|
45
|
+
def __init__(self, call: Any) -> None:
|
|
46
|
+
self._call = call
|
|
47
|
+
|
|
48
|
+
def list(
|
|
49
|
+
self,
|
|
50
|
+
q: Optional[str] = None,
|
|
51
|
+
verified: Optional[bool] = None,
|
|
52
|
+
page: Optional[int] = None,
|
|
53
|
+
page_size: Optional[int] = None,
|
|
54
|
+
access: Optional[str] = None,
|
|
55
|
+
) -> Page[User]:
|
|
56
|
+
"""``access``: "active", "suspended" or "banned"."""
|
|
57
|
+
d = self._call("GET", "/backend/users" + query({"q": q, "verified": verified, "access": access, "page": page, "pageSize": page_size}))
|
|
58
|
+
return Page([User.from_dict(u) for u in d["items"]], d["total"], d["page"], d["pageSize"])
|
|
59
|
+
|
|
60
|
+
def get(self, user_id: str) -> Dict[str, Any]:
|
|
61
|
+
"""``{"user": User, "status", "activeSessions", "lastActiveAt", "accounts", "sessions", "recentAudit", ...}``."""
|
|
62
|
+
d = dict(self._call("GET", f"/backend/users/{seg(user_id)}"))
|
|
63
|
+
d["user"] = User.from_dict({**d["user"], "status": d.get("status"), "activeSessions": d.get("activeSessions"), "lastActiveAt": d.get("lastActiveAt")})
|
|
64
|
+
return d
|
|
65
|
+
|
|
66
|
+
def update(
|
|
67
|
+
self,
|
|
68
|
+
user_id: str,
|
|
69
|
+
name: Optional[str] = None,
|
|
70
|
+
email_verified: Optional[bool] = None,
|
|
71
|
+
*,
|
|
72
|
+
first_name: Any = ...,
|
|
73
|
+
last_name: Any = ...,
|
|
74
|
+
username: Any = ...,
|
|
75
|
+
phone_number: Any = ...,
|
|
76
|
+
phone_number_verified: Optional[bool] = None,
|
|
77
|
+
locale: Any = ...,
|
|
78
|
+
timezone: Any = ...,
|
|
79
|
+
public_metadata: Optional[Dict[str, Any]] = None,
|
|
80
|
+
private_metadata: Optional[Dict[str, Any]] = None,
|
|
81
|
+
) -> User:
|
|
82
|
+
"""Changes a user. Roles can't be changed with a secret key.
|
|
83
|
+
|
|
84
|
+
Leave an attribute out to keep it, pass None to clear it. Metadata
|
|
85
|
+
replaces the stored object as a whole (max 8 KB each); public metadata
|
|
86
|
+
is readable by the user, private metadata never leaves your backend.
|
|
87
|
+
"""
|
|
88
|
+
from .client import _attrs
|
|
89
|
+
|
|
90
|
+
patch: Dict[str, Any] = {}
|
|
91
|
+
if name is not None:
|
|
92
|
+
patch["name"] = name
|
|
93
|
+
if email_verified is not None:
|
|
94
|
+
patch["emailVerified"] = email_verified
|
|
95
|
+
patch.update(_attrs(first_name=first_name, last_name=last_name, username=username, phone_number=phone_number, locale=locale, timezone=timezone, keep_none=True))
|
|
96
|
+
if phone_number_verified is not None:
|
|
97
|
+
patch["phoneNumberVerified"] = phone_number_verified
|
|
98
|
+
if public_metadata is not None:
|
|
99
|
+
patch["publicMetadata"] = public_metadata
|
|
100
|
+
if private_metadata is not None:
|
|
101
|
+
patch["privateMetadata"] = private_metadata
|
|
102
|
+
return User.from_dict(self._call("PATCH", f"/backend/users/{seg(user_id)}", patch)["user"])
|
|
103
|
+
|
|
104
|
+
def delete(self, user_id: str) -> None:
|
|
105
|
+
self._call("DELETE", f"/backend/users/{seg(user_id)}")
|
|
106
|
+
|
|
107
|
+
def ban(self, user_id: str, reason: Optional[str] = None) -> User:
|
|
108
|
+
"""Bans until unbanned: signs the user out everywhere and refuses sign-in (ACCOUNT_BANNED).
|
|
109
|
+
|
|
110
|
+
``reason`` is an internal note; the user never sees it.
|
|
111
|
+
"""
|
|
112
|
+
return User.from_dict(self._call("POST", f"/backend/users/{seg(user_id)}/ban", {"reason": reason})["user"])
|
|
113
|
+
|
|
114
|
+
def unban(self, user_id: str) -> User:
|
|
115
|
+
return User.from_dict(self._call("DELETE", f"/backend/users/{seg(user_id)}/ban")["user"])
|
|
116
|
+
|
|
117
|
+
def suspend(
|
|
118
|
+
self,
|
|
119
|
+
user_id: str,
|
|
120
|
+
*,
|
|
121
|
+
until: Optional[Union[datetime, str]] = None,
|
|
122
|
+
hours: Optional[float] = None,
|
|
123
|
+
reason: Optional[str] = None,
|
|
124
|
+
) -> User:
|
|
125
|
+
"""Suspends for ``hours`` or until ``until`` (1 minute to 1 year): signs the user out and
|
|
126
|
+
refuses sign-in (ACCOUNT_SUSPENDED) until then. It lifts by itself."""
|
|
127
|
+
return User.from_dict(self._call("POST", f"/backend/users/{seg(user_id)}/suspend", {**_until(until, hours), "reason": reason})["user"])
|
|
128
|
+
|
|
129
|
+
def unsuspend(self, user_id: str) -> User:
|
|
130
|
+
return User.from_dict(self._call("DELETE", f"/backend/users/{seg(user_id)}/suspend")["user"])
|
|
131
|
+
|
|
132
|
+
def bulk(
|
|
133
|
+
self,
|
|
134
|
+
action: str,
|
|
135
|
+
ids: Sequence[str],
|
|
136
|
+
*,
|
|
137
|
+
reason: Optional[str] = None,
|
|
138
|
+
until: Optional[Union[datetime, str]] = None,
|
|
139
|
+
hours: Optional[float] = None,
|
|
140
|
+
) -> Dict[str, Any]:
|
|
141
|
+
"""One action on up to 100 users: "delete", "ban", "unban", "suspend", "unsuspend",
|
|
142
|
+
"revoke_sessions" or "verify_email". Returns ``{results: [{id, ok, error?}], succeeded, failed}``."""
|
|
143
|
+
body: Dict[str, Any] = {"action": action, "ids": list(ids)}
|
|
144
|
+
if reason is not None:
|
|
145
|
+
body["reason"] = reason
|
|
146
|
+
if until is not None or hours is not None:
|
|
147
|
+
body.update(_until(until, hours))
|
|
148
|
+
return dict(self._call("POST", "/backend/users/bulk", body))
|
|
149
|
+
|
|
150
|
+
def revoke_sessions(self, user_id: str) -> int:
|
|
151
|
+
"""Signs the user out everywhere. Returns how many sessions were revoked."""
|
|
152
|
+
return int(self._call("POST", f"/backend/users/{seg(user_id)}/revoke-sessions", {})["revoked"])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class _Sessions:
|
|
156
|
+
def __init__(self, call: Any) -> None:
|
|
157
|
+
self._call = call
|
|
158
|
+
|
|
159
|
+
def list(self, page: Optional[int] = None, page_size: Optional[int] = None) -> Page[Dict[str, Any]]:
|
|
160
|
+
d = self._call("GET", "/backend/sessions" + query({"page": page, "pageSize": page_size}))
|
|
161
|
+
return Page(list(d["items"]), d["total"], d["page"], d["pageSize"])
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TausiBackend:
|
|
165
|
+
"""Server-side client for one project, authenticated with its secret key.
|
|
166
|
+
|
|
167
|
+
tausi = TausiBackend("https://tausi.app", os.environ["TAUSI_SECRET_KEY"])
|
|
168
|
+
auth = tausi.authenticate_request(request)
|
|
169
|
+
if auth is None:
|
|
170
|
+
return 401
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(self, base_url: str, secret_key: str, *, timeout: float = 10.0, transport: Transport = urllib_transport) -> None:
|
|
174
|
+
if not secret_key or not secret_key.startswith("sk_"):
|
|
175
|
+
raise ValueError("TausiBackend needs a project secret key (sk_...).")
|
|
176
|
+
self._http = Http(base_url, transport=transport, timeout=timeout)
|
|
177
|
+
self._headers = {"authorization": f"Bearer {secret_key}"}
|
|
178
|
+
self.users = _Users(self._call)
|
|
179
|
+
self.sessions = _Sessions(self._call)
|
|
180
|
+
|
|
181
|
+
def _call(self, method: str, path: str, body: Any = None) -> Any:
|
|
182
|
+
return self._http.request(method, path, self._headers, body)[0]
|
|
183
|
+
|
|
184
|
+
def verify_session(self, session_token: Optional[str]) -> Optional[AuthResult]:
|
|
185
|
+
"""Checks a session token your frontend sent.
|
|
186
|
+
|
|
187
|
+
Returns None when it is unknown, expired, revoked, malformed or from
|
|
188
|
+
another project. Raises TausiError only for problems on your side
|
|
189
|
+
(bad key, network, 5xx).
|
|
190
|
+
"""
|
|
191
|
+
if not session_token:
|
|
192
|
+
return None
|
|
193
|
+
try:
|
|
194
|
+
r = self._call("POST", "/backend/sessions/verify", {"sessionToken": session_token})
|
|
195
|
+
except TausiError as e:
|
|
196
|
+
if e.status == 400: # malformed token
|
|
197
|
+
return None
|
|
198
|
+
raise
|
|
199
|
+
if not r or not r.get("valid") or not r.get("user") or not r.get("session"):
|
|
200
|
+
return None
|
|
201
|
+
return AuthResult(User.from_dict(r["user"]), Session.from_dict(r["session"]))
|
|
202
|
+
|
|
203
|
+
def authenticate_request(self, request: Any) -> Optional[AuthResult]:
|
|
204
|
+
"""Reads the bearer token from a request (or header value) and verifies it."""
|
|
205
|
+
return self.verify_session(bearer_token(request))
|
|
206
|
+
|
|
207
|
+
def get_project(self) -> Mapping[str, Any]:
|
|
208
|
+
"""The key's project ``{id, name, publishableKey}``: a startup check that the key works."""
|
|
209
|
+
return self._call("GET", "/backend/project")["project"]
|
tausi/client.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import urllib.parse
|
|
5
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from ._http import Http, Transport, seg, urllib_transport
|
|
8
|
+
from .errors import TausiError
|
|
9
|
+
from .models import AuthResult, OAuthStart, Session, User
|
|
10
|
+
from .pkce import create_pkce
|
|
11
|
+
from .token_store import MemoryTokenStore, TokenStore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_ATTR_KEYS = {
|
|
15
|
+
"first_name": "firstName",
|
|
16
|
+
"last_name": "lastName",
|
|
17
|
+
"username": "username",
|
|
18
|
+
"phone_number": "phoneNumber",
|
|
19
|
+
"locale": "locale",
|
|
20
|
+
"timezone": "timezone",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _attrs(keep_none: bool, **values: Any) -> Dict[str, Any]:
|
|
25
|
+
"""Profile attributes in API casing. ``...`` means "leave out"; None clears (when editing)."""
|
|
26
|
+
out: Dict[str, Any] = {}
|
|
27
|
+
for py, value in values.items():
|
|
28
|
+
if value is ... or (value is None and not keep_none):
|
|
29
|
+
continue
|
|
30
|
+
out[_ATTR_KEYS[py]] = value
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _captcha(token: Optional[str]) -> Dict[str, str]:
|
|
35
|
+
"""The bot-check token travels in a header (Tausi verifies it with Turnstile / hCaptcha)."""
|
|
36
|
+
return {"x-tausi-captcha": token} if token else {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def random_device_id() -> str:
|
|
40
|
+
"""A random per-install ID for X-Tausi-Device. Persist it yourself (one per install or browser)."""
|
|
41
|
+
return secrets.token_hex(16)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_oauth_redirect(url: str) -> Tuple[Optional[str], Optional[str]]:
|
|
45
|
+
"""``(code, error)`` from a redirect URL carrying ?tausi_code=… or ?tausi_error=…."""
|
|
46
|
+
qs = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
|
|
47
|
+
return (qs.get("tausi_code") or [None])[0], (qs.get("tausi_error") or [None])[0]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def oauth_error_message(code: str) -> str:
|
|
51
|
+
"""User-facing text for ?tausi_error codes."""
|
|
52
|
+
return {
|
|
53
|
+
"access_denied": "Sign-in was cancelled.",
|
|
54
|
+
"account_not_linked": "An account with this email already exists. Sign in with your password, or verify your email first.",
|
|
55
|
+
"email_not_verified": "Confirm your email address first. We've sent you a link.",
|
|
56
|
+
"email_not_found": "The provider didn't share your email address, which this app needs.",
|
|
57
|
+
"signup_limited": "Too many accounts have been created from this network or device.",
|
|
58
|
+
"account_banned": "This account has been banned.",
|
|
59
|
+
"account_suspended": "This account is suspended for now. Try again later.",
|
|
60
|
+
"device_required": "Sign up from the app.",
|
|
61
|
+
}.get(code, "Sign-in didn't complete. Please try again.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TausiClient:
|
|
65
|
+
"""End-user client (publishable key).
|
|
66
|
+
|
|
67
|
+
Use it for server-rendered apps where Python signs the user in and keeps
|
|
68
|
+
their token server-side (the Django or Flask session), and for scripts,
|
|
69
|
+
CLIs and desktop apps. Every method returns data or raises TausiError.
|
|
70
|
+
|
|
71
|
+
tausi = TausiClient("https://tausi.app", "pk_...")
|
|
72
|
+
result = tausi.sign_in("asha@example.com", "correct horse battery")
|
|
73
|
+
token = tausi.get_token()
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
base_url: str,
|
|
79
|
+
publishable_key: str,
|
|
80
|
+
*,
|
|
81
|
+
token_store: Optional[TokenStore] = None,
|
|
82
|
+
device_id: Optional[str] = None,
|
|
83
|
+
origin: Optional[str] = None,
|
|
84
|
+
timeout: float = 10.0,
|
|
85
|
+
transport: Transport = urllib_transport,
|
|
86
|
+
on_session_change: Optional[Callable[[Optional[str]], None]] = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
if publishable_key and publishable_key.startswith("sk_"):
|
|
89
|
+
raise ValueError("Never use a secret key in TausiClient. Use TausiBackend for secret keys.")
|
|
90
|
+
if not publishable_key or not publishable_key.startswith("pk_"):
|
|
91
|
+
raise ValueError("TausiClient needs a publishable key (pk_...).")
|
|
92
|
+
self._http = Http(base_url, transport=transport, timeout=timeout)
|
|
93
|
+
self._key = publishable_key
|
|
94
|
+
self._store: TokenStore = token_store if token_store is not None else MemoryTokenStore()
|
|
95
|
+
self._device_id = device_id
|
|
96
|
+
self._origin = origin
|
|
97
|
+
self._on_change = on_session_change
|
|
98
|
+
|
|
99
|
+
# ---- token ----
|
|
100
|
+
|
|
101
|
+
def get_token(self) -> Optional[str]:
|
|
102
|
+
"""The current session token: send it to your API as ``Authorization: Bearer``."""
|
|
103
|
+
return self._store.get()
|
|
104
|
+
|
|
105
|
+
def _set_token(self, token: Optional[str]) -> None:
|
|
106
|
+
self._store.set(token)
|
|
107
|
+
if self._on_change:
|
|
108
|
+
self._on_change(token)
|
|
109
|
+
|
|
110
|
+
def _call(self, method: str, path: str, body: Any = None, headers: Optional[Dict[str, str]] = None) -> Any:
|
|
111
|
+
extra = headers or {}
|
|
112
|
+
headers = {"x-tausi-key": self._key, **extra}
|
|
113
|
+
if self._device_id:
|
|
114
|
+
headers["x-tausi-device"] = self._device_id
|
|
115
|
+
if self._origin:
|
|
116
|
+
headers["origin"] = self._origin
|
|
117
|
+
token = self.get_token()
|
|
118
|
+
if token:
|
|
119
|
+
headers["authorization"] = f"Bearer {token}"
|
|
120
|
+
try:
|
|
121
|
+
data, res = self._http.request(method, path, headers, body)
|
|
122
|
+
except TausiError as e:
|
|
123
|
+
# A rejected token is useless: forget it so the app shows signed-out UI.
|
|
124
|
+
if e.status == 401 and e.code == "UNAUTHENTICATED" and token:
|
|
125
|
+
self._set_token(None)
|
|
126
|
+
raise
|
|
127
|
+
renewed = res.header("x-tausi-session-token")
|
|
128
|
+
if renewed and renewed != token:
|
|
129
|
+
self._set_token(renewed)
|
|
130
|
+
return data
|
|
131
|
+
|
|
132
|
+
def _signed_in(self, r: Mapping[str, Any]) -> AuthResult:
|
|
133
|
+
self._set_token(r["sessionToken"])
|
|
134
|
+
return AuthResult(User.from_dict(r["user"]), Session.from_dict(r["session"]))
|
|
135
|
+
|
|
136
|
+
# ---- email + password ----
|
|
137
|
+
|
|
138
|
+
def sign_up(
|
|
139
|
+
self,
|
|
140
|
+
email: str,
|
|
141
|
+
password: str,
|
|
142
|
+
name: str,
|
|
143
|
+
*,
|
|
144
|
+
first_name: Optional[str] = None,
|
|
145
|
+
last_name: Optional[str] = None,
|
|
146
|
+
username: Optional[str] = None,
|
|
147
|
+
phone_number: Optional[str] = None,
|
|
148
|
+
locale: Optional[str] = None,
|
|
149
|
+
timezone: Optional[str] = None,
|
|
150
|
+
redirect_to: Optional[str] = None,
|
|
151
|
+
captcha_token: Optional[str] = None,
|
|
152
|
+
) -> Dict[str, Any]:
|
|
153
|
+
"""Creates an account: ``{message, verificationRequired}``. No session is created.
|
|
154
|
+
|
|
155
|
+
``redirect_to`` is where the verification link sends the user (an allowed
|
|
156
|
+
origin or one of the project's Redirect URLs); default: the App URL.
|
|
157
|
+
|
|
158
|
+
Raises TausiError ``EMAIL_ALREADY_REGISTERED`` (409) when the email has
|
|
159
|
+
an account (its owner gets an email) and ``USERNAME_TAKEN`` for a taken username.
|
|
160
|
+
"""
|
|
161
|
+
body: Dict[str, Any] = {"email": email, "password": password, "name": name}
|
|
162
|
+
body.update(_attrs(first_name=first_name, last_name=last_name, username=username, phone_number=phone_number, locale=locale, timezone=timezone, keep_none=False))
|
|
163
|
+
if redirect_to:
|
|
164
|
+
body["redirectTo"] = redirect_to
|
|
165
|
+
return self._call("POST", "/auth/sign-up/email", body, headers=_captcha(captcha_token))
|
|
166
|
+
|
|
167
|
+
def sign_in(self, email: str, password: str, remember_me: Optional[bool] = None, captcha_token: Optional[str] = None) -> AuthResult:
|
|
168
|
+
body: Dict[str, Any] = {"email": email, "password": password}
|
|
169
|
+
if remember_me is not None:
|
|
170
|
+
body["rememberMe"] = remember_me
|
|
171
|
+
return self._signed_in(self._call("POST", "/auth/sign-in/email", body, headers=_captcha(captcha_token)))
|
|
172
|
+
|
|
173
|
+
def sign_out(self) -> None:
|
|
174
|
+
"""Ends the session on the server and forgets the token (even if the server is unreachable)."""
|
|
175
|
+
try:
|
|
176
|
+
if self.get_token():
|
|
177
|
+
self._call("POST", "/auth/sign-out", {})
|
|
178
|
+
finally:
|
|
179
|
+
self._set_token(None)
|
|
180
|
+
|
|
181
|
+
def get_session(self) -> Optional[AuthResult]:
|
|
182
|
+
"""The signed-in user and session, or None (no request is made without a token)."""
|
|
183
|
+
if not self.get_token():
|
|
184
|
+
return None
|
|
185
|
+
try:
|
|
186
|
+
r = self._call("GET", "/auth/session")
|
|
187
|
+
except TausiError as e:
|
|
188
|
+
if e.status == 401:
|
|
189
|
+
return None
|
|
190
|
+
raise
|
|
191
|
+
if not r or not r.get("user") or not r.get("session"):
|
|
192
|
+
return None
|
|
193
|
+
return AuthResult(User.from_dict(r["user"]), Session.from_dict(r["session"]))
|
|
194
|
+
|
|
195
|
+
def send_verification_email(self, email: str, redirect_to: Optional[str] = None) -> Dict[str, Any]:
|
|
196
|
+
return self._call("POST", "/auth/send-verification-email", {"email": email, **({"redirectTo": redirect_to} if redirect_to else {})})
|
|
197
|
+
|
|
198
|
+
def verify_email(self, token: str) -> Dict[str, Any]:
|
|
199
|
+
return self._call("POST", "/auth/verify-email", {"token": token})
|
|
200
|
+
|
|
201
|
+
def forgot_password(self, email: str, redirect_to: Optional[str] = None, captcha_token: Optional[str] = None) -> Dict[str, Any]:
|
|
202
|
+
"""``redirect_to``: the page (or deep link) the reset email opens, with ?token=…&type=password_reset."""
|
|
203
|
+
return self._call("POST", "/auth/forgot-password", {"email": email, **({"redirectTo": redirect_to} if redirect_to else {})}, headers=_captcha(captcha_token))
|
|
204
|
+
|
|
205
|
+
def get_captcha_config(self) -> Optional[Dict[str, Any]]:
|
|
206
|
+
"""``{provider, siteKey, actions}`` when the project has a bot check, else None.
|
|
207
|
+
Render the provider's widget and pass its token as ``captcha_token``."""
|
|
208
|
+
return self._call("GET", "/auth/providers").get("captcha")
|
|
209
|
+
|
|
210
|
+
def reset_password(self, token: str, new_password: str) -> Dict[str, Any]:
|
|
211
|
+
return self._call("POST", "/auth/reset-password", {"token": token, "newPassword": new_password})
|
|
212
|
+
|
|
213
|
+
def change_password(self, current_password: str, new_password: str) -> None:
|
|
214
|
+
"""Every other session is signed out and this one gets a fresh token."""
|
|
215
|
+
r = self._call("POST", "/auth/change-password", {"currentPassword": current_password, "newPassword": new_password})
|
|
216
|
+
if r and r.get("sessionToken"):
|
|
217
|
+
self._set_token(r["sessionToken"])
|
|
218
|
+
|
|
219
|
+
# ---- profile ----
|
|
220
|
+
|
|
221
|
+
def get_user(self) -> User:
|
|
222
|
+
return User.from_dict(self._call("GET", "/users/me")["user"])
|
|
223
|
+
|
|
224
|
+
def update_user(
|
|
225
|
+
self,
|
|
226
|
+
name: Optional[str] = None,
|
|
227
|
+
image: Any = ...,
|
|
228
|
+
*,
|
|
229
|
+
first_name: Any = ...,
|
|
230
|
+
last_name: Any = ...,
|
|
231
|
+
username: Any = ...,
|
|
232
|
+
phone_number: Any = ...,
|
|
233
|
+
locale: Any = ...,
|
|
234
|
+
timezone: Any = ...,
|
|
235
|
+
) -> User:
|
|
236
|
+
"""Edits the signed-in user's profile. Leave an argument out to keep it; pass None to clear it.
|
|
237
|
+
|
|
238
|
+
A new ``phone_number`` is unverified. Raises ``USERNAME_TAKEN`` (409) for a taken username.
|
|
239
|
+
"""
|
|
240
|
+
patch: Dict[str, Any] = {}
|
|
241
|
+
if name is not None:
|
|
242
|
+
patch["name"] = name
|
|
243
|
+
if image is not ...:
|
|
244
|
+
patch["image"] = image
|
|
245
|
+
patch.update(_attrs(first_name=first_name, last_name=last_name, username=username, phone_number=phone_number, locale=locale, timezone=timezone, keep_none=True))
|
|
246
|
+
return User.from_dict(self._call("PATCH", "/users/me", patch)["user"])
|
|
247
|
+
|
|
248
|
+
def delete_account(self, password: str) -> None:
|
|
249
|
+
self._call("DELETE", "/users/me", {"password": password})
|
|
250
|
+
self._set_token(None)
|
|
251
|
+
|
|
252
|
+
# ---- sessions ----
|
|
253
|
+
|
|
254
|
+
def list_sessions(self) -> List[Dict[str, Any]]:
|
|
255
|
+
return list(self._call("GET", "/auth/sessions")["sessions"])
|
|
256
|
+
|
|
257
|
+
def revoke_session(self, session_id: str) -> None:
|
|
258
|
+
self._call("DELETE", f"/auth/sessions/{seg(session_id)}")
|
|
259
|
+
|
|
260
|
+
def revoke_other_sessions(self) -> int:
|
|
261
|
+
"""Signs out every other device. Returns how many sessions were revoked."""
|
|
262
|
+
return int(self._call("DELETE", "/auth/sessions")["revoked"])
|
|
263
|
+
|
|
264
|
+
# ---- social sign-in ----
|
|
265
|
+
|
|
266
|
+
def get_providers(self) -> List[Dict[str, Any]]:
|
|
267
|
+
"""Sign-in methods the project enabled: ``[{id, kind, displayName}, ...]``."""
|
|
268
|
+
return list(self._call("GET", "/auth/providers")["providers"])
|
|
269
|
+
|
|
270
|
+
def start_oauth(self, provider: str, redirect_to: str) -> OAuthStart:
|
|
271
|
+
"""Starts a social sign-in with PKCE.
|
|
272
|
+
|
|
273
|
+
Redirect the browser to ``url`` and keep ``code_verifier``. The user
|
|
274
|
+
comes back to ``redirect_to`` (on one of the project's allowed
|
|
275
|
+
origins) with ?tausi_code=… or ?tausi_error=…; then call
|
|
276
|
+
:meth:`handle_oauth_redirect`.
|
|
277
|
+
"""
|
|
278
|
+
verifier, challenge = create_pkce()
|
|
279
|
+
qs: Dict[str, str] = {
|
|
280
|
+
"key": self._key,
|
|
281
|
+
"redirect_to": redirect_to,
|
|
282
|
+
"code_challenge": challenge,
|
|
283
|
+
"code_challenge_method": "S256",
|
|
284
|
+
}
|
|
285
|
+
if self._device_id:
|
|
286
|
+
qs["device"] = self._device_id
|
|
287
|
+
return OAuthStart(f"{self._http.base}/auth/oauth/{seg(provider)}?{urllib.parse.urlencode(qs)}", verifier)
|
|
288
|
+
|
|
289
|
+
def exchange_oauth_code(self, code: str, code_verifier: str) -> AuthResult:
|
|
290
|
+
"""Trades the one-time code for a session (stored like sign_in)."""
|
|
291
|
+
return self._signed_in(self._call("POST", "/auth/oauth/exchange", {"code": code, "codeVerifier": code_verifier}))
|
|
292
|
+
|
|
293
|
+
def handle_oauth_redirect(self, url: str, code_verifier: str) -> Optional[AuthResult]:
|
|
294
|
+
"""Exchanges the code in ``url``, raises the provider error as TausiError (status 0), or returns None."""
|
|
295
|
+
code, error = parse_oauth_redirect(url)
|
|
296
|
+
if error:
|
|
297
|
+
raise TausiError(0, error, oauth_error_message(error))
|
|
298
|
+
return self.exchange_oauth_code(code, code_verifier) if code else None
|