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 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