Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1b48e8d
feat(auth): add Python authentication state
swkeever Aug 28, 2026
8c706f5
feat(auth): expand Python authentication transport
swkeever Aug 28, 2026
36f924e
feat(auth): implement Python session lifecycle
swkeever Aug 28, 2026
b0fb9d6
feat(auth): complete Python authentication facade
swkeever Aug 28, 2026
8961e07
test(auth): bind Python authentication contract
swkeever Aug 28, 2026
5076d1d
chore: enforce strict quality gates
swkeever Aug 28, 2026
a8968ec
fix: address authentication review findings
swkeever Aug 28, 2026
2e0416e
fix: synchronize authentication state
swkeever Aug 28, 2026
b1b64d8
fix: make authentication state atomic
swkeever Aug 28, 2026
8ba1ceb
fix: serialize authentication transitions
swkeever Aug 28, 2026
d8683b6
fix: distinguish provider authorization failures
swkeever Aug 28, 2026
ee8c528
fix: preserve authentication invariants
swkeever Aug 28, 2026
7cdeda3
fix: serialize authentication state transitions
swkeever Aug 28, 2026
c1c0aee
fix: enforce authentication ownership boundaries
swkeever Aug 28, 2026
cdbfac3
fix: preserve authenticated operation guarantees
swkeever Aug 28, 2026
3af1245
fix: serialize auth event delivery
swkeever Aug 28, 2026
c8d8f9c
fix: retry invalidated realtime subscriptions
swkeever Aug 28, 2026
175a4b1
fix: identify restored device sessions
swkeever Aug 28, 2026
3ad6a97
chore: require compatible Ruff version
swkeever Aug 28, 2026
ae086d6
fix: use structured provider errors
swkeever Aug 28, 2026
b8e15d7
feat: expose session cursor pagination
swkeever Aug 28, 2026
2db7f0e
fix: harden authentication concurrency
swkeever Aug 28, 2026
5dd73de
fix: normalize authentication failures
swkeever Aug 28, 2026
c926391
fix: harden realtime auth transitions
swkeever Aug 28, 2026
933d2a8
fix(auth): close facade review gaps
swkeever Aug 28, 2026
8cbe66f
fix(auth): refresh user after method promotion
swkeever Aug 28, 2026
2ce360e
test(realtime): await scheduled auth cleanup
swkeever Aug 28, 2026
7a1847b
feat(auth): complete stable authentication surface
swkeever Aug 28, 2026
844d19e
fix(realtime): fence publishes across auth changes
swkeever Aug 28, 2026
cb49343
fix(auth): align device verification contract
swkeever Aug 28, 2026
118bb00
docs(auth): clarify platform exchange eligibility
swkeever Aug 28, 2026
c92c2e6
fix(auth): close concurrency review gaps
swkeever Aug 28, 2026
f501c6d
fix(auth): fence stale asynchronous state
swkeever Aug 28, 2026
dfaa12a
fix(auth): complete realtime cleanup barriers
swkeever Aug 28, 2026
9c743df
fix(realtime): drain completed cleanup snapshots
swkeever Aug 28, 2026
dfb2a2b
fix(auth): reconcile validated response state
swkeever Aug 28, 2026
3a9cbc0
fix(auth): preserve listener semantics
swkeever Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,6 @@ VOLCANO_SDK_CONTRACT_FIXTURE=/absolute/path/to/fixture.json \
```

The fixture must be an absolute path to a mode-`0600` JSON file.

See [Authentication](docs/authentication.md) for account, session, hosted auth,
and OAuth examples.
255 changes: 255 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
# Authentication

Create a client with your project URL and anonymous key, then use `client.auth`
for account and session operations.

```python
from volcano_sdk import VolcanoClient

client = VolcanoClient(
api_url="https://api.volcano.dev",
anon_key="your-anon-key",
)

session = client.auth.sign_in(
email="user@example.com",
password="your-password",
)

print(client.current_user.id)
print(session.expires_in)
```

The client keeps the active `current_user` and `current_session` in memory.
Pass both tokens when restoring an existing session in a new client. An access
token without a refresh token is valid, but a refresh token without an access
token is rejected.

```python
restored = VolcanoClient(
api_url="https://api.volcano.dev",
anon_key="your-anon-key",
access_token=session.access_token,
refresh_token=session.refresh_token,
)

restored.auth.refresh_session()
```

Store tokens in the secure storage provided by your runtime. Do not log them or
place them in source control.

## Manage the session

Subscribe to auth-state changes when application state must follow the client.
The listener runs immediately when the client is signed out or already has a
resolved user, then after committed auth changes. For restored tokens without a
loaded profile, the first event waits for `get_user()` or `refresh_session()` so
the client does not report a valid session as signed out. Call the returned
function to unsubscribe.

```python
unsubscribe = client.auth.on_auth_state_change(
lambda user: print("signed in" if user else "signed out")
)

client.auth.refresh_session()
client.auth.sign_out()
unsubscribe()
```

A failed refresh clears local authentication so stale credentials are not
reused. `sign_out()` also clears local state if the remote revoke fails.

List and revoke device sessions through the same facade:

```python
page = client.auth.get_sessions(sort="created_at", status="active", limit=20)

for device_session in page.sessions:
print(device_session.id, device_session.last_activity_at)

if page.next_cursor:
Comment thread
swkeever marked this conversation as resolved.
cursor_page = client.auth.get_sessions(
sort="created_at",
status="active",
cursor=page.next_cursor,
limit=20,
)

client.auth.delete_session(session_id="session-id")
client.auth.delete_all_other_sessions()
```

## Use password policy and device authorization

Read the server-enforced policy instead of duplicating password rules:

```python
policy = client.auth.get_password_policy()
print(policy.effective_min_length, policy.compromised_passwords_rejected)
```

An RFC 8628 device client starts authorization and polls at the returned
interval. A successful poll commits the returned user and session to that
client. The signed-in verifier approves the code on a separate client:

```python
authorization = device_client.auth.start_device_authorization(client_id="volcano-cli")
print(authorization.verification_uri, authorization.user_code)

verifier.auth.verify_device(user_code=authorization.user_code, action="approve")
session = device_client.auth.poll_device_token(
client_id="volcano-cli",
device_code=authorization.device_code,
)
```

After the device client commits its approved device-flow session, it can
exchange that verified session for a short-lived platform token. Ordinary
email/password and OAuth sessions are not eligible. Treat `token.token` as a
secret:

```python
token = device_client.auth.exchange_platform_token(client_id="volcano-cli")
```

## Create and update accounts

Sign-up can return without a session when email confirmation is required.

```python
result = client.auth.sign_up(
email="new-user@example.com",
password="your-password",
user_metadata={"plan": "starter"},
)

if result.confirmation_required:
print("Check your email")
```

Update the current user, or start with an anonymous account and preserve its
identity when converting it:

```python
client.auth.update_user(user_metadata={"plan": "pro"})

client.auth.sign_out()
client.auth.sign_up_anonymous(user_metadata={"source": "demo"})
anonymous_id = client.current_user.id

converted = client.auth.convert_anonymous(
email="converted@example.com",
password="your-password",
)
assert converted.id == anonymous_id
```

Conversion is permanent once the API accepts it. If the follow-up token rotation
fails, the method still returns the converted user and clears the local session;
sign in with the new credentials to continue.

Email workflows are available as explicit operations:

```python
client.auth.resend_confirmation(email="new-user@example.com")
client.auth.confirm_email(token="confirmation-token")
client.auth.forgot_password(email="user@example.com")
client.auth.reset_password(
token="recovery-token",
new_password="your-new-password",
)

change = client.auth.request_email_change(new_email="next@example.com")
client.auth.confirm_email_change(token="email-change-token")
# Or cancel a pending request:
client.auth.cancel_email_change()
```

Password reset revokes the reset account's existing sessions. If this client is
using one of them, `reset_password()` clears it before returning; sign in with
the new password to continue.

## Open hosted auth and OAuth

Hosted auth returns a URL and generated state value for your application to
retain before navigation:

```python
request = client.auth.get_hosted_auth_url(
project_id="project-id",
action="login",
)
print(request.authorization_url)
```

When hosted auth redirects back, compare the returned state with
`request.state` before adopting any returned credentials. The Python SDK does
not consume browser callbacks automatically.

OAuth authorization follows the same pattern. Preserve `request.state` and
pass it as `expected_state` during exchange; the SDK rejects a mismatch before
calling the API.

```python
request = client.auth.get_oauth_authorization_url(
provider="github",
redirect_url="https://app.example.com/auth/callback",
)

# After the provider redirects to your application:
client.auth.exchange_oauth_code(
code="authorization-code",
redirect_url="https://app.example.com/auth/callback",
state="state-from-callback",
expected_state=request.state,
)
```

Signed-in users can link providers, inspect them, refresh provider tokens, and
call provider APIs through Volcano:

```python
link = client.auth.link_oauth_provider(
provider="github",
redirect_url="https://app.example.com/auth/link/callback",
)

providers = client.auth.get_linked_oauth_providers()
token = client.auth.get_oauth_provider_token(provider="github")
client.auth.refresh_oauth_token(provider="github")
profile = client.auth.call_oauth_api(
provider="github",
endpoint="/user",
)
client.auth.unlink_oauth_provider(provider="github")
```

## Manage identities and sign-in methods

List the email identities and sign-in methods owned by the current account:

```python
identities = client.auth.list_identities()
methods = client.auth.list_methods()

for identity in identities:
print(identity.email, identity.is_primary)

for method in methods:
print(method.type, method.provider, method.is_primary)
```

Promote a sign-in method or unlink a non-primary identity by its ID:

```python
promoted = client.auth.promote_method(method_id="method-uuid")
client.auth.unlink_identity(identity_id="identity-uuid")
```

The API refuses to unlink a primary or last identity, or an identity whose
removal would leave the account without a sign-in method.

Keep generated state values and provider tokens secret. Navigate to the returned
authorization URL only after storing its matching state value.
62 changes: 62 additions & 0 deletions features/contract/auth.feature
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,65 @@ Feature: SDK authentication contract
Then the SDK operation succeeds
And the current session belongs to the contract user
And the current session exposes access and refresh tokens

@auth @SDK-AUTH-002
Scenario: Password sign-up acknowledges a session-less account
Given a unique unconfirmed contract user
When the client signs up with the new user's credentials
Then the SDK operation succeeds
And sign-up is acknowledged without a session
And the current session is empty

@auth @SDK-AUTH-003
Scenario: The current user can be retrieved and updated
Given the client is signed in as the confirmed contract user
When the client retrieves the current user
Then the current user belongs to the contract user
When the client updates the current user's metadata
Then the current user contains the updated metadata

@auth @SDK-AUTH-004
Scenario: Refresh rotates tokens and failed refresh clears authentication
Given the client is signed in as the confirmed contract user
When the client refreshes the current session
Then the current session exposes rotated access and refresh tokens
When the client refreshes with an invalid refresh token
Then the SDK operation fails
And the current session is empty

@auth @SDK-AUTH-005
Scenario: Sign-out clears local authentication
Given the client is signed in as the confirmed contract user
When the client signs out
Then the SDK operation succeeds
And the current session is empty

@auth @SDK-AUTH-006
Scenario: Auth-state listeners observe changes until unsubscribe
Given the client is signed in as the confirmed contract user
When the client subscribes to auth-state changes
Then the listener immediately observes the current user
When the client signs out
Then the listener observes the signed-out state
When the client unsubscribes from auth-state changes
And the client signs in with the contract user's credentials
Then the listener receives no additional events

@auth @SDK-AUTH-007
Scenario: An anonymous user can convert to a credentialed account
Given a unique anonymous contract user
When the client signs up anonymously
Then the current session belongs to the anonymous user
When the client converts the anonymous user with credentials
Then the SDK operation succeeds
And the converted user keeps the anonymous user identity

@auth @SDK-AUTH-011
Scenario: A user can inspect and delete their sessions
Given the client is signed in as the confirmed contract user on multiple sessions
When the client lists the current user's sessions
Then the session list contains the current session
When the client deletes another current-user session
Then the deleted session is absent from the session list
When the client deletes all current-user sessions
Then the current session is empty
Loading
Loading