Skip to content

Commit 5715eb8

Browse files
authored
feat(auth): optionally sign in after signup (#118)
* feat(auth): optionally sign in after signup * fix(auth): preserve signup session ownership
1 parent dfe04f4 commit 5715eb8

4 files changed

Lines changed: 103 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,9 +255,19 @@ Pass a user ID to enforce that user's Row-Level Security policies. Omit
255255
The helper preserves libpq connection syntax, including hostless and multi-host
256256
targets, and leaves unrelated query values unchanged.
257257

258-
`sign_up()` returns an immutable acknowledgement and never creates or replaces a session. The
259-
response is identical for new and existing email addresses. Call `sign_in()` separately after the
260-
account is ready to establish a session.
258+
`sign_up()` returns an immutable acknowledgement without changing the session by default.
259+
The signup acknowledgement is identical for new and existing email addresses. Pass
260+
`sign_in_when_allowed=True` to follow it with `sign_in()` only when confirmation is not required:
261+
262+
```python
263+
result = client.auth.sign_up(
264+
email="new-user@example.com", password="secret", sign_in_when_allowed=True
265+
)
266+
session = result.session # None when no follow-up sign-in ran.
267+
```
268+
269+
A successful follow-up stores the session and emits the normal sign-in event. A failed
270+
follow-up raises its usual typed error; it does not undo the successful signup.
261271

262272
`get_session()` reads immutable local state. It does not refresh or validate the token.
263273

‎src/volcano_sdk/auth.py‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import threading
1010
from collections.abc import Mapping
1111
from contextlib import suppress
12+
from dataclasses import replace
1213
from datetime import datetime
1314
from http import HTTPStatus
1415
from typing import TYPE_CHECKING, Literal, Protocol, TypeVar, cast
@@ -520,8 +521,10 @@ def sign_up(
520521
email: str,
521522
password: str,
522523
metadata: Mapping[str, object] | None = None,
524+
sign_in_when_allowed: bool = False,
523525
) -> SignUpResult:
524-
"""Create an account without creating or replacing a local session."""
526+
"""Sign up, optionally signing in when confirmation is not required."""
527+
generation, _ = self._client._capture_session()
525528
transport = cast("AuthSignUpTransport", self._client._transport)
526529
response = invoke(
527530
transport.auth_signup,
@@ -530,7 +533,11 @@ def sign_up(
530533
password=password,
531534
metadata=dict(metadata or {}),
532535
)
533-
return _sign_up_result_from_payload(response_payload(response, 201))
536+
result = _sign_up_result_from_payload(response_payload(response, 201))
537+
if sign_in_when_allowed and not result.confirmation_required:
538+
session = self._sign_in_for_generation(email, password, generation)
539+
return replace(result, session=session)
540+
return result
534541

535542
def sign_in_anonymously(
536543
self,
@@ -971,6 +978,13 @@ def update_user(
971978
def sign_in(self, *, email: str, password: str) -> Session:
972979
"""Sign in a user and store the returned session."""
973980
generation, _ = self._client._capture_session()
981+
return self._sign_in_for_generation(email, password, generation)
982+
983+
def _sign_in_for_generation(
984+
self, email: str, password: str, generation: int
985+
) -> Session:
986+
if self._client._capture_session()[0] != generation:
987+
raise SessionChangedError
974988
response = invoke(
975989
self._client._transport.auth_signin,
976990
authorization=self._client._anon_token(),

‎src/volcano_sdk/models.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,11 @@ class OAuthProviderTokenStatus:
159159

160160
@dataclass(frozen=True, slots=True)
161161
class SignUpResult:
162-
"""Session-less acknowledgement returned after sign-up."""
162+
"""Sign-up acknowledgement with an optional follow-up sign-in session."""
163163

164164
confirmation_required: bool
165165
message: str
166+
session: Session | None = field(default=None, repr=False)
166167

167168

168169
@dataclass(frozen=True, slots=True)

‎tests/unit/test_state.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ def _linked_oauth_providers() -> AuthListOAuthProvidersResponse200:
168168

169169
class StateTransport:
170170
on_signin: Callable[[], None] | None = None
171+
on_signup: Callable[[], None] | None = None
171172

172173
def __init__(self) -> None:
173174
self.next_access_token = "access-1"
@@ -343,6 +344,8 @@ def auth_signin(self, **kwargs: Any) -> Response:
343344
def auth_signup(self, **kwargs: Any) -> Response:
344345
self.authorizations.append(("signup", kwargs["authorization"]))
345346
self.signup_calls.append(kwargs)
347+
if self.on_signup is not None:
348+
self.on_signup()
346349
return self.signup_response
347350

348351
def auth_signup_anonymous(self, **kwargs: Any) -> Response:
@@ -1181,6 +1184,75 @@ def test_sign_up_uses_empty_metadata_without_creating_a_session() -> None:
11811184
assert transport.signup_calls[0]["metadata"] == {}
11821185

11831186

1187+
@pytest.mark.parametrize("confirmation_required", [True, False])
1188+
@pytest.mark.parametrize("sign_in_when_allowed", [True, False])
1189+
def test_sign_up_only_signs_in_when_opted_in_and_allowed(
1190+
*, confirmation_required: bool, sign_in_when_allowed: bool
1191+
) -> None:
1192+
transport = StateTransport()
1193+
transport.signup_response = Response(
1194+
201, {"confirmation_required": confirmation_required, "message": "Accepted"}
1195+
)
1196+
client = VolcanoClient(anon_key="anon", _transport=transport)
1197+
result = client.auth.sign_up(
1198+
email="new@example.com",
1199+
password="secret",
1200+
sign_in_when_allowed=sign_in_when_allowed,
1201+
)
1202+
1203+
signed_in = sign_in_when_allowed and not confirmation_required
1204+
assert (result.session is not None) is signed_in
1205+
assert result.session is client.auth.get_session()
1206+
assert result.confirmation_required is confirmation_required
1207+
assert result.message == "Accepted"
1208+
assert transport.authorizations == [("signup", "anon")] + (
1209+
[("auth", "anon")] if signed_in else []
1210+
)
1211+
1212+
1213+
@pytest.mark.parametrize("replace_during", ["signup", "signin"])
1214+
def test_signup_followup_does_not_replace_a_newer_session(replace_during: str) -> None:
1215+
transport = StateTransport()
1216+
transport.signup_response = Response(
1217+
201, {"confirmation_required": False, "message": "Accepted"}
1218+
)
1219+
client = VolcanoClient(anon_key="anon", _transport=transport)
1220+
replacement = Session("replacement", "refresh", "other")
1221+
1222+
def replace_session() -> None:
1223+
client.auth.set_session(replacement)
1224+
1225+
if replace_during == "signup":
1226+
transport.on_signup = replace_session
1227+
else:
1228+
transport.on_signin = replace_session
1229+
with pytest.raises(SessionChangedError):
1230+
client.auth.sign_up(
1231+
email="new@example.com", password="secret", sign_in_when_allowed=True
1232+
)
1233+
assert client.auth.get_session() == replacement
1234+
1235+
1236+
def test_sign_up_surfaces_followup_signin_failure_without_changing_session() -> None:
1237+
transport = StateTransport()
1238+
client = VolcanoClient(anon_key="anon", _transport=transport)
1239+
established = client.auth.sign_in(email="user@example.com", password="secret")
1240+
transport.signup_response = Response(
1241+
201, {"confirmation_required": False, "message": "Accepted"}
1242+
)
1243+
1244+
def reject_signin() -> None:
1245+
message = "Sign-in rejected"
1246+
raise AuthenticationError(message)
1247+
1248+
transport.on_signin = reject_signin
1249+
with pytest.raises(AuthenticationError, match="Sign-in rejected"):
1250+
client.auth.sign_up(
1251+
email="new@example.com", password="secret", sign_in_when_allowed=True
1252+
)
1253+
assert client.auth.get_session() is established
1254+
1255+
11841256
def test_sign_up_raises_typed_errors_without_changing_session() -> None:
11851257
transport = StateTransport()
11861258
client = VolcanoClient(anon_key="anon", _transport=transport)

0 commit comments

Comments
 (0)