backd 0.0.1__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.
- backd/__init__.py +284 -0
- backd/_aio.py +29 -0
- backd/_async/__init__.py +1 -0
- backd/_async/account.py +153 -0
- backd/_async/auth.py +791 -0
- backd/_async/client.py +489 -0
- backd/_async/config.py +365 -0
- backd/_async/consents.py +48 -0
- backd/_async/data.py +500 -0
- backd/_async/files.py +448 -0
- backd/_async/functions.py +159 -0
- backd/_async/orgs.py +418 -0
- backd/_async/push.py +101 -0
- backd/_async/realtime.py +268 -0
- backd/_core.py +99 -0
- backd/_live.py +777 -0
- backd/_retry.py +23 -0
- backd/_sio.py +23 -0
- backd/_sync/__init__.py +1 -0
- backd/_sync/account.py +154 -0
- backd/_sync/auth.py +792 -0
- backd/_sync/client.py +490 -0
- backd/_sync/config.py +366 -0
- backd/_sync/consents.py +49 -0
- backd/_sync/data.py +501 -0
- backd/_sync/files.py +449 -0
- backd/_sync/functions.py +160 -0
- backd/_sync/orgs.py +419 -0
- backd/_sync/push.py +102 -0
- backd/_sync/realtime.py +316 -0
- backd/cache.py +137 -0
- backd/config_storage.py +90 -0
- backd/errors.py +388 -0
- backd/files.py +37 -0
- backd/models.py +716 -0
- backd/oauth.py +163 -0
- backd/py.typed +0 -0
- backd/query.py +303 -0
- backd/size.py +87 -0
- backd/storage.py +97 -0
- backd/upload.py +136 -0
- backd-0.0.1.dist-info/METADATA +58 -0
- backd-0.0.1.dist-info/RECORD +45 -0
- backd-0.0.1.dist-info/WHEEL +4 -0
- backd-0.0.1.dist-info/licenses/LICENSE +21 -0
backd/_async/auth.py
ADDED
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
"""Sign-up, login and session management: ``client.auth``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeVar, cast
|
|
10
|
+
|
|
11
|
+
from .._core import parse_time
|
|
12
|
+
from ..errors import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
SecondStepRequiredError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
VerificationRequiredError,
|
|
17
|
+
)
|
|
18
|
+
from ..models import (
|
|
19
|
+
ConsentChallenge,
|
|
20
|
+
EnrollmentChallenge,
|
|
21
|
+
Me,
|
|
22
|
+
MfaChallenge,
|
|
23
|
+
ProviderSession,
|
|
24
|
+
ReauthMethod,
|
|
25
|
+
Session,
|
|
26
|
+
SessionInfo,
|
|
27
|
+
SignInResult,
|
|
28
|
+
User,
|
|
29
|
+
)
|
|
30
|
+
from ..oauth import Pending, load_pending, new_pkce, oauth_error, parse_return, save_pending
|
|
31
|
+
from ..storage import SlotStorage
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from .client import AsyncClient
|
|
35
|
+
|
|
36
|
+
__all__ = ["AsyncAuth", "AsyncMagicLink", "AsyncMfa", "AuthEvent", "ReauthInput"]
|
|
37
|
+
|
|
38
|
+
T = TypeVar("T")
|
|
39
|
+
|
|
40
|
+
#: What changed: a sign-in (signup or login), a sign-out (logout, logout everywhere, account deleted), or the
|
|
41
|
+
#: server refusing the stored token (expired, revoked, or the user disabled).
|
|
42
|
+
AuthEvent = Literal["SIGNED_IN", "SIGNED_OUT", "SESSION_EXPIRED"]
|
|
43
|
+
AuthListener = Callable[[AuthEvent, Session | None], None]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _ReauthRequired(TypedDict):
|
|
47
|
+
method: ReauthMethod
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ReauthInput(_ReauthRequired, total=False):
|
|
51
|
+
"""A proof of identity: ``totp`` or ``recovery_code`` with a ``code``, ``password`` with a ``password``,
|
|
52
|
+
or ``email_code`` with the ``code`` that ``request_reauth_code`` sent."""
|
|
53
|
+
|
|
54
|
+
code: str
|
|
55
|
+
password: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _compact(**fields: Any) -> dict[str, Any]:
|
|
59
|
+
"""A body without the fields that were not given."""
|
|
60
|
+
return {k: v for k, v in fields.items() if v is not None}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _auth_base(org: str | None) -> list[str]:
|
|
64
|
+
"""The path of the sign-in routes: the realm's, or those of one organization."""
|
|
65
|
+
return ["_auth", "orgs", org] if org else ["_auth"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AsyncMfa:
|
|
69
|
+
"""The signed-in user's second factor (``client.auth.mfa``). Changes need a fresh proof: see ``auth.reauth``."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, auth: AsyncAuth) -> None:
|
|
72
|
+
self._auth = auth
|
|
73
|
+
|
|
74
|
+
async def status(self) -> dict[str, Any]:
|
|
75
|
+
"""``available``, ``enabled``, ``methods``, ``recovery_codes_left`` and ``required``."""
|
|
76
|
+
return cast(dict[str, Any], (await self._auth._client.request("GET", ["_auth", "mfa"])).data)
|
|
77
|
+
|
|
78
|
+
async def setup(self, *, challenge: str | None = None) -> dict[str, Any]:
|
|
79
|
+
"""Starts setting an authenticator app up: ``setup_id``, ``secret``, ``otpauth_uri``, ``qr`` (a PNG data
|
|
80
|
+
URI) and ``expires_at``. With the ``challenge`` of an :class:`EnrollmentChallenge` there is no session yet."""
|
|
81
|
+
res = await self._auth._client.request(
|
|
82
|
+
"POST",
|
|
83
|
+
["_auth", "mfa", "totp", "setup"],
|
|
84
|
+
body={"challenge": challenge} if challenge else None,
|
|
85
|
+
reauth=not challenge,
|
|
86
|
+
auth=not challenge,
|
|
87
|
+
)
|
|
88
|
+
return cast(dict[str, Any], res.data)
|
|
89
|
+
|
|
90
|
+
async def confirm(self, *, setup_id: str, code: str, challenge: str | None = None) -> dict[str, Any]:
|
|
91
|
+
"""Turns it on with the first code of the app. Returns the ``recovery_codes``, shown once; with a
|
|
92
|
+
``challenge`` it also ends the sign-in and the client holds the session (``session`` is it), unless the
|
|
93
|
+
user has consents to accept first: then ``consent`` is the :class:`ConsentChallenge` to finish with
|
|
94
|
+
``auth.accept_consents``."""
|
|
95
|
+
body: dict[str, Any] = {"setup_id": setup_id, "code": code}
|
|
96
|
+
if challenge:
|
|
97
|
+
body["challenge"] = challenge
|
|
98
|
+
res = await self._auth._client.request(
|
|
99
|
+
"POST", ["_auth", "mfa", "totp", "confirm"], body=body, reauth=not challenge, auth=not challenge
|
|
100
|
+
)
|
|
101
|
+
data = cast(dict[str, Any], res.data)
|
|
102
|
+
if data.get("consent_required"):
|
|
103
|
+
consent = ConsentChallenge(data["challenge"], data.get("consents", []))
|
|
104
|
+
return {"recovery_codes": data["recovery_codes"], "consent": consent}
|
|
105
|
+
if data.get("token") or data.get("session_id"):
|
|
106
|
+
codes = data["recovery_codes"]
|
|
107
|
+
session = {k: v for k, v in data.items() if k != "recovery_codes"}
|
|
108
|
+
return {"recovery_codes": codes, "session": await self._auth._signed_in(cast(Session, session))}
|
|
109
|
+
return {"recovery_codes": data["recovery_codes"]}
|
|
110
|
+
|
|
111
|
+
async def disable(self) -> None:
|
|
112
|
+
"""Turns it off (not for a role that requires it: ``mfa_required_by_role``)."""
|
|
113
|
+
await self._auth._client.request("DELETE", ["_auth", "mfa", "totp"], reauth=True)
|
|
114
|
+
|
|
115
|
+
async def regenerate_recovery_codes(self) -> list[str]:
|
|
116
|
+
"""Replaces the recovery codes; the new ones are shown once."""
|
|
117
|
+
res = await self._auth._client.request("POST", ["_auth", "mfa", "recovery-codes"], reauth=True)
|
|
118
|
+
return cast(list[str], cast(dict[str, Any], res.data)["recovery_codes"])
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class AsyncMagicLink:
|
|
122
|
+
"""Sign-in by a link or a code emailed to the user (``client.auth.magic_link``)."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, auth: AsyncAuth) -> None:
|
|
125
|
+
self._auth = auth
|
|
126
|
+
|
|
127
|
+
async def request(
|
|
128
|
+
self,
|
|
129
|
+
email: str,
|
|
130
|
+
*,
|
|
131
|
+
redirect_to: str | None = None,
|
|
132
|
+
locale: str | None = None,
|
|
133
|
+
org: str | None = None,
|
|
134
|
+
consents: Mapping[str, str] | None = None,
|
|
135
|
+
) -> str:
|
|
136
|
+
"""Asks for a message with a link and a 6-digit code. Always succeeds, whatever the address is. The
|
|
137
|
+
link ends at ``redirect_to`` (``complete_sign_in`` finishes it); the code is typed in this program with
|
|
138
|
+
``verify_code``. The PKCE verifier waits in ``oauth_storage`` for both. Returns the request id.
|
|
139
|
+
|
|
140
|
+
``consents`` are accepted for the account a new address gets. ``org`` is the organization the person
|
|
141
|
+
signs in to (an id or a slug; the client's own when it was made with one): only its members are sent a
|
|
142
|
+
link, and the session it gives is scoped to it."""
|
|
143
|
+
client = self._auth._client
|
|
144
|
+
verifier, challenge = new_pkce()
|
|
145
|
+
body = _compact(
|
|
146
|
+
email=email, redirect_to=redirect_to, code_challenge=challenge, locale=locale, consents=consents
|
|
147
|
+
)
|
|
148
|
+
res = await client.request("POST", [*self._auth.base(org), "magic-link"], body=body, auth=False)
|
|
149
|
+
request_id = cast(dict[str, Any], res.data)["request_id"]
|
|
150
|
+
save_pending(client.oauth_storage, Pending(verifier, "magic-link", "signin", request_id))
|
|
151
|
+
return cast(str, request_id)
|
|
152
|
+
|
|
153
|
+
async def verify_code(self, code: str) -> SignInResult:
|
|
154
|
+
"""Signs in with the code of the message, in the program that asked. The answer is the session or a
|
|
155
|
+
second step, like ``sign_in``'s. A wrong code is ``invalid_code`` (five end the request); a request that
|
|
156
|
+
is unknown, used or expired is ``invalid_magic_link``."""
|
|
157
|
+
client = self._auth._client
|
|
158
|
+
pending = load_pending(client.oauth_storage)
|
|
159
|
+
if pending is None or not pending.request_id:
|
|
160
|
+
raise ValidationError("ask for a link first, in this client: request(email)", status=400, code="no_request")
|
|
161
|
+
body = {"request_id": pending.request_id, "code": code.strip(), "code_verifier": pending.verifier}
|
|
162
|
+
res = await client.request("POST", ["_auth", "magic-link", "verify"], body=body, auth=False)
|
|
163
|
+
client.oauth_storage.remove()
|
|
164
|
+
return await self._auth._step(res.data)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _Password:
|
|
168
|
+
"""Setting and removing the password of an account that may sign in another way (``auth.password``)."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, auth: AsyncAuth) -> None:
|
|
171
|
+
self._auth = auth
|
|
172
|
+
|
|
173
|
+
async def set(self, new_password: str) -> None:
|
|
174
|
+
await self._auth.change_password(new_password)
|
|
175
|
+
|
|
176
|
+
async def remove(self) -> None:
|
|
177
|
+
await self._auth._client.request("DELETE", ["_auth", "password"], reauth=True)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class AsyncAuth:
|
|
181
|
+
"""Sign-up, login and session management: ``client.auth``."""
|
|
182
|
+
|
|
183
|
+
def __init__(self, client: AsyncClient) -> None:
|
|
184
|
+
self._client = client
|
|
185
|
+
self._listeners: list[AuthListener] = []
|
|
186
|
+
#: The session this client signed in with, as the sign-in answered (``org`` is the organization it is
|
|
187
|
+
#: scoped to), or ``None``. It is kept in memory only: a program that has just started knows nothing
|
|
188
|
+
#: until it asks the server (:meth:`me`, :meth:`org`).
|
|
189
|
+
self.current: Session | None = None
|
|
190
|
+
#: The signed-in user's second factor: ``client.auth.mfa``.
|
|
191
|
+
self.mfa = AsyncMfa(self)
|
|
192
|
+
#: Sign-in by an emailed link and code: ``client.auth.magic_link``.
|
|
193
|
+
self.magic_link = AsyncMagicLink(self)
|
|
194
|
+
#: Setting and removing the password.
|
|
195
|
+
self.password = _Password(self)
|
|
196
|
+
|
|
197
|
+
def base(self, org: str | None = None) -> list[str]:
|
|
198
|
+
"""The path of the sign-in routes for an organization: the one given, else the client's, else the realm's."""
|
|
199
|
+
return _auth_base((org or "").strip() or self._client.org_ref)
|
|
200
|
+
|
|
201
|
+
# -- sign-up and sign-in ---------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
async def signup(
|
|
204
|
+
self,
|
|
205
|
+
email: str,
|
|
206
|
+
password: str,
|
|
207
|
+
*,
|
|
208
|
+
invitation: str | None = None,
|
|
209
|
+
locale: str | None = None,
|
|
210
|
+
redirect_to: str | None = None,
|
|
211
|
+
org: str | None = None,
|
|
212
|
+
consents: Mapping[str, str] | None = None,
|
|
213
|
+
) -> Session:
|
|
214
|
+
"""Creates an account and signs in. Realms with ``signup: invite`` need an ``invitation`` token.
|
|
215
|
+
``locale`` (such as ``es`` or ``es-MX``) is the language to use for the user; the server maps it silently
|
|
216
|
+
to one the realm lists. ``redirect_to`` is where the page after the verification link may send the user
|
|
217
|
+
(it must be within the realm's ``email.allowed_redirects``).
|
|
218
|
+
|
|
219
|
+
A realm that requires verified addresses creates the account but starts no session: this raises
|
|
220
|
+
:class:`VerificationRequiredError`, nothing is stored, and the user signs in after following the link in
|
|
221
|
+
the email they were sent. A user who has a second step to take raises :class:`SecondStepRequiredError`
|
|
222
|
+
carrying the challenge. ``consents`` are the consents accepted (``{"terms": "2026-10"}``): every required
|
|
223
|
+
one of the realm must be there at its current version, or this raises :class:`ConsentRequiredError`.
|
|
224
|
+
``org`` is the organization the person joins: the sign-up goes to ``/_auth/orgs/{org}/signup``, which
|
|
225
|
+
needs the organization's ``invitation``, and the session is scoped to it."""
|
|
226
|
+
body = _compact(
|
|
227
|
+
email=email,
|
|
228
|
+
password=password,
|
|
229
|
+
consents=consents,
|
|
230
|
+
invitation=invitation,
|
|
231
|
+
locale=locale,
|
|
232
|
+
redirect_to=redirect_to,
|
|
233
|
+
)
|
|
234
|
+
res = await self._client.request("POST", [*self.base(org), "signup"], body=body, auth=False)
|
|
235
|
+
if res.status == 202:
|
|
236
|
+
raise VerificationRequiredError(
|
|
237
|
+
"the account was created: verify the email address, then sign in",
|
|
238
|
+
status=202,
|
|
239
|
+
code="verification_required",
|
|
240
|
+
)
|
|
241
|
+
return self._session(await self._step(res.data))
|
|
242
|
+
|
|
243
|
+
async def sign_up_with_org(
|
|
244
|
+
self,
|
|
245
|
+
*,
|
|
246
|
+
email: str,
|
|
247
|
+
password: str,
|
|
248
|
+
name: str,
|
|
249
|
+
slug: str,
|
|
250
|
+
invitation: str | None = None,
|
|
251
|
+
locale: str | None = None,
|
|
252
|
+
redirect_to: str | None = None,
|
|
253
|
+
consents: Mapping[str, str] | None = None,
|
|
254
|
+
) -> dict[str, Any]:
|
|
255
|
+
"""Creates an account and an organization for a new person and signs in to it: how a company starts.
|
|
256
|
+
The realm's sign-up must be open; ``slug`` is the name people will type to sign in (``acme-corp``) and is
|
|
257
|
+
unique (``slug_taken``, a ``ConflictError``, makes nothing). The session is scoped to the new
|
|
258
|
+
organization and stored by the client; the answer holds it with the ``organization``. A realm that
|
|
259
|
+
verifies addresses first creates both and raises :class:`VerificationRequiredError`."""
|
|
260
|
+
body = _compact(
|
|
261
|
+
email=email,
|
|
262
|
+
password=password,
|
|
263
|
+
name=name,
|
|
264
|
+
slug=slug,
|
|
265
|
+
invitation=invitation,
|
|
266
|
+
locale=locale,
|
|
267
|
+
redirect_to=redirect_to,
|
|
268
|
+
consents=consents,
|
|
269
|
+
)
|
|
270
|
+
res = await self._client.request("POST", ["_auth", "orgs"], body=body, auth=False)
|
|
271
|
+
if res.status == 202:
|
|
272
|
+
raise VerificationRequiredError(
|
|
273
|
+
"the account and the organization were created: verify the email address, then sign in",
|
|
274
|
+
status=202,
|
|
275
|
+
code="verification_required",
|
|
276
|
+
)
|
|
277
|
+
data = dict(cast(dict[str, Any], res.data))
|
|
278
|
+
organization = data.pop("organization", None)
|
|
279
|
+
# The session is the new organization's: kept under its own client (named as the person will type it:
|
|
280
|
+
# the slug) when the storage can, else in this one.
|
|
281
|
+
target = self._client.org(slug).auth if isinstance(self._client.root.storage, SlotStorage) else self
|
|
282
|
+
session = target._session(await target._step(data))
|
|
283
|
+
return {**session, "organization": organization}
|
|
284
|
+
|
|
285
|
+
async def find_orgs(self, email: str, *, locale: str | None = None) -> None:
|
|
286
|
+
"""Asks the server to email an address the organizations it belongs to, each with the link to its
|
|
287
|
+
sign-in page: for someone on a new device who does not remember the company. It always succeeds,
|
|
288
|
+
whatever the address is; the realm needs ``organizations.signin_link``."""
|
|
289
|
+
await self._accepted(["_auth", "orgs", "find"], _compact(email=email, locale=locale))
|
|
290
|
+
|
|
291
|
+
async def preview_invitation(self, token: str, *, org: str | None = None) -> dict[str, Any]:
|
|
292
|
+
"""What an invitation to an organization offers, before the person has signed in: the ``org`` (id and
|
|
293
|
+
name), the ``roles`` and the ``teams``, so the app can ask "Join Acme as editor?". Every token that does
|
|
294
|
+
not work fails alike with ``invalid_invitation``."""
|
|
295
|
+
base = self.base(org)
|
|
296
|
+
if len(base) < 3:
|
|
297
|
+
raise TypeError("preview_invitation: the organization is required (client `org` or the `org` argument)")
|
|
298
|
+
res = await self._client.request("POST", [*base, "invitations", "preview"], body={"token": token}, auth=False)
|
|
299
|
+
return cast(dict[str, Any], res.data)
|
|
300
|
+
|
|
301
|
+
async def org(self) -> dict[str, Any] | None:
|
|
302
|
+
"""The organization this session is scoped to, and what the person is there (``id``, ``name``, ``slug``,
|
|
303
|
+
``roles``, ``teams``); ``None`` for a session that is not scoped. It asks the server, so it is right
|
|
304
|
+
after a restart too."""
|
|
305
|
+
org = (await self.me()).get("org")
|
|
306
|
+
if org and org.get("name") and self._client.org_ref:
|
|
307
|
+
await self._remember({"name": org["name"], "org": org["id"]})
|
|
308
|
+
return org
|
|
309
|
+
|
|
310
|
+
async def _step(self, data: Any) -> SignInResult:
|
|
311
|
+
"""What a first factor answered: the session (stored), or the second step the user has to take."""
|
|
312
|
+
d = data or {}
|
|
313
|
+
if d.get("mfa_required"):
|
|
314
|
+
return MfaChallenge(d["challenge"], d.get("methods") or [])
|
|
315
|
+
if d.get("mfa_enrollment_required"):
|
|
316
|
+
return EnrollmentChallenge(d["challenge"])
|
|
317
|
+
if d.get("consent_required"):
|
|
318
|
+
return ConsentChallenge(d["challenge"], d.get("consents") or [])
|
|
319
|
+
return await self._signed_in(d)
|
|
320
|
+
|
|
321
|
+
def _session(self, result: SignInResult) -> Session:
|
|
322
|
+
"""Turns a step into the session a method promises, or raises :class:`SecondStepRequiredError`."""
|
|
323
|
+
if isinstance(result, ConsentChallenge):
|
|
324
|
+
raise SecondStepRequiredError(
|
|
325
|
+
"consents must be accepted: show client.consents.text(name, version) and call "
|
|
326
|
+
"auth.accept_consents(challenge, consents)",
|
|
327
|
+
status=200,
|
|
328
|
+
code="consent_required",
|
|
329
|
+
challenge=result.challenge,
|
|
330
|
+
consents=cast("list[dict[str, str]]", result.consents),
|
|
331
|
+
)
|
|
332
|
+
if isinstance(result, EnrollmentChallenge):
|
|
333
|
+
raise SecondStepRequiredError(
|
|
334
|
+
"this account must set up a second factor: auth.mfa.setup(challenge=…)",
|
|
335
|
+
status=200,
|
|
336
|
+
code="mfa_enrollment_required",
|
|
337
|
+
challenge=result.challenge,
|
|
338
|
+
)
|
|
339
|
+
if isinstance(result, MfaChallenge):
|
|
340
|
+
raise SecondStepRequiredError(
|
|
341
|
+
"a code of the second factor is needed: auth.verify_mfa(challenge, method, code)",
|
|
342
|
+
status=200,
|
|
343
|
+
code="mfa_required",
|
|
344
|
+
challenge=result.challenge,
|
|
345
|
+
methods=result.methods,
|
|
346
|
+
)
|
|
347
|
+
return result
|
|
348
|
+
|
|
349
|
+
async def verify_mfa(self, challenge: str, method: Literal["totp", "recovery_code"], code: str) -> ProviderSession:
|
|
350
|
+
"""Finishes a sign-in that answered with an :class:`MfaChallenge`: the code of the user's authenticator
|
|
351
|
+
app (``totp``) or a recovery code (``recovery_code``). Returns the session (a provider's also has
|
|
352
|
+
``new_user``, a recovery code ``recovery_codes_left``). Raises ``invalid_code`` (a ``ValidationError``;
|
|
353
|
+
``details`` has the attempts left), ``invalid_challenge`` after five wrong ones or five minutes, and
|
|
354
|
+
``mfa_locked`` (a ``RetryableError``, 429) after ten wrong codes in an hour."""
|
|
355
|
+
res = await self._client.request(
|
|
356
|
+
"POST",
|
|
357
|
+
["_auth", "mfa", "verify"],
|
|
358
|
+
body={"challenge": challenge, "method": method, "code": code},
|
|
359
|
+
auth=False,
|
|
360
|
+
)
|
|
361
|
+
return cast(ProviderSession, await self._signed_in(res.data))
|
|
362
|
+
|
|
363
|
+
async def accept_consents(self, challenge: str, consents: Mapping[str, str]) -> ProviderSession:
|
|
364
|
+
"""Finishes a sign-in that answered with a :class:`ConsentChallenge`: the consents the user accepted, as
|
|
365
|
+
``{name: version}``, with every one the challenge named at its version. Returns the session. Raises
|
|
366
|
+
:class:`ConsentRequiredError` when one is missing (the challenge stays valid) and ``invalid_challenge``
|
|
367
|
+
after five minutes."""
|
|
368
|
+
res = await self._client.request(
|
|
369
|
+
"POST", ["_auth", "consents", "accept"], body={"challenge": challenge, "consents": consents}, auth=False
|
|
370
|
+
)
|
|
371
|
+
return cast(ProviderSession, await self._signed_in(res.data))
|
|
372
|
+
|
|
373
|
+
async def with_proof(
|
|
374
|
+
self,
|
|
375
|
+
ask: Callable[[list[str]], Awaitable[ReauthInput]],
|
|
376
|
+
action: Callable[[], Awaitable[T]],
|
|
377
|
+
) -> T:
|
|
378
|
+
"""Runs a sensitive action, asking the user for a fresh proof when the server wants one: ``ask`` gets the
|
|
379
|
+
proofs the user can give (``totp``, ``recovery_code``, ``password`` or ``email_code``) and returns the one
|
|
380
|
+
to use, as :meth:`reauth` takes it; the action is then tried once more."""
|
|
381
|
+
try:
|
|
382
|
+
return await action()
|
|
383
|
+
except AuthenticationError as e:
|
|
384
|
+
if e.code != "reauth_required":
|
|
385
|
+
raise
|
|
386
|
+
me = await self.me()
|
|
387
|
+
await self.reauth(await ask(list(me.get("reauth_methods", []))))
|
|
388
|
+
return await action()
|
|
389
|
+
|
|
390
|
+
async def sign_in(
|
|
391
|
+
self, email: str, password: str, *, org: str | None = None, invitation: str | None = None
|
|
392
|
+
) -> SignInResult:
|
|
393
|
+
"""Signs in with email and password. The answer is the session, or, for a user with a second factor, a
|
|
394
|
+
:class:`MfaChallenge` to finish with :meth:`verify_mfa`, or an :class:`EnrollmentChallenge`: check its
|
|
395
|
+
type. Nothing is stored until a session. ``org`` is the organization the person signs in to (an id or a
|
|
396
|
+
slug; the client's own when it was made with one): the session, or the second step, is scoped to it. A
|
|
397
|
+
person who is not a member, an organization that is not there and a wrong password all fail alike, with
|
|
398
|
+
``invalid_credentials``. A right password that an administrator expired raises
|
|
399
|
+
:class:`PasswordExpiredError`."""
|
|
400
|
+
body = _compact(email=email, password=password, invitation=invitation)
|
|
401
|
+
res = await self._client.request("POST", [*self.base(org), "login"], body=body, auth=False)
|
|
402
|
+
return await self._step(res.data)
|
|
403
|
+
|
|
404
|
+
async def login(
|
|
405
|
+
self, email: str, password: str, *, org: str | None = None, invitation: str | None = None
|
|
406
|
+
) -> Session:
|
|
407
|
+
"""Signs in with email and password and promises a session: a user who needs a second step makes this
|
|
408
|
+
raise :class:`SecondStepRequiredError` (use :meth:`sign_in` to get the step as a value)."""
|
|
409
|
+
return self._session(await self.sign_in(email, password, org=org, invitation=invitation))
|
|
410
|
+
|
|
411
|
+
# -- providers -------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
async def sign_in_with(
|
|
414
|
+
self,
|
|
415
|
+
provider: str,
|
|
416
|
+
*,
|
|
417
|
+
redirect_to: str | None = None,
|
|
418
|
+
invitation: str | None = None,
|
|
419
|
+
locale: str | None = None,
|
|
420
|
+
consents: Mapping[str, str] | None = None,
|
|
421
|
+
org: str | None = None,
|
|
422
|
+
navigate: Callable[[str], Awaitable[None]] | None = None,
|
|
423
|
+
) -> str:
|
|
424
|
+
"""Starts signing in with a provider the realm lists (``google``, ``microsoft`` or ``apple``): makes a
|
|
425
|
+
PKCE verifier, keeps it in ``oauth_storage`` for the return, and returns the provider's address, which
|
|
426
|
+
``navigate`` is given when it is (``webbrowser.open``, for a tool). The person comes back to
|
|
427
|
+
``redirect_to`` (it must be within the realm's ``sign_in.allowed_redirects``; the realm's
|
|
428
|
+
``sign_in.app_url`` when omitted), where :meth:`complete_sign_in` finishes the sign-in. ``org`` signs in
|
|
429
|
+
through the connection of that organization instead of the realm's own providers."""
|
|
430
|
+
return await self._start_oauth(provider, "signin", redirect_to, invitation, locale, consents, org, navigate)
|
|
431
|
+
|
|
432
|
+
async def link_provider(
|
|
433
|
+
self,
|
|
434
|
+
provider: str,
|
|
435
|
+
*,
|
|
436
|
+
redirect_to: str | None = None,
|
|
437
|
+
org: str | None = None,
|
|
438
|
+
navigate: Callable[[str], Awaitable[None]] | None = None,
|
|
439
|
+
) -> str:
|
|
440
|
+
"""Adds a provider to the signed-in user as another way to sign in. The same flow as
|
|
441
|
+
:meth:`sign_in_with`; :meth:`complete_sign_in` then returns ``{"linked": provider}``."""
|
|
442
|
+
return await self._start_oauth(provider, "link", redirect_to, None, None, None, org, navigate)
|
|
443
|
+
|
|
444
|
+
async def _start_oauth(
|
|
445
|
+
self,
|
|
446
|
+
provider: str,
|
|
447
|
+
intent: Literal["signin", "link"],
|
|
448
|
+
redirect_to: str | None,
|
|
449
|
+
invitation: str | None,
|
|
450
|
+
locale: str | None,
|
|
451
|
+
consents: Mapping[str, str] | None,
|
|
452
|
+
org: str | None,
|
|
453
|
+
navigate: Callable[[str], Awaitable[None]] | None,
|
|
454
|
+
) -> str:
|
|
455
|
+
client = self._client
|
|
456
|
+
verifier, challenge = new_pkce()
|
|
457
|
+
body = _compact(
|
|
458
|
+
redirect_to=redirect_to,
|
|
459
|
+
code_challenge=challenge,
|
|
460
|
+
intent=intent,
|
|
461
|
+
invitation=invitation,
|
|
462
|
+
locale=locale,
|
|
463
|
+
consents=consents,
|
|
464
|
+
)
|
|
465
|
+
base = _auth_base(org) if intent == "link" else self.base(org)
|
|
466
|
+
res = await client.request("POST", [*base, "oauth", provider, "start"], body=body, auth=intent == "link")
|
|
467
|
+
save_pending(client.oauth_storage, Pending(verifier, provider, intent))
|
|
468
|
+
url = cast(str, cast(dict[str, Any], res.data)["authorize_url"])
|
|
469
|
+
if navigate is not None:
|
|
470
|
+
await navigate(url)
|
|
471
|
+
return url
|
|
472
|
+
|
|
473
|
+
async def complete_sign_in(self, uri: str) -> SignInResult | dict[str, str] | None:
|
|
474
|
+
"""Finishes a sign-in with a provider (or a magic link) on the address backd sent the person back to,
|
|
475
|
+
``uri``. It reads the outcome from the address: a sign-in is traded for a session (stored by the client,
|
|
476
|
+
like a login's) and returned, with ``new_user`` and, for a new user, the ``profile`` the provider gave;
|
|
477
|
+
linking returns ``{"linked": provider}``; a failure raises the error the code means (``account_exists``
|
|
478
|
+
and ``link_conflict`` are a ``ConflictError``, ``signup_closed``, ``email_not_verified`` and
|
|
479
|
+
``signin_refused`` a ``ForbiddenError``, ``cancelled`` an ``AuthenticationError``, …; ``code`` is the
|
|
480
|
+
code). Returns ``None`` for an address that has nothing to finish.
|
|
481
|
+
|
|
482
|
+
An address that does not belong to a sign-in this client started (no PKCE verifier waits) raises a
|
|
483
|
+
``ValidationError`` whose ``code`` is ``wrong_device``."""
|
|
484
|
+
ret = parse_return(uri)
|
|
485
|
+
if ret is None:
|
|
486
|
+
return None
|
|
487
|
+
storage = self._client.oauth_storage
|
|
488
|
+
pending = load_pending(storage)
|
|
489
|
+
storage.remove()
|
|
490
|
+
if ret.error:
|
|
491
|
+
raise oauth_error(ret.error, ret.provider or (pending.provider if pending else None))
|
|
492
|
+
if ret.linked:
|
|
493
|
+
return {"linked": ret.linked}
|
|
494
|
+
if pending is None:
|
|
495
|
+
raise ValidationError(
|
|
496
|
+
"this address does not belong to a sign-in this client started: finish it where you started it",
|
|
497
|
+
status=400,
|
|
498
|
+
code="wrong_device",
|
|
499
|
+
)
|
|
500
|
+
res = await self._client.request(
|
|
501
|
+
"POST", ["_auth", "token"], body={"code": ret.code, "code_verifier": pending.verifier}, auth=False
|
|
502
|
+
)
|
|
503
|
+
# A sign-in to an organization from a client that is not bound to one belongs to that organization's client.
|
|
504
|
+
client = self._client
|
|
505
|
+
target = self
|
|
506
|
+
if ret.org and not client.org_ref and isinstance(client.root.storage, SlotStorage):
|
|
507
|
+
target = client.org(ret.org).auth
|
|
508
|
+
return await target._step(res.data)
|
|
509
|
+
|
|
510
|
+
async def unlink_provider(self, provider: str, *, org: str | None = None) -> None:
|
|
511
|
+
"""Removes one of the user's ways to sign in (``password`` included). The last one can't be removed: that
|
|
512
|
+
raises a ``ConflictError`` whose ``code`` is ``last_sign_in_method``. ``org`` selects the one that came
|
|
513
|
+
through an organization's connection (a user holds one per provider per organization)."""
|
|
514
|
+
await self._client.request(
|
|
515
|
+
"DELETE", ["_auth", "identities", provider], query={"org": org} if org else None, reauth=True
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
async def providers(self, *, org: str | None = None) -> list[str]:
|
|
519
|
+
"""The providers the app may offer right now, for the realm or, with ``org``, for one organization's
|
|
520
|
+
connections: names such as ``google`` and ``microsoft``, only those that are configured and usable."""
|
|
521
|
+
return cast(list[str], (await self.options(org=org))["providers"])
|
|
522
|
+
|
|
523
|
+
async def options(self, *, org: str | None = None) -> dict[str, Any]:
|
|
524
|
+
"""What an organization's sign-in page can offer: ``providers`` and the other ways in, ``methods``
|
|
525
|
+
(``password`` and, when the realm has them, ``magic_link``), and ``sso_required``: the organization's
|
|
526
|
+
people sign in through its connection only; then ``providers`` holds just its buttons and ``methods``
|
|
527
|
+
is empty. ``org`` defaults to the client's own; without either the realm's providers are listed."""
|
|
528
|
+
res = await self._client.request("GET", [*self.base(org), "providers"], auth=False)
|
|
529
|
+
d = cast(dict[str, Any], res.data)
|
|
530
|
+
return {
|
|
531
|
+
"providers": d.get("items") or [],
|
|
532
|
+
"methods": d.get("methods") or [],
|
|
533
|
+
"sso_required": d.get("sso_required") is True,
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async def sign_in_with_id_token(
|
|
537
|
+
self,
|
|
538
|
+
provider: str,
|
|
539
|
+
*,
|
|
540
|
+
id_token: str,
|
|
541
|
+
nonce: str,
|
|
542
|
+
authorization_code: str | None = None,
|
|
543
|
+
invitation: str | None = None,
|
|
544
|
+
locale: str | None = None,
|
|
545
|
+
consents: Mapping[str, str] | None = None,
|
|
546
|
+
org: str | None = None,
|
|
547
|
+
) -> SignInResult:
|
|
548
|
+
"""Signs in with the ID token a mobile app got from the platform sign-in (Sign in with Apple, Google
|
|
549
|
+
Sign-In, MSAL), with the raw ``nonce`` it started that sign-in with. For Apple, also the
|
|
550
|
+
``authorization_code`` its SDK gave. Fails with ``invalid_token`` (an ``AuthenticationError``) when the
|
|
551
|
+
token does not verify, and with ``account_exists``, ``signup_closed``, … as :meth:`complete_sign_in`."""
|
|
552
|
+
body = _compact(
|
|
553
|
+
id_token=id_token,
|
|
554
|
+
nonce=nonce,
|
|
555
|
+
authorization_code=authorization_code,
|
|
556
|
+
invitation=invitation,
|
|
557
|
+
locale=locale,
|
|
558
|
+
consents=consents,
|
|
559
|
+
)
|
|
560
|
+
res = await self._client.request(
|
|
561
|
+
"POST", [*self.base(org), "oauth", provider, "id-token"], body=body, auth=False
|
|
562
|
+
)
|
|
563
|
+
return await self._step(res.data)
|
|
564
|
+
|
|
565
|
+
async def link_provider_with_id_token(
|
|
566
|
+
self, provider: str, *, id_token: str, nonce: str, authorization_code: str | None = None, org: str | None = None
|
|
567
|
+
) -> dict[str, Any]:
|
|
568
|
+
"""Adds a provider to the signed-in user with the ID token of the platform sign-in."""
|
|
569
|
+
body = _compact(id_token=id_token, nonce=nonce, authorization_code=authorization_code, intent="link")
|
|
570
|
+
res = await self._client.request("POST", [*_auth_base(org), "oauth", provider, "id-token"], body=body)
|
|
571
|
+
return cast(dict[str, Any], res.data)
|
|
572
|
+
|
|
573
|
+
# -- email flows -----------------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
async def _accepted(self, path: list[str], body: dict[str, Any]) -> None:
|
|
576
|
+
"""A call that needs no session and answers with no body."""
|
|
577
|
+
await self._client.request("POST", path, body=_compact(**body), auth=False)
|
|
578
|
+
|
|
579
|
+
async def resend_verification(self, email: str, *, redirect_to: str | None = None) -> None:
|
|
580
|
+
"""Asks for the verification email again. Always succeeds, whatever the address is (unknown, disabled,
|
|
581
|
+
already verified), so it can't be used to find out who is registered."""
|
|
582
|
+
await self._accepted(["_auth", "verify-email", "resend"], {"email": email, "redirect_to": redirect_to})
|
|
583
|
+
|
|
584
|
+
async def verify_email(self, token: str) -> None:
|
|
585
|
+
"""Verifies an address with the token of the link in the email: for apps that host their own page
|
|
586
|
+
(``email.links`` in ``realm.yaml``). Starts no session. An expired, used or unknown token raises a
|
|
587
|
+
``ValidationError`` whose ``code`` is ``invalid_token``."""
|
|
588
|
+
await self._accepted(["_auth", "verify-email"], {"token": token})
|
|
589
|
+
|
|
590
|
+
async def request_password_reset(
|
|
591
|
+
self, email: str, *, redirect_to: str | None = None, org: str | None = None
|
|
592
|
+
) -> None:
|
|
593
|
+
"""Asks for a password reset email. Always succeeds, whatever the address is. Raises a
|
|
594
|
+
``RetryableError`` once the realm's limits for reset requests (per address and per client) are reached.
|
|
595
|
+
``org`` is the organization whose page the person asks from: its members get the organization's context
|
|
596
|
+
in the email."""
|
|
597
|
+
await self._accepted(
|
|
598
|
+
[*self.base(org), "reset-password", "request"], {"email": email, "redirect_to": redirect_to}
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
async def reset_password(self, token: str, password: str) -> None:
|
|
602
|
+
"""Sets a new password with the token of a reset link. Ends every session of the user, verifies their
|
|
603
|
+
address and starts no session: log in afterwards. A password the policy refuses raises a
|
|
604
|
+
``ValidationError`` and leaves the token usable; a bad token has the ``invalid_token`` code."""
|
|
605
|
+
await self._accepted(["_auth", "reset-password"], {"token": token, "password": password})
|
|
606
|
+
|
|
607
|
+
async def update_password(
|
|
608
|
+
self,
|
|
609
|
+
email: str,
|
|
610
|
+
password: str,
|
|
611
|
+
new_password: str,
|
|
612
|
+
*,
|
|
613
|
+
code: str | None = None,
|
|
614
|
+
method: Literal["totp", "recovery"] | None = None,
|
|
615
|
+
) -> None:
|
|
616
|
+
"""Changes the password with the current one, signed out: for a password that expired for the user
|
|
617
|
+
alone (``password_expired`` at sign-in) or one the user wants to replace without a session. An account
|
|
618
|
+
with a second factor also gives ``code`` (a recovery code with ``method="recovery"``). Ends every session
|
|
619
|
+
of the user and starts none: log in afterwards. A password the realm's expiry covers (a leak) is refused
|
|
620
|
+
with ``password_expired``: use :meth:`request_password_reset`."""
|
|
621
|
+
await self._accepted(
|
|
622
|
+
["_auth", "password", "update"],
|
|
623
|
+
{"email": email, "password": password, "new_password": new_password, "code": code, "method": method},
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
async def request_email_change(self, new_email: str, *, redirect_to: str | None = None) -> None:
|
|
627
|
+
"""Asks to change the signed-in user's email address (realms with ``account.allow_email_change``). Needs a
|
|
628
|
+
fresh proof: call :meth:`reauth` first. Nothing changes until the link sent to the new address is used
|
|
629
|
+
(see :meth:`confirm_email_change`)."""
|
|
630
|
+
await self._client.request(
|
|
631
|
+
"POST", ["_auth", "email"], body=_compact(new_email=new_email, redirect_to=redirect_to), reauth=True
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
async def confirm_email_change(self, token: str) -> None:
|
|
635
|
+
"""Confirms an email change with the token sent to the new address: the address changes and every session
|
|
636
|
+
of the user ends."""
|
|
637
|
+
await self._accepted(["_auth", "confirm-email-change"], {"token": token})
|
|
638
|
+
|
|
639
|
+
async def revert_email_change(self, token: str) -> None:
|
|
640
|
+
"""Undoes an email change with the token sent to the old address: restores it, ends every session and
|
|
641
|
+
makes the password unusable until it is reset (a reset email is sent to the restored address)."""
|
|
642
|
+
await self._accepted(["_auth", "revert-email-change"], {"token": token})
|
|
643
|
+
|
|
644
|
+
async def accept_invitation(self, token: str, password: str, *, locale: str | None = None) -> None:
|
|
645
|
+
"""Accepts an invitation that was emailed (admin ``invitations.send``), with the token of its link:
|
|
646
|
+
creates the account for the invited address, already verified, and starts no session."""
|
|
647
|
+
await self._accepted(["_auth", "accept-invitation"], {"token": token, "password": password, "locale": locale})
|
|
648
|
+
|
|
649
|
+
# -- the session -----------------------------------------------------------------------
|
|
650
|
+
|
|
651
|
+
async def logout(self) -> None:
|
|
652
|
+
"""Ends the current session. The stored token is removed even if the server can't be reached."""
|
|
653
|
+
try:
|
|
654
|
+
await self._client.request("POST", ["_auth", "logout"], expire=False)
|
|
655
|
+
except AuthenticationError:
|
|
656
|
+
pass # a session the server no longer knows is logged out already
|
|
657
|
+
finally:
|
|
658
|
+
await self._signed_out("SIGNED_OUT")
|
|
659
|
+
|
|
660
|
+
async def logout_all(self) -> None:
|
|
661
|
+
"""Ends every session of the user, on every device."""
|
|
662
|
+
await self._client.request("POST", ["_auth", "logout-all"])
|
|
663
|
+
await self._signed_out("SIGNED_OUT")
|
|
664
|
+
|
|
665
|
+
async def me(self) -> Me:
|
|
666
|
+
"""The signed-in user, with their sign-in methods in ``identities``; for a session scoped to an
|
|
667
|
+
organization, ``org`` says which and what the person is there."""
|
|
668
|
+
return cast(Me, (await self._client.request("GET", ["_auth", "me"])).data)
|
|
669
|
+
|
|
670
|
+
async def update_me(self, *, locale: str | None = None) -> User:
|
|
671
|
+
"""Changes the signed-in user's own settings: today their language, which must be one the realm lists
|
|
672
|
+
(case is ignored). Anything else is a ``ValidationError`` with code ``invalid_locale`` and the allowed
|
|
673
|
+
languages in ``details``."""
|
|
674
|
+
res = await self._client.request("PATCH", ["_auth", "me"], body=_compact(locale=locale))
|
|
675
|
+
return cast(User, res.data)
|
|
676
|
+
|
|
677
|
+
async def delete_account(self) -> None:
|
|
678
|
+
"""Deletes the signed-in user's account: it is **deactivated** (disabled, its sessions end) and all data
|
|
679
|
+
is kept. Erasing a user's data is an administrator's action. Needs a fresh proof: call :meth:`reauth`
|
|
680
|
+
first (a ``reauth_required`` error otherwise)."""
|
|
681
|
+
await self._client.request("DELETE", ["_auth", "me"], reauth=True)
|
|
682
|
+
await self._signed_out("SIGNED_OUT")
|
|
683
|
+
|
|
684
|
+
async def change_password(self, new_password: str) -> None:
|
|
685
|
+
"""Changes the password. This session stays valid; all others end. Needs a fresh proof: call
|
|
686
|
+
:meth:`reauth` first."""
|
|
687
|
+
await self._client.request("POST", ["_auth", "password"], body={"new_password": new_password}, reauth=True)
|
|
688
|
+
|
|
689
|
+
async def reauth(self, proof: ReauthInput) -> dict[str, str]:
|
|
690
|
+
"""Proves who the signed-in user is again, before a sensitive action (changing the password or the
|
|
691
|
+
address, deleting the account, unlinking a sign-in method, managing the second factor, creating an API
|
|
692
|
+
key as an administrator). ``method`` is the strongest proof the user has (see ``reauth_methods`` of
|
|
693
|
+
:meth:`me`): ``totp`` or ``recovery_code`` with a second factor, ``password``, or ``email_code`` after
|
|
694
|
+
:meth:`request_reauth_code`. The client keeps the proof and sends it with the sensitive requests while it
|
|
695
|
+
is good (the realm's ``reauth.ttl``, 5 minutes by default), so one prompt covers a settings page. A wrong
|
|
696
|
+
proof is an ``AuthenticationError`` (``invalid_credentials``) or a ``ValidationError``
|
|
697
|
+
(``invalid_code``) and counts against the same limits as signing in. A password an administrator expired
|
|
698
|
+
is a :class:`PasswordExpiredError`. Returns ``reauth_token`` and ``expires_at``."""
|
|
699
|
+
body = _compact(method=proof["method"], code=proof.get("code"), password=proof.get("password"))
|
|
700
|
+
res = await self._client.request("POST", ["_auth", "reauth"], body=body)
|
|
701
|
+
data = cast(dict[str, str], res.data)
|
|
702
|
+
self._client._proof = (data["reauth_token"], parse_time(data["expires_at"]))
|
|
703
|
+
return {"reauth_token": data["reauth_token"], "expires_at": data["expires_at"]}
|
|
704
|
+
|
|
705
|
+
def has_fresh_proof(self) -> bool:
|
|
706
|
+
"""Whether the client holds a proof that is still good (see :meth:`reauth`)."""
|
|
707
|
+
proof = self._client._proof
|
|
708
|
+
return proof is not None and proof[1] > time.time()
|
|
709
|
+
|
|
710
|
+
async def request_reauth_code(self) -> None:
|
|
711
|
+
"""Emails a code to a user who has no password and no second factor, to give to :meth:`reauth` with
|
|
712
|
+
``method="email_code"``."""
|
|
713
|
+
await self._client.request("POST", ["_auth", "reauth", "email-code"])
|
|
714
|
+
|
|
715
|
+
async def sessions(self) -> list[SessionInfo]:
|
|
716
|
+
"""The user's active sessions, newest first."""
|
|
717
|
+
res = await self._client.request("GET", ["_auth", "sessions"])
|
|
718
|
+
return cast("list[SessionInfo]", cast(dict[str, Any], res.data)["items"])
|
|
719
|
+
|
|
720
|
+
async def revoke_session(self, session_id: str) -> None:
|
|
721
|
+
"""Ends one of the user's sessions, for example a lost device."""
|
|
722
|
+
await self._client.request("DELETE", ["_auth", "sessions", session_id])
|
|
723
|
+
|
|
724
|
+
def token(self) -> str | None:
|
|
725
|
+
"""The stored session token, if any."""
|
|
726
|
+
return self._client.storage.get() or None
|
|
727
|
+
|
|
728
|
+
def has_session(self) -> bool:
|
|
729
|
+
"""Whether this client holds a token. A program that has just started knows nothing more: ask the
|
|
730
|
+
server with :meth:`me`."""
|
|
731
|
+
return bool(self._client.storage.get())
|
|
732
|
+
|
|
733
|
+
def on_auth_change(self, listener: AuthListener) -> Callable[[], None]:
|
|
734
|
+
"""Calls ``listener(event, session)`` on sign-in, sign-out and session expiry. Returns a function that
|
|
735
|
+
stops listening."""
|
|
736
|
+
self._listeners.append(listener)
|
|
737
|
+
|
|
738
|
+
def stop() -> None:
|
|
739
|
+
if listener in self._listeners:
|
|
740
|
+
self._listeners.remove(listener)
|
|
741
|
+
|
|
742
|
+
return stop
|
|
743
|
+
|
|
744
|
+
# -- state -----------------------------------------------------------------------------
|
|
745
|
+
|
|
746
|
+
async def _signed_in(self, session: Any) -> Session:
|
|
747
|
+
client = self._client
|
|
748
|
+
client._proof = None
|
|
749
|
+
client._credentials_changed() # what was read as someone else (or no one) isn't theirs
|
|
750
|
+
token = session.get("token")
|
|
751
|
+
if token:
|
|
752
|
+
client.storage.set(token)
|
|
753
|
+
self.current = session
|
|
754
|
+
await self._remember({"org": session.get("org"), "expires_at": session.get("expires_at")})
|
|
755
|
+
self._emit("SIGNED_IN", session)
|
|
756
|
+
return cast(Session, session)
|
|
757
|
+
|
|
758
|
+
async def _remember(self, fields: dict[str, Any]) -> None:
|
|
759
|
+
"""Keeps a note of the session beside the token of an organization's client."""
|
|
760
|
+
client = self._client
|
|
761
|
+
storage = client.root.storage
|
|
762
|
+
if not client.org_ref or not isinstance(storage, SlotStorage):
|
|
763
|
+
return
|
|
764
|
+
note = storage.slot("meta:" + client.org_ref)
|
|
765
|
+
try:
|
|
766
|
+
held = json.loads(note.get() or "{}")
|
|
767
|
+
except ValueError:
|
|
768
|
+
held = {} # a damaged note is replaced
|
|
769
|
+
note.set(json.dumps({**held, **{k: v for k, v in fields.items() if v is not None}}))
|
|
770
|
+
|
|
771
|
+
async def _signed_out(self, event: AuthEvent) -> None:
|
|
772
|
+
client = self._client
|
|
773
|
+
client._proof = None
|
|
774
|
+
client._credentials_changed()
|
|
775
|
+
client.storage.remove()
|
|
776
|
+
self.current = None
|
|
777
|
+
storage = client.root.storage
|
|
778
|
+
if client.org_ref and isinstance(storage, SlotStorage):
|
|
779
|
+
storage.slot("meta:" + client.org_ref).remove()
|
|
780
|
+
self._emit(event, None)
|
|
781
|
+
|
|
782
|
+
async def _expired(self) -> None:
|
|
783
|
+
"""Called by the client when the server refuses the stored token."""
|
|
784
|
+
await self._signed_out("SESSION_EXPIRED")
|
|
785
|
+
|
|
786
|
+
def _emit(self, event: AuthEvent, session: Session | None) -> None:
|
|
787
|
+
for listener in list(self._listeners):
|
|
788
|
+
try:
|
|
789
|
+
listener(event, session)
|
|
790
|
+
except Exception: # a failing listener must not break the client or the other listeners
|
|
791
|
+
logging.getLogger("backd").exception("an on_auth_change listener failed")
|