This commit is contained in:
Ejiro Asiuwhu 2026-07-20 09:47:27 +01:00 committed by GitHub
commit b933f9de90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 10980 additions and 0 deletions

View File

@ -0,0 +1,218 @@
# Stack Auth Python SDK
Python SDK for [Stack Auth](https://stack-auth.com) — the open-source authentication platform. Provides `StackServerApp` for server-side user management, team operations, JWT verification, and more.
## Installation
```bash
pip install stack-auth
```
## Quick Start
```python
from stack_auth import StackServerApp
app = StackServerApp(
project_id="your-project-id",
secret_server_key="your-secret-server-key",
)
# List users
users = app.list_users(limit=10)
for user in users.items:
print(f"{user.display_name} ({user.primary_email})")
# Create a user
user = app.create_user(
primary_email="user@example.com",
password="securepassword",
display_name="Jane Doe",
)
# Get a user
user = app.get_user("user-id")
# Update a user
updated = app.update_user("user-id", display_name="New Name")
# Delete a user
app.delete_user("user-id")
```
## Async Support
Every method has an async equivalent via `AsyncStackServerApp`:
```python
from stack_auth import AsyncStackServerApp
app = AsyncStackServerApp(
project_id="your-project-id",
secret_server_key="your-secret-server-key",
)
user = await app.get_user("user-id")
users = await app.list_users(limit=10)
team = await app.create_team(display_name="Engineering")
```
## Authentication
Verify incoming request tokens without framework-specific middleware:
```python
from stack_auth._auth import sync_authenticate_request
from stack_auth._jwt import SyncJWKSFetcher
fetcher = SyncJWKSFetcher(
project_id="your-project-id",
base_url="https://api.stack-auth.com",
)
# Works with any object that has a .headers mapping
result = sync_authenticate_request(request, fetcher=fetcher)
if result.status == "authenticated":
print(f"User: {result.user_id}")
else:
print("Not authenticated")
```
### FastAPI Example
```python
from fastapi import Depends, HTTPException, Request
from stack_auth._auth import sync_authenticate_request
from stack_auth._jwt import SyncJWKSFetcher
fetcher = SyncJWKSFetcher(project_id="...", base_url="https://api.stack-auth.com")
def get_user_id(request: Request) -> str:
result = sync_authenticate_request(request, fetcher=fetcher)
if result.status != "authenticated":
raise HTTPException(status_code=401)
return result.user_id
@app.get("/protected")
def protected_route(user_id: str = Depends(get_user_id)):
return {"user_id": user_id}
```
## Error Handling
All API errors raise typed exceptions:
```python
from stack_auth.errors import (
StackAuthError,
AuthenticationError,
NotFoundError,
ValidationError,
)
try:
user = app.get_user("invalid-id")
except NotFoundError as e:
print(f"Not found: {e.message}")
except AuthenticationError as e:
print(f"Auth failed: {e.code}")
except StackAuthError as e:
print(f"API error: {e.code} - {e.message}")
```
## Teams
```python
# Create a team
team = app.create_team(
display_name="Engineering",
creator_user_id="user-id",
)
# List teams
teams = app.list_teams(user_id="user-id")
# Manage members
app.add_team_member(team_id=team.id, user_id="another-user-id")
profiles = app.list_team_member_profiles(team_id=team.id)
# Permissions
app.grant_team_permission(
team_id=team.id,
user_id="user-id",
permission_id="admin",
)
```
## Self-Hosted
Point to your self-hosted Stack Auth instance:
```python
app = StackServerApp(
project_id="your-project-id",
secret_server_key="your-secret-key",
base_url="https://your-stack-auth.example.com",
)
```
## API Reference
### StackServerApp / AsyncStackServerApp
**Constructor:**
- `project_id` (str) — Your Stack Auth project ID
- `secret_server_key` (str) — Server secret key from the dashboard
- `base_url` (str, optional) — API base URL (default: `https://api.stack-auth.com`)
- `publishable_client_key` (str, optional) — Required for projects with `requirePublishableClientKey`
- `token_store` (optional) — Token storage strategy
**User methods:** `get_user`, `list_users`, `create_user`, `update_user`, `delete_user`, `get_user_by_api_key`, `get_partial_user`
**Team methods:** `get_team`, `list_teams`, `create_team`, `update_team`, `delete_team`, `get_team_by_api_key`, `add_team_member`, `remove_team_member`, `list_team_member_profiles`, `get_team_member_profile`, `send_team_invitation`, `list_team_invitations`, `revoke_team_invitation`
**Permission methods:** `grant_team_permission`, `revoke_team_permission`, `list_team_permissions`, `grant_user_permission`, `revoke_user_permission`, `list_user_permissions`
**Session methods:** `list_sessions`, `get_session`, `revoke_session`
**Contact channel methods:** `send_contact_channel_verification`, `verify_contact_channel`, `check_contact_channel_verification`
**API key methods:** `create_user_api_key`, `list_user_api_keys`, `revoke_user_api_key`, `create_team_api_key`, `list_team_api_keys`, `revoke_team_api_key`, `check_api_key`
**OAuth methods:** `list_oauth_providers`, `create_oauth_provider`, `get_oauth_provider`, `list_connected_accounts`
**Payment methods:** `list_products`, `get_item`, `grant_product`, `cancel_subscription`
**Email methods:** `send_email`, `get_email_delivery_stats`
**Data vault:** `get_data_vault_store` → returns `DataVaultStore` with `get`, `set`, `delete`, `list_keys`
## Development
```bash
# Clone the repo
git clone https://github.com/stack-auth/stack-auth.git
cd stack-auth/sdks/implementations/python
# Install in dev mode
pip install -e ".[dev]"
# Run unit tests (no server needed)
python3 -m pytest tests/ -v
# Run E2E tests (requires running Stack Auth)
# First: pnpm start-deps && pnpm dev (from monorepo root)
STACK_E2E=1 python3 -m pytest tests/test_e2e.py -v -s
```
## Requirements
- Python >= 3.10
- httpx
- PyJWT[crypto]
- pydantic >= 2.7
## License
MIT — same as [Stack Auth](https://github.com/stack-auth/stack-auth).

View File

@ -0,0 +1,64 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "stack-auth"
version = "0.1.0"
description = "Python SDK for Stack Auth"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Typing :: Typed",
"Framework :: Pydantic :: 2",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Security",
]
keywords = ["stack-auth", "authentication", "sdk", "users", "teams", "python"]
dependencies = [
"httpx>=0.27,<1.0",
"pyjwt[crypto]>=2.9,<3.0",
"pydantic>=2.7,<3.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=1.0",
"respx>=0.22",
"ruff>=0.11",
"mypy>=1.10",
"pytest-cov>=5.0",
]
[project.urls]
Homepage = "https://github.com/stack-auth/stack"
Repository = "https://github.com/stack-auth/stack"
Documentation = "https://docs.stack-auth.com"
"Bug Tracker" = "https://github.com/stack-auth/stack/issues"
[tool.hatch.build.targets.wheel]
packages = ["src/stack_auth"]
[tool.ruff]
target-version = "py310"
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B", "SIM", "TCH"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
[tool.mypy]
python_version = "3.10"
strict = true

View File

@ -0,0 +1,165 @@
"""Stack Auth Python SDK.
A type-safe Python client for the Stack Auth API, providing both
synchronous and asynchronous interfaces for user management, team
management, permissions, API keys, OAuth, payments, email, and more.
Quick Start:
Install the package::
pip install stack-auth
Synchronous usage with :class:`StackServerApp`::
from stack_auth import StackServerApp
app = StackServerApp(
project_id="my-project-id",
secret_server_key="ssk_...",
)
user = app.get_user("user-123")
users = app.list_users(limit=10)
app.close()
Async usage with :class:`AsyncStackServerApp`::
from stack_auth import AsyncStackServerApp
async with AsyncStackServerApp(
project_id="my-project-id",
secret_server_key="ssk_...",
) as app:
user = await app.get_user("user-123")
users = await app.list_users(limit=10)
Error handling with :class:`StackAuthError`::
from stack_auth import StackServerApp, StackAuthError, NotFoundError
app = StackServerApp(project_id="...", secret_server_key="...")
try:
user = app.create_user(primary_email="alice@example.com")
except StackAuthError as e:
print(f"API error: {e}")
Key Features:
- **Type-safe**: All responses are validated Pydantic v2 models.
- **Sync + Async**: Choose :class:`StackServerApp` or
:class:`AsyncStackServerApp` based on your application's needs.
- **Full API coverage**: Users, teams, permissions, sessions,
API keys, OAuth, payments, email, and data vault.
"""
from stack_auth._app import AsyncStackServerApp, StackServerApp
from stack_auth._auth import (
AuthState,
TokenPartialUser,
async_authenticate_request,
decode_access_token_claims,
sync_authenticate_request,
)
from stack_auth._pagination import PaginatedResult
from stack_auth._version import __version__
from stack_auth.errors import (
AnalyticsError,
ApiKeyError,
AuthenticationError,
CliError,
ConflictError,
EmailError,
NotFoundError,
OAuthError,
PasskeyError,
PaymentError,
PermissionDeniedError,
RateLimitError,
StackAuthError,
ValidationError,
)
from stack_auth.models import (
ActiveSession,
ApiKey,
AsyncDataVaultStore,
AsyncServerItem,
BaseUser,
ContactChannel,
DataVaultStore,
EmailDeliveryInfo,
GeoInfo,
Item,
NotificationCategory,
OAuthConnection,
OAuthProvider,
Product,
Project,
ProjectConfig,
ProjectPermission,
ServerItem,
ServerTeam,
ServerUser,
Team,
TeamApiKey,
TeamApiKeyFirstView,
TeamInvitation,
TeamMemberProfile,
TeamPermission,
UserApiKey,
UserApiKeyFirstView,
)
__all__ = [
"__version__",
"StackServerApp",
"AsyncStackServerApp",
"PaginatedResult",
# Errors
"StackAuthError",
"AuthenticationError",
"NotFoundError",
"ValidationError",
"PermissionDeniedError",
"ConflictError",
"OAuthError",
"PasskeyError",
"ApiKeyError",
"PaymentError",
"EmailError",
"RateLimitError",
"CliError",
"AnalyticsError",
# Auth
"AuthState",
"TokenPartialUser",
"decode_access_token_claims",
"sync_authenticate_request",
"async_authenticate_request",
# Models
"DataVaultStore",
"AsyncDataVaultStore",
"BaseUser",
"ServerUser",
"Team",
"ServerTeam",
"TeamMemberProfile",
"TeamInvitation",
"ActiveSession",
"GeoInfo",
"ContactChannel",
"TeamPermission",
"ProjectPermission",
"Project",
"ProjectConfig",
"ApiKey",
"UserApiKey",
"UserApiKeyFirstView",
"TeamApiKey",
"TeamApiKeyFirstView",
"OAuthConnection",
"OAuthProvider",
"Product",
"Item",
"ServerItem",
"AsyncServerItem",
"EmailDeliveryInfo",
"NotificationCategory",
]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,205 @@
"""Authentication module for Stack Auth.
Provides AuthState and TokenPartialUser dataclasses, decode_access_token_claims()
for unverified JWT payload extraction, and sync/async authenticate_request()
functions that compose with the JWT verifier from _jwt.py.
"""
from __future__ import annotations
import base64
import json
import logging
from dataclasses import dataclass
from typing import Any, Literal, Mapping
import httpx
import jwt
logger = logging.getLogger("stack_auth")
from stack_auth._jwt import (
AsyncJWKSFetcher,
SyncJWKSFetcher,
async_verify_token,
sync_verify_token,
)
from stack_auth._types import RequestLike
@dataclass(frozen=True, slots=True)
class TokenPartialUser:
"""Partial user information extracted from a JWT payload without verification.
This is a lightweight representation suitable for quick user identification
when full token verification is not required (e.g., logging, routing).
"""
id: str
display_name: str | None
primary_email: str | None
primary_email_verified: bool
is_anonymous: bool
is_multi_factor_required: bool
is_restricted: bool
restricted_reason: dict[str, Any] | None
@dataclass(frozen=True, slots=True)
class AuthState:
"""Result of authenticating an incoming request.
Attributes:
status: Either ``"authenticated"`` or ``"unauthenticated"``.
user_id: The user's ID from the ``sub`` claim, or ``None``.
claims: Full decoded JWT claims, or ``None``.
token: The raw JWT string, or ``None``.
"""
status: Literal["authenticated", "unauthenticated"]
user_id: str | None = None
claims: dict[str, Any] | None = None
token: str | None = None
def decode_access_token_claims(token: str) -> TokenPartialUser | None:
"""Extract partial user info from a JWT without verifying its signature.
This performs a base64url decode of the payload segment only.
It does NOT verify the token's signature, expiry, or issuer.
Args:
token: The encoded JWT string.
Returns:
A ``TokenPartialUser`` if the payload contains at least a ``sub`` claim,
or ``None`` if the token is malformed or missing required fields.
"""
try:
parts = token.split(".")
if len(parts) < 2:
return None
payload_b64 = parts[1]
# Add padding for base64url decoding
payload_b64 += "=" * (-len(payload_b64) % 4)
payload_bytes = base64.urlsafe_b64decode(payload_b64)
data = json.loads(payload_bytes)
user_id: str = data["sub"]
return TokenPartialUser(
id=user_id,
display_name=data.get("name"),
primary_email=data.get("email"),
primary_email_verified=data.get("email_verified", False),
is_anonymous=data.get("is_anonymous", False),
is_multi_factor_required=data.get("is_multi_factor_required", False),
is_restricted=data.get("is_restricted", False),
restricted_reason=data.get("restricted_reason"),
)
except (ValueError, KeyError, json.JSONDecodeError):
return None
def _extract_token_from_headers(headers: Mapping[str, str]) -> str | None:
"""Extract a bearer token from request headers.
Checks the ``Authorization`` header first (case-insensitive),
then falls back to the ``x-stack-auth`` JSON header's ``accessToken`` field.
Args:
headers: A mapping of header names to values.
Returns:
The extracted token string, or ``None`` if no valid token is found.
"""
# Check Authorization header (both cases for case-sensitive mappings)
auth_value = headers.get("Authorization") or headers.get("authorization")
if auth_value and auth_value.startswith("Bearer "):
return auth_value[len("Bearer "):]
# Fallback to x-stack-auth JSON header
stack_auth_value = headers.get("x-stack-auth")
if stack_auth_value:
try:
data = json.loads(stack_auth_value)
access_token = data.get("accessToken")
if access_token:
return access_token # type: ignore[no-any-return]
except (json.JSONDecodeError, AttributeError):
pass
return None
def sync_authenticate_request(
request: RequestLike,
*,
fetcher: SyncJWKSFetcher,
) -> AuthState:
"""Authenticate an incoming request using its JWT token (synchronous).
Extracts the token from request headers and verifies it using the
provided JWKS fetcher. Returns an ``AuthState`` indicating whether
the request is authenticated.
Args:
request: An object conforming to the ``RequestLike`` protocol.
fetcher: A ``SyncJWKSFetcher`` for retrieving signing keys.
Returns:
An ``AuthState`` with status ``"authenticated"`` on success,
or ``"unauthenticated"`` if no token is present or verification fails.
"""
token = _extract_token_from_headers(request.headers)
if token is None:
return AuthState(status="unauthenticated")
try:
claims = sync_verify_token(token, fetcher)
return AuthState(
status="authenticated",
user_id=claims.get("sub"),
claims=claims,
token=token,
)
except (jwt.PyJWTError, httpx.HTTPError, ValueError, KeyError) as exc:
logger.debug("authenticate_request failed", exc_info=exc)
return AuthState(status="unauthenticated")
async def async_authenticate_request(
request: RequestLike,
*,
fetcher: AsyncJWKSFetcher,
) -> AuthState:
"""Authenticate an incoming request using its JWT token (asynchronous).
Extracts the token from request headers and verifies it using the
provided JWKS fetcher. Returns an ``AuthState`` indicating whether
the request is authenticated.
Args:
request: An object conforming to the ``RequestLike`` protocol.
fetcher: An ``AsyncJWKSFetcher`` for retrieving signing keys.
Returns:
An ``AuthState`` with status ``"authenticated"`` on success,
or ``"unauthenticated"`` if no token is present or verification fails.
"""
token = _extract_token_from_headers(request.headers)
if token is None:
return AuthState(status="unauthenticated")
try:
claims = await async_verify_token(token, fetcher)
return AuthState(
status="authenticated",
user_id=claims.get("sub"),
claims=claims,
token=token,
)
except (jwt.PyJWTError, httpx.HTTPError, ValueError, KeyError) as exc:
logger.debug("authenticate_request failed", exc_info=exc)
return AuthState(status="unauthenticated")

View File

@ -0,0 +1,349 @@
"""Sync and async HTTP clients for the Stack Auth API.
Provides BaseAPIClient[T], SyncAPIClient, and AsyncAPIClient implementing the
full request pipeline: header construction, URL building, response processing
with x-stack-actual-status, error dispatch via x-stack-known-error, retry with
exponential backoff for idempotent methods, and rate limit handling.
"""
from __future__ import annotations
import asyncio
import time
import uuid
from typing import Any, Generic, TypeVar
import httpx
from stack_auth._constants import API_VERSION, DEFAULT_BASE_URL, SDK_NAME
from stack_auth._version import __version__
from stack_auth.errors import RateLimitError, StackAuthError
HttpxClientT = TypeVar("HttpxClientT", httpx.Client, httpx.AsyncClient)
class BaseAPIClient(Generic[HttpxClientT]):
"""Generic base class shared by sync and async clients.
Handles header construction, URL building, response parsing, and retry
policy. Subclasses provide the concrete httpx transport.
"""
IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
MAX_RETRIES = 5
def __init__(
self,
*,
base_url: str = DEFAULT_BASE_URL,
project_id: str,
secret_server_key: str,
publishable_client_key: str | None = None,
) -> None:
"""Initialize the API client.
Args:
base_url: Stack Auth API base URL.
project_id: The Stack Auth project identifier.
secret_server_key: Server-side secret key for authentication.
publishable_client_key: Optional publishable client key.
"""
self._base_url = base_url.rstrip("/")
self._project_id = project_id
self._secret_server_key = secret_server_key
self._publishable_client_key = publishable_client_key
self._client: HttpxClientT | None = None
# ------------------------------------------------------------------
# Header / URL helpers
# ------------------------------------------------------------------
def _build_headers(self) -> dict[str, str]:
"""Build HTTP headers required for every Stack Auth API request."""
headers = {
"x-stack-project-id": self._project_id,
"x-stack-access-type": "server",
"x-stack-secret-server-key": self._secret_server_key,
"x-stack-client-version": f"{SDK_NAME}@{__version__}",
"x-stack-override-error-status": "true",
"x-stack-random-nonce": str(uuid.uuid4()),
}
if self._publishable_client_key is not None:
headers["x-stack-publishable-client-key"] = self._publishable_client_key
return headers
def _build_url(self, path: str) -> str:
"""Construct the full API URL for the given endpoint path."""
return f"{self._base_url}/api/{API_VERSION}{path}"
# ------------------------------------------------------------------
# Response processing
# ------------------------------------------------------------------
def _parse_response(self, response: httpx.Response) -> tuple[int, dict[str, Any] | None]:
"""Parse an httpx response according to the Stack Auth protocol.
Returns ``(actual_status, parsed_json)`` on success.
Raises the appropriate :class:`StackAuthError` subclass on failure.
"""
actual_status = self._get_actual_status(response)
# Known-error dispatch
known_error = response.headers.get("x-stack-known-error")
if known_error:
try:
parsed = response.json()
body = parsed if isinstance(parsed, dict) else {}
except Exception:
body = {}
raise StackAuthError.from_response(
code=known_error,
message=body.get("message", "Unknown error"),
details=body.get("details"),
)
# Success range
if 200 <= actual_status < 300:
if response.content:
return actual_status, response.json()
return actual_status, None
# Rate limit (429 that exhausted retries)
if actual_status == 429:
raise RateLimitError(code="RATE_LIMIT_EXCEEDED", message="Rate limit exceeded after retries")
# Unrecognised error
raise StackAuthError(code="HTTP_ERROR", message=f"HTTP {actual_status}")
# ------------------------------------------------------------------
# Retry helpers
# ------------------------------------------------------------------
@staticmethod
def _get_actual_status(response: httpx.Response) -> int:
"""Extract the real HTTP status, preferring x-stack-actual-status header.
Falls back to response.status_code if the header is absent or malformed.
"""
header = response.headers.get("x-stack-actual-status")
if header:
try:
return int(header)
except ValueError:
pass
return response.status_code
def _should_retry(self, method: str, attempt: int) -> bool:
"""Return True if the request method is idempotent and retries remain."""
return method.upper() in self.IDEMPOTENT_METHODS and attempt < self.MAX_RETRIES
@staticmethod
def _get_retry_delay(attempt: int, response: httpx.Response | None = None) -> float:
"""Calculate retry delay using Retry-After header or exponential backoff."""
if response is not None:
actual_status = BaseAPIClient._get_actual_status(response)
if actual_status == 429:
retry_after = response.headers.get("Retry-After")
if retry_after is not None:
try:
return float(retry_after)
except ValueError:
pass
return 1.0 * (2 ** attempt)
class SyncAPIClient(BaseAPIClient[httpx.Client]):
"""Synchronous HTTP client using :class:`httpx.Client`."""
def _get_client(self) -> httpx.Client:
"""Return the underlying httpx.Client, creating it lazily if needed."""
if self._client is None:
self._client = httpx.Client(timeout=httpx.Timeout(30.0))
return self._client
def request(
self,
method: str,
path: str,
*,
body: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""Send a synchronous HTTP request with retry and error handling.
Args:
method: HTTP method (GET, POST, PUT, PATCH, DELETE).
path: API endpoint path (appended to the base URL).
body: Optional JSON body for the request.
params: Optional query parameters.
Returns:
Parsed JSON response dict, or None for empty responses.
Raises:
StackAuthError: On known API errors or non-2xx responses.
"""
url = self._build_url(path)
headers = self._build_headers()
# POST/PUT/PATCH: always send a JSON body (default to {} if None)
method_upper = method.upper()
if method_upper in {"POST", "PUT", "PATCH"}:
json_body = body if body is not None else {}
else:
json_body = body # may be None → no body
last_exc: BaseException | None = None
for attempt in range(self.MAX_RETRIES + 1):
try:
resp = self._get_client().request(
method_upper,
url,
headers=headers,
json=json_body,
params=params,
)
# Check for 429 via x-stack-actual-status
actual_status = self._get_actual_status(resp)
# 429 retries apply to ALL methods (including POST/PATCH).
# Unlike network errors, a 429 guarantees the server did NOT
# process the request, so retrying is safe regardless of
# idempotency. This matches the SDK spec and the behavior of
# Stripe, Anthropic, and OpenAI SDKs.
if actual_status == 429 and attempt < self.MAX_RETRIES:
delay = self._get_retry_delay(attempt, resp)
time.sleep(delay)
continue
_status, data = self._parse_response(resp)
return data
except (httpx.HTTPError, httpx.TimeoutException) as exc:
last_exc = exc
if self._should_retry(method_upper, attempt):
delay = self._get_retry_delay(attempt, None)
time.sleep(delay)
continue
raise
# Exhausted retries
if last_exc is not None:
raise last_exc
return None # pragma: no cover
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def close(self) -> None:
"""Close the underlying HTTP client and release resources."""
if self._client is not None:
self._client.close()
self._client = None
def __enter__(self) -> SyncAPIClient:
"""Enter the context manager."""
return self
def __exit__(self, *_: Any) -> None:
"""Exit the context manager and close the client."""
self.close()
class AsyncAPIClient(BaseAPIClient[httpx.AsyncClient]):
"""Asynchronous HTTP client using :class:`httpx.AsyncClient`."""
def _get_client(self) -> httpx.AsyncClient:
"""Return the underlying httpx.AsyncClient, creating it lazily if needed."""
if self._client is None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0))
return self._client
async def request(
self,
method: str,
path: str,
*,
body: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""Send an asynchronous HTTP request with retry and error handling.
Args:
method: HTTP method (GET, POST, PUT, PATCH, DELETE).
path: API endpoint path (appended to the base URL).
body: Optional JSON body for the request.
params: Optional query parameters.
Returns:
Parsed JSON response dict, or None for empty responses.
Raises:
StackAuthError: On known API errors or non-2xx responses.
"""
url = self._build_url(path)
headers = self._build_headers()
method_upper = method.upper()
if method_upper in {"POST", "PUT", "PATCH"}:
json_body = body if body is not None else {}
else:
json_body = body
last_exc: BaseException | None = None
for attempt in range(self.MAX_RETRIES + 1):
try:
resp = await self._get_client().request(
method_upper,
url,
headers=headers,
json=json_body,
params=params,
)
actual_status = self._get_actual_status(resp)
# 429 retries apply to ALL methods (including POST/PATCH).
# Unlike network errors, a 429 guarantees the server did NOT
# process the request, so retrying is safe regardless of
# idempotency. This matches the SDK spec and the behavior of
# Stripe, Anthropic, and OpenAI SDKs.
if actual_status == 429 and attempt < self.MAX_RETRIES:
delay = self._get_retry_delay(attempt, resp)
await asyncio.sleep(delay)
continue
_status, data = self._parse_response(resp)
return data
except (httpx.HTTPError, httpx.TimeoutException) as exc:
last_exc = exc
if self._should_retry(method_upper, attempt):
delay = self._get_retry_delay(attempt, None)
await asyncio.sleep(delay)
continue
raise
if last_exc is not None:
raise last_exc
return None # pragma: no cover
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def aclose(self) -> None:
"""Close the underlying async HTTP client and release resources."""
if self._client is not None:
await self._client.aclose()
self._client = None
async def __aenter__(self) -> AsyncAPIClient:
"""Enter the async context manager."""
return self
async def __aexit__(self, *_: Any) -> None:
"""Exit the async context manager and close the client."""
await self.aclose()

View File

@ -0,0 +1,3 @@
DEFAULT_BASE_URL = "https://api.stack-auth.com"
SDK_NAME = "python"
API_VERSION = "v1"

View File

@ -0,0 +1,254 @@
"""JWT verification and JWKS fetching for Stack Auth.
Provides async and sync JWKS fetchers with in-memory TTL caching,
plus RS256 JWT verification functions. Algorithm is hardcoded to RS256
to prevent CVE-2022-29217 style algorithm confusion attacks.
"""
from __future__ import annotations
import asyncio
import threading
import time
from typing import Any
import httpx
import jwt
from jwt.algorithms import RSAAlgorithm
ALLOWED_ALGORITHMS: list[str] = ["RS256"]
"""Hardcoded algorithm list. NEVER read algorithm from token header (CVE-2022-29217)."""
JWKS_CACHE_TTL: float = 300.0
"""Cache JWKS keys for 5 minutes before re-fetching."""
class AsyncJWKSFetcher:
"""Fetches JWKS from a remote endpoint with in-memory TTL caching (async).
Args:
jwks_url: The URL of the JWKS endpoint.
http_client: An ``httpx.AsyncClient`` instance for making HTTP requests.
"""
def __init__(self, jwks_url: str, http_client: httpx.AsyncClient) -> None:
"""Initialize the async JWKS fetcher.
Args:
jwks_url: The URL of the JWKS endpoint.
http_client: An httpx.AsyncClient for making HTTP requests.
"""
self._jwks_url = jwks_url
self._http_client = http_client
self._cache: dict[str, Any] | None = None
self._cache_time: float = 0.0
self._fetch_lock = asyncio.Lock()
async def get_signing_key(self, kid: str) -> Any:
"""Return the RSA public key for the given key ID.
If the key is not in the current JWKS, one forced refresh is attempted.
Raises ``ValueError`` if the key is still not found after refresh.
"""
jwks = await self._fetch_jwks()
key_data = _find_key(jwks, kid)
if key_data is None:
# Force-refresh once for potential key rotation
jwks = await self._fetch_jwks(force=True)
key_data = _find_key(jwks, kid)
if key_data is None:
raise ValueError(f"Signing key '{kid}' not found in JWKS")
return RSAAlgorithm.from_jwk(key_data)
async def _fetch_jwks(self, force: bool = False) -> dict[str, Any]:
"""Fetch JWKS from the endpoint, using cache if fresh.
Uses an asyncio.Lock to deduplicate concurrent fetches so only one
HTTP request is made when multiple coroutines hit a cold or expired cache.
"""
async with self._fetch_lock:
now = time.monotonic()
if not force and self._cache is not None and (now - self._cache_time) < JWKS_CACHE_TTL:
return self._cache
response = await self._http_client.get(self._jwks_url)
response.raise_for_status()
self._cache = response.json()
self._cache_time = time.monotonic()
return self._cache # type: ignore[return-value]
class SyncJWKSFetcher:
"""Fetches JWKS from a remote endpoint with in-memory TTL caching (sync).
Args:
jwks_url: The URL of the JWKS endpoint.
http_client: An ``httpx.Client`` instance for making HTTP requests.
"""
def __init__(self, jwks_url: str, http_client: httpx.Client) -> None:
"""Initialize the sync JWKS fetcher.
Args:
jwks_url: The URL of the JWKS endpoint.
http_client: An httpx.Client for making HTTP requests.
"""
self._jwks_url = jwks_url
self._http_client = http_client
self._cache: dict[str, Any] | None = None
self._cache_time: float = 0.0
self._fetch_lock = threading.Lock()
def get_signing_key(self, kid: str) -> Any:
"""Return the RSA public key for the given key ID.
If the key is not in the current JWKS, one forced refresh is attempted.
Raises ``ValueError`` if the key is still not found after refresh.
"""
jwks = self._fetch_jwks()
key_data = _find_key(jwks, kid)
if key_data is None:
jwks = self._fetch_jwks(force=True)
key_data = _find_key(jwks, kid)
if key_data is None:
raise ValueError(f"Signing key '{kid}' not found in JWKS")
return RSAAlgorithm.from_jwk(key_data)
def _fetch_jwks(self, force: bool = False) -> dict[str, Any]:
"""Fetch JWKS from the endpoint, using cache if fresh.
Uses a threading.Lock to deduplicate concurrent fetches so only one
HTTP request is made when multiple threads hit a cold or expired cache.
"""
with self._fetch_lock:
now = time.monotonic()
if not force and self._cache is not None and (now - self._cache_time) < JWKS_CACHE_TTL:
return self._cache
response = self._http_client.get(self._jwks_url)
response.raise_for_status()
self._cache = response.json()
self._cache_time = time.monotonic()
return self._cache # type: ignore[return-value]
def _find_key(jwks: dict[str, Any], kid: str) -> dict[str, Any] | None:
"""Find a key by kid in a JWKS key set."""
for key in jwks.get("keys", []):
if key.get("kid") == kid:
return key # type: ignore[no-any-return]
return None
async def async_verify_token(
token: str,
fetcher: AsyncJWKSFetcher,
*,
audience: str | None = None,
issuer: str | None = None,
) -> dict[str, Any]:
"""Verify an RS256 JWT and return decoded claims.
Algorithm is hardcoded to RS256 -- the ``alg`` field in the token header
is never trusted (CVE-2022-29217 protection).
Args:
token: The encoded JWT string.
fetcher: An ``AsyncJWKSFetcher`` to retrieve signing keys.
audience: Optional expected audience claim.
issuer: Optional expected issuer claim.
Returns:
Decoded claims dictionary.
Raises:
ValueError: If the JWT header is missing a ``kid`` claim.
jwt.ExpiredSignatureError: If the token has expired.
jwt.InvalidSignatureError: If signature verification fails.
jwt.InvalidAlgorithmError: If the token uses a non-RS256 algorithm.
"""
unverified_header = jwt.get_unverified_header(token)
kid = unverified_header.get("kid")
if kid is None:
raise ValueError("JWT header missing 'kid' claim")
key = await fetcher.get_signing_key(kid)
kwargs: dict[str, Any] = {}
options: dict[str, bool] = {"verify_exp": True}
if audience is not None:
kwargs["audience"] = audience
else:
options["verify_aud"] = False
if issuer is not None:
kwargs["issuer"] = issuer
else:
options["verify_iss"] = False
return jwt.decode( # type: ignore[no-any-return]
token,
key,
algorithms=ALLOWED_ALGORITHMS,
options=options,
**kwargs,
)
def sync_verify_token(
token: str,
fetcher: SyncJWKSFetcher,
*,
audience: str | None = None,
issuer: str | None = None,
) -> dict[str, Any]:
"""Verify an RS256 JWT and return decoded claims (synchronous version).
Algorithm is hardcoded to RS256 -- the ``alg`` field in the token header
is never trusted (CVE-2022-29217 protection).
Args:
token: The encoded JWT string.
fetcher: A ``SyncJWKSFetcher`` to retrieve signing keys.
audience: Optional expected audience claim.
issuer: Optional expected issuer claim.
Returns:
Decoded claims dictionary.
Raises:
ValueError: If the JWT header is missing a ``kid`` claim.
jwt.ExpiredSignatureError: If the token has expired.
jwt.InvalidSignatureError: If signature verification fails.
jwt.InvalidAlgorithmError: If the token uses a non-RS256 algorithm.
"""
unverified_header = jwt.get_unverified_header(token)
kid = unverified_header.get("kid")
if kid is None:
raise ValueError("JWT header missing 'kid' claim")
key = fetcher.get_signing_key(kid)
kwargs: dict[str, Any] = {}
options: dict[str, bool] = {"verify_exp": True}
if audience is not None:
kwargs["audience"] = audience
else:
options["verify_aud"] = False
if issuer is not None:
kwargs["issuer"] = issuer
else:
options["verify_iss"] = False
return jwt.decode( # type: ignore[no-any-return]
token,
key,
algorithms=ALLOWED_ALGORITHMS,
options=options,
**kwargs,
)

View File

@ -0,0 +1,38 @@
"""Cursor-based pagination wrapper for Stack Auth list API responses."""
from __future__ import annotations
from typing import Generic, TypeVar
from pydantic import BaseModel, Field
T = TypeVar("T")
class _PaginationMeta(BaseModel):
"""Nested pagination metadata from API response."""
next_cursor: str | None = None
class PaginatedResult(BaseModel, Generic[T]):
"""Cursor-based pagination wrapper for list API responses.
Matches the Stack Auth API response shape:
{ "items": [...], "pagination": { "next_cursor": "..." } }
Exposes .next_cursor and .has_next_page as top-level convenience properties.
"""
items: list[T]
pagination: _PaginationMeta = Field(default_factory=_PaginationMeta)
@property
def next_cursor(self) -> str | None:
"""The cursor for fetching the next page, or None if no more pages."""
return self.pagination.next_cursor
@property
def has_next_page(self) -> bool:
"""Whether there are more pages available."""
return self.pagination.next_cursor is not None

View File

@ -0,0 +1,441 @@
"""Token store subsystem: ABC, concrete implementations, registry, and CAS refresh algorithm.
This module implements the token store interface from the SDK spec (_utilities.spec.md):
- TokenStore ABC with three abstract methods
- MemoryTokenStore, ExplicitTokenStore, RequestTokenStore concrete implementations
- Module-level registry for shared MemoryTokenStore instances per project_id
- CAS-based get_or_fetch_likely_valid_tokens algorithm with dual locks
- Token refresh helper using OAuth2 form-encoded endpoint
"""
from __future__ import annotations
import asyncio
import base64
import json
import threading
import time
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
import logging
import httpx
from stack_auth._types import RequestLike
logger = logging.getLogger("stack_auth")
# ---------------------------------------------------------------------------
# TokenStore ABC
# ---------------------------------------------------------------------------
class TokenStore(ABC):
"""Abstract base class for token storage backends.
Each instance carries its own sync and async locks for CAS refresh.
"""
def __init__(self) -> None:
"""Initialize the token store with sync and async CAS locks."""
self._sync_lock = threading.Lock()
self._async_lock = asyncio.Lock()
@abstractmethod
def get_stored_access_token(self) -> str | None:
"""Return the stored access token, or None if not set."""
...
@abstractmethod
def get_stored_refresh_token(self) -> str | None:
"""Return the stored refresh token, or None if not set."""
...
@abstractmethod
def compare_and_set(
self,
compare_refresh_token: str | None,
new_refresh_token: str | None,
new_access_token: str | None,
) -> None:
"""Atomically update tokens if the current refresh token matches compare_refresh_token."""
...
# ---------------------------------------------------------------------------
# MemoryTokenStore
# ---------------------------------------------------------------------------
class MemoryTokenStore(TokenStore):
"""In-memory token store. Shared per project_id via the module registry."""
def __init__(self) -> None:
"""Initialize with empty access and refresh tokens."""
super().__init__()
self._access_token: str | None = None
self._refresh_token: str | None = None
def get_stored_access_token(self) -> str | None:
"""Return the stored access token, or None if not set."""
return self._access_token
def get_stored_refresh_token(self) -> str | None:
"""Return the stored refresh token, or None if not set."""
return self._refresh_token
def compare_and_set(
self,
compare_refresh_token: str | None,
new_refresh_token: str | None,
new_access_token: str | None,
) -> None:
"""Atomically update tokens if the current refresh token matches."""
if self._refresh_token == compare_refresh_token:
self._refresh_token = new_refresh_token
self._access_token = new_access_token
# ---------------------------------------------------------------------------
# ExplicitTokenStore
# ---------------------------------------------------------------------------
class ExplicitTokenStore(TokenStore):
"""Token store initialized from explicit access_token and refresh_token values.
Supports CAS update to in-memory state to prevent infinite refresh loops.
"""
def __init__(self, access_token: str | None = None, refresh_token: str | None = None) -> None:
"""Initialize with explicit token values.
Args:
access_token: Initial access token, or None.
refresh_token: Initial refresh token, or None.
"""
super().__init__()
self._access_token: str | None = access_token
self._refresh_token: str | None = refresh_token
def get_stored_access_token(self) -> str | None:
"""Return the stored access token, or None if not set."""
return self._access_token
def get_stored_refresh_token(self) -> str | None:
"""Return the stored refresh token, or None if not set."""
return self._refresh_token
def compare_and_set(
self,
compare_refresh_token: str | None,
new_refresh_token: str | None,
new_access_token: str | None,
) -> None:
"""Atomically update tokens if the current refresh token matches."""
if self._refresh_token == compare_refresh_token:
self._refresh_token = new_refresh_token
self._access_token = new_access_token
# ---------------------------------------------------------------------------
# RequestTokenStore
# ---------------------------------------------------------------------------
class RequestTokenStore(TokenStore):
"""Token store that extracts tokens from a request's x-stack-auth JSON header.
Supports CAS update to in-memory state for refreshed tokens.
"""
def __init__(self, request: RequestLike) -> None:
"""Initialize by extracting tokens from the request's x-stack-auth header.
Args:
request: A request-like object whose headers may contain x-stack-auth.
"""
super().__init__()
self._access_token: str | None = None
self._refresh_token: str | None = None
header_value = request.headers.get("x-stack-auth")
if header_value:
try:
data = json.loads(header_value)
self._access_token = data.get("accessToken")
self._refresh_token = data.get("refreshToken")
except (json.JSONDecodeError, AttributeError):
pass
def get_stored_access_token(self) -> str | None:
"""Return the stored access token, or None if not set."""
return self._access_token
def get_stored_refresh_token(self) -> str | None:
"""Return the stored refresh token, or None if not set."""
return self._refresh_token
def compare_and_set(
self,
compare_refresh_token: str | None,
new_refresh_token: str | None,
new_access_token: str | None,
) -> None:
"""Atomically update tokens if the current refresh token matches."""
if self._refresh_token == compare_refresh_token:
self._refresh_token = new_refresh_token
self._access_token = new_access_token
# ---------------------------------------------------------------------------
# Registry: shared MemoryTokenStore per project_id
# ---------------------------------------------------------------------------
_token_store_registry: dict[str, MemoryTokenStore] = {}
"""Module-level registry mapping project_id to shared MemoryTokenStore instances.
Per SDK spec (Token Store Registry): all uses of "memory" with the same project_id
must share the same underlying token store instance and refresh lock. This dict
ensures that invariant.
To reset in tests: ``stack_auth._token_store._token_store_registry.clear()``
"""
_registry_lock = threading.Lock()
"""Guards _token_store_registry against concurrent check-then-set races."""
def _get_or_create_memory_store(project_id: str) -> MemoryTokenStore:
"""Return the shared MemoryTokenStore for a project, creating one if needed.
Thread-safe: uses _registry_lock to prevent two threads from creating
duplicate stores for the same project_id.
"""
with _registry_lock:
if project_id not in _token_store_registry:
_token_store_registry[project_id] = MemoryTokenStore()
return _token_store_registry[project_id]
# ---------------------------------------------------------------------------
# TokenStoreInit type and resolver
# ---------------------------------------------------------------------------
TokenStoreInit = Literal["memory"] | dict | RequestLike | None
def resolve_token_store(init: TokenStoreInit, project_id: str) -> TokenStore | None:
"""Resolve a TokenStoreInit value to a concrete TokenStore instance.
- "memory" -> shared MemoryTokenStore per project_id
- dict -> ExplicitTokenStore from access_token/refresh_token keys
- RequestLike -> RequestTokenStore from x-stack-auth header
- None -> None
"""
if init is None:
return None
if init == "memory":
return _get_or_create_memory_store(project_id)
if isinstance(init, dict):
return ExplicitTokenStore(
access_token=init.get("access_token"),
refresh_token=init.get("refresh_token"),
)
if isinstance(init, RequestLike):
return RequestTokenStore(init)
raise TypeError(f"Invalid token store initializer: {type(init)}")
# ---------------------------------------------------------------------------
# JWT helpers (no verification)
# ---------------------------------------------------------------------------
def _decode_jwt_payload(token: str) -> dict[str, Any] | None:
"""Base64url decode the JWT payload segment without signature verification.
Returns the claims dict, or None if the token is malformed.
"""
try:
parts = token.split(".")
if len(parts) < 2:
return None
payload_b64 = parts[1]
# Add padding
payload_b64 += "=" * (-len(payload_b64) % 4)
payload_bytes = base64.urlsafe_b64decode(payload_b64)
return json.loads(payload_bytes) # type: ignore[no-any-return]
except (ValueError, json.JSONDecodeError):
return None
def _is_expired(token: str) -> bool:
"""Check if the JWT's exp claim is in the past."""
claims = _decode_jwt_payload(token)
if claims is None or "exp" not in claims:
return True
return claims["exp"] <= time.time()
def _is_fresh_enough(token: str | None) -> bool:
"""Check if token expires in >20s AND was issued <75s ago.
Returns False for None tokens or tokens with missing exp/iat claims.
All calculations in seconds (not milliseconds).
"""
if token is None:
return False
claims = _decode_jwt_payload(token)
if claims is None:
return False
exp = claims.get("exp")
iat = claims.get("iat")
if exp is None or iat is None:
return False
now = time.time()
return (exp - now) > 20 and (now - iat) < 75
# ---------------------------------------------------------------------------
# CAS refresh algorithm
# ---------------------------------------------------------------------------
def get_or_fetch_likely_valid_tokens_sync(
store: TokenStore,
refresh_fn: Callable[[str], tuple[bool, str | None]],
) -> tuple[str | None, str | None]:
"""CAS-based token refresh (sync version).
Returns (refresh_token, access_token) tuple.
Algorithm per SDK spec:
1. If no refresh_token: return (None, access_token) if not expired, else (None, None)
2. If access_token is fresh enough: return (refresh, access)
3. Otherwise: call refresh_fn, CAS-update store
"""
with store._sync_lock:
original_refresh = store.get_stored_refresh_token()
original_access = store.get_stored_access_token()
if original_refresh is None:
if original_access is not None and not _is_expired(original_access):
return (None, original_access)
return (None, None)
if _is_fresh_enough(original_access):
return (original_refresh, original_access)
# Need to refresh
was_valid, new_access = refresh_fn(original_refresh)
if was_valid and new_access is not None:
store.compare_and_set(original_refresh, original_refresh, new_access)
return (original_refresh, new_access)
else:
store.compare_and_set(original_refresh, None, None)
return (None, None)
async def get_or_fetch_likely_valid_tokens_async(
store: TokenStore,
refresh_fn: Callable[[str], Awaitable[tuple[bool, str | None]]],
) -> tuple[str | None, str | None]:
"""CAS-based token refresh (async version).
Returns (refresh_token, access_token) tuple. Same algorithm as sync variant.
"""
async with store._async_lock:
original_refresh = store.get_stored_refresh_token()
original_access = store.get_stored_access_token()
if original_refresh is None:
if original_access is not None and not _is_expired(original_access):
return (None, original_access)
return (None, None)
if _is_fresh_enough(original_access):
return (original_refresh, original_access)
# Need to refresh
was_valid, new_access = await refresh_fn(original_refresh)
if was_valid and new_access is not None:
store.compare_and_set(original_refresh, original_refresh, new_access)
return (original_refresh, new_access)
else:
store.compare_and_set(original_refresh, None, None)
return (None, None)
# ---------------------------------------------------------------------------
# Token refresh helpers (sync + async)
# ---------------------------------------------------------------------------
def _refresh_access_token_sync(
http_client: httpx.Client,
base_url: str,
project_id: str,
refresh_token: str,
) -> tuple[bool, str | None]:
"""Refresh an access token via the OAuth2 token endpoint (sync).
POST form-encoded body to {base_url}/api/v1/auth/oauth/token.
Returns (was_valid, new_access_token).
"""
url = f"{base_url}/api/v1/auth/oauth/token"
try:
resp = http_client.post(
url,
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": project_id,
"client_secret": "__stack_public_client__",
},
)
if resp.status_code == 200:
data = resp.json()
if not isinstance(data, dict):
return (False, None)
access_token = data.get("access_token")
if not access_token:
return (False, None)
return (True, access_token)
return (False, None)
except (httpx.HTTPError, ValueError, KeyError) as exc:
logger.debug("Token refresh failed (sync)", exc_info=exc)
return (False, None)
async def _refresh_access_token_async(
http_client: httpx.AsyncClient,
base_url: str,
project_id: str,
refresh_token: str,
) -> tuple[bool, str | None]:
"""Refresh an access token via the OAuth2 token endpoint (async).
POST form-encoded body to {base_url}/api/v1/auth/oauth/token.
Returns (was_valid, new_access_token).
"""
url = f"{base_url}/api/v1/auth/oauth/token"
try:
resp = await http_client.post(
url,
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": project_id,
"client_secret": "__stack_public_client__",
},
)
if resp.status_code == 200:
data = resp.json()
if not isinstance(data, dict):
return (False, None)
access_token = data.get("access_token")
if not access_token:
return (False, None)
return (True, access_token)
return (False, None)
except (httpx.HTTPError, ValueError, KeyError) as exc:
logger.debug("Token refresh failed (async)", exc_info=exc)
return (False, None)

View File

@ -0,0 +1,13 @@
from __future__ import annotations
from typing import Mapping, Protocol, runtime_checkable
@runtime_checkable
class RequestLike(Protocol):
"""Protocol for objects that expose HTTP headers (e.g. Django/Flask/Starlette requests)."""
@property
def headers(self) -> Mapping[str, str]:
"""Return the request headers as a string mapping."""
...

View File

@ -0,0 +1 @@
__version__ = "0.1.0"

View File

@ -0,0 +1,259 @@
"""Stack Auth error hierarchy with factory dispatch for all known error codes."""
from __future__ import annotations
from typing import Any
class StackAuthError(Exception):
"""Base error for all Stack Auth SDK errors.
Attributes:
code: The error code string from the Stack Auth API.
message: Human-readable error description.
details: Optional dictionary with additional error context.
"""
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
"""Initialize a StackAuthError.
Args:
code: The error code string from the Stack Auth API.
message: Human-readable error description.
details: Optional dictionary with additional error context.
"""
self.code = code
self.message = message
self.details = details
super().__init__(f"[{code}] {message}")
@classmethod
def from_response(
cls,
code: str,
message: str,
details: dict[str, Any] | None = None,
) -> StackAuthError:
"""Create the appropriate error subclass for a given error code.
Known error codes are dispatched to their category subclass.
Unknown codes produce a base StackAuthError.
"""
error_cls = _ERROR_CODE_MAP.get(code, StackAuthError)
return error_cls(code, message, details)
class AuthenticationError(StackAuthError):
"""Authentication or authorization failure."""
class NotFoundError(StackAuthError):
"""Requested resource was not found."""
class ValidationError(StackAuthError):
"""Request validation or input error."""
class PermissionDeniedError(StackAuthError):
"""Insufficient permissions for the requested operation."""
class ConflictError(StackAuthError):
"""Resource conflict (duplicate, already exists, etc.)."""
class OAuthError(StackAuthError):
"""OAuth-specific error."""
class PasskeyError(StackAuthError):
"""Passkey/WebAuthn-specific error."""
class ApiKeyError(StackAuthError):
"""API key-related error."""
class PaymentError(StackAuthError):
"""Payment or billing error."""
class EmailError(StackAuthError):
"""Email-specific error."""
class RateLimitError(StackAuthError):
"""Rate limit exceeded."""
class CliError(StackAuthError):
"""CLI-specific error."""
class AnalyticsError(StackAuthError):
"""Analytics-specific error."""
# Complete mapping of all ~100 error codes from known-errors.tsx to category classes.
_ERROR_CODE_MAP: dict[str, type[StackAuthError]] = {
# AuthenticationError - project authentication
"PROJECT_AUTHENTICATION_ERROR": AuthenticationError,
"INVALID_PROJECT_AUTHENTICATION": AuthenticationError,
"PROJECT_KEY_WITHOUT_ACCESS_TYPE": AuthenticationError,
"INVALID_ACCESS_TYPE": AuthenticationError,
"ACCESS_TYPE_WITHOUT_PROJECT_ID": AuthenticationError,
"ACCESS_TYPE_REQUIRED": AuthenticationError,
"INSUFFICIENT_ACCESS_TYPE": AuthenticationError,
"INVALID_PUBLISHABLE_CLIENT_KEY": AuthenticationError,
"INVALID_SECRET_SERVER_KEY": AuthenticationError,
"INVALID_SUPER_SECRET_ADMIN_KEY": AuthenticationError,
"INVALID_ADMIN_ACCESS_TOKEN": AuthenticationError,
"UNPARSABLE_ADMIN_ACCESS_TOKEN": AuthenticationError,
"ADMIN_ACCESS_TOKEN_EXPIRED": AuthenticationError,
"INVALID_PROJECT_FOR_ADMIN_ACCESS_TOKEN": AuthenticationError,
"ADMIN_ACCESS_TOKEN_IS_NOT_ADMIN": AuthenticationError,
"PROJECT_AUTHENTICATION_REQUIRED": AuthenticationError,
"CLIENT_AUTHENTICATION_REQUIRED": AuthenticationError,
"PUBLISHABLE_CLIENT_KEY_REQUIRED_FOR_PROJECT": AuthenticationError,
"SERVER_AUTHENTICATION_REQUIRED": AuthenticationError,
"CLIENT_OR_SERVER_AUTHENTICATION_REQUIRED": AuthenticationError,
"CLIENT_OR_ADMIN_AUTHENTICATION_REQUIRED": AuthenticationError,
"CLIENT_OR_SERVER_OR_ADMIN_AUTHENTICATION_REQUIRED": AuthenticationError,
"ADMIN_AUTHENTICATION_REQUIRED": AuthenticationError,
"EXPECTED_INTERNAL_PROJECT": AuthenticationError,
# AuthenticationError - session authentication
"SESSION_AUTHENTICATION_ERROR": AuthenticationError,
"INVALID_SESSION_AUTHENTICATION": AuthenticationError,
"INVALID_ACCESS_TOKEN": AuthenticationError,
"UNPARSABLE_ACCESS_TOKEN": AuthenticationError,
"ACCESS_TOKEN_EXPIRED": AuthenticationError,
"INVALID_PROJECT_FOR_ACCESS_TOKEN": AuthenticationError,
# AuthenticationError - refresh token
"REFRESH_TOKEN_ERROR": AuthenticationError,
"REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED": AuthenticationError,
"CANNOT_DELETE_CURRENT_SESSION": AuthenticationError,
"PROVIDER_REJECTED": AuthenticationError,
# AuthenticationError - user authentication
"USER_AUTHENTICATION_REQUIRED": AuthenticationError,
"CANNOT_GET_OWN_USER_WITHOUT_USER": AuthenticationError,
# AuthenticationError - multi-factor
"MULTI_FACTOR_AUTHENTICATION_REQUIRED": AuthenticationError,
"INVALID_TOTP_CODE": AuthenticationError,
# NotFoundError
"USER_NOT_FOUND": NotFoundError,
"USER_ID_DOES_NOT_EXIST": NotFoundError,
"TEAM_NOT_FOUND": NotFoundError,
"PROJECT_NOT_FOUND": NotFoundError,
"CURRENT_PROJECT_NOT_FOUND": NotFoundError,
"BRANCH_DOES_NOT_EXIST": NotFoundError,
"PERMISSION_NOT_FOUND": NotFoundError,
"TEAM_PERMISSION_NOT_FOUND": NotFoundError,
"API_KEY_NOT_FOUND": NotFoundError,
"ITEM_NOT_FOUND": NotFoundError,
"CUSTOMER_DOES_NOT_EXIST": NotFoundError,
"PRODUCT_DOES_NOT_EXIST": NotFoundError,
"DATA_VAULT_STORE_DOES_NOT_EXIST": NotFoundError,
"DATA_VAULT_STORE_HASHED_KEY_DOES_NOT_EXIST": NotFoundError,
"SUBSCRIPTION_INVOICE_NOT_FOUND": NotFoundError,
"ONE_TIME_PURCHASE_NOT_FOUND": NotFoundError,
"STRIPE_ACCOUNT_INFO_NOT_FOUND": NotFoundError,
# ValidationError
"SCHEMA_ERROR": ValidationError,
"BODY_PARSING_ERROR": ValidationError,
"ALL_OVERLOADS_FAILED": ValidationError,
"UNSUPPORTED_ERROR": ValidationError,
"EMAIL_PASSWORD_MISMATCH": ValidationError,
"PASSWORD_REQUIREMENTS_NOT_MET": ValidationError,
"PASSWORD_TOO_SHORT": ValidationError,
"PASSWORD_TOO_LONG": ValidationError,
"PASSWORD_CONFIRMATION_MISMATCH": ValidationError,
"USER_DOES_NOT_HAVE_PASSWORD": ValidationError,
"USER_EMAIL_ALREADY_EXISTS": ValidationError,
"EMAIL_NOT_VERIFIED": ValidationError,
"EMAIL_ALREADY_VERIFIED": ValidationError,
"EMAIL_NOT_ASSOCIATED_WITH_USER": ValidationError,
"EMAIL_IS_NOT_PRIMARY_EMAIL": ValidationError,
"RESTRICTED_USER_NOT_ALLOWED": ValidationError,
"TEAM_INVITATION_RESTRICTED_USER_NOT_ALLOWED": ValidationError,
"SIGN_UP_NOT_ENABLED": ValidationError,
"SIGN_UP_REJECTED": ValidationError,
"BOT_CHALLENGE_REQUIRED": ValidationError,
"BOT_CHALLENGE_FAILED": ValidationError,
"PASSWORD_AUTHENTICATION_NOT_ENABLED": ValidationError,
"PASSKEY_AUTHENTICATION_NOT_ENABLED": ValidationError,
"ANONYMOUS_ACCOUNTS_NOT_ENABLED": ValidationError,
"ANONYMOUS_AUTHENTICATION_NOT_ALLOWED": ValidationError,
"REDIRECT_URL_NOT_WHITELISTED": ValidationError,
"VERIFICATION_ERROR": ValidationError,
"VERIFICATION_CODE_NOT_FOUND": ValidationError,
"VERIFICATION_CODE_EXPIRED": ValidationError,
"VERIFICATION_CODE_ALREADY_USED": ValidationError,
"VERIFICATION_CODE_MAX_ATTEMPTS_REACHED": ValidationError,
# PermissionDeniedError
"PROJECT_PERMISSION_REQUIRED": PermissionDeniedError,
"TEAM_PERMISSION_REQUIRED": PermissionDeniedError,
"WRONG_PERMISSION_SCOPE": PermissionDeniedError,
"CONTAINED_PERMISSION_NOT_FOUND": PermissionDeniedError,
"PERMISSION_ID_ALREADY_EXISTS": PermissionDeniedError,
# ConflictError
"TEAM_ALREADY_EXISTS": ConflictError,
"TEAM_MEMBERSHIP_ALREADY_EXISTS": ConflictError,
"TEAM_MEMBERSHIP_NOT_FOUND": NotFoundError,
"EMAIL_TEMPLATE_ALREADY_EXISTS": ConflictError,
"CONTACT_CHANNEL_ALREADY_USED_FOR_AUTH_BY_SOMEONE_ELSE": ConflictError,
# OAuthError
"OAUTH_CONNECTION_NOT_CONNECTED_TO_USER": OAuthError,
"OAUTH_CONNECTION_ALREADY_CONNECTED_TO_ANOTHER_USER": OAuthError,
"OAUTH_CONNECTION_DOES_NOT_HAVE_REQUIRED_SCOPE": OAuthError,
"OAUTH_ACCESS_TOKEN_NOT_AVAILABLE": OAuthError,
"OAUTH_EXTRA_SCOPE_NOT_AVAILABLE_WITH_SHARED_OAUTH_KEYS": OAuthError,
"OAUTH_ACCESS_TOKEN_NOT_AVAILABLE_WITH_SHARED_OAUTH_KEYS": OAuthError,
"INVALID_OAUTH_CLIENT_ID_OR_SECRET": OAuthError,
"INVALID_SCOPE": OAuthError,
"USER_ALREADY_CONNECTED_TO_ANOTHER_OAUTH_CONNECTION": OAuthError,
"OUTER_OAUTH_TIMEOUT": OAuthError,
"OAUTH_PROVIDER_NOT_FOUND_OR_NOT_ENABLED": OAuthError,
"APPLE_BUNDLE_ID_NOT_CONFIGURED": OAuthError,
"OAUTH_PROVIDER_ACCOUNT_ID_ALREADY_USED_FOR_SIGN_IN": OAuthError,
"OAUTH_PROVIDER_ACCESS_DENIED": OAuthError,
"INVALID_SHARED_OAUTH_PROVIDER_ID": OAuthError,
"INVALID_STANDARD_OAUTH_PROVIDER_ID": OAuthError,
"INVALID_AUTHORIZATION_CODE": OAuthError,
"INVALID_APPLE_CREDENTIALS": OAuthError,
# PasskeyError
"PASSKEY_REGISTRATION_FAILED": PasskeyError,
"PASSKEY_WEBAUTHN_ERROR": PasskeyError,
"PASSKEY_AUTHENTICATION_FAILED": PasskeyError,
# ApiKeyError
"API_KEY_NOT_VALID": ApiKeyError,
"API_KEY_EXPIRED": ApiKeyError,
"API_KEY_REVOKED": ApiKeyError,
"WRONG_API_KEY_TYPE": ApiKeyError,
"PUBLIC_API_KEY_CANNOT_BE_REVOKED": ApiKeyError,
# PaymentError
"ITEM_CUSTOMER_TYPE_DOES_NOT_MATCH": PaymentError,
"PRODUCT_CUSTOMER_TYPE_DOES_NOT_MATCH": PaymentError,
"PRODUCT_ALREADY_GRANTED": PaymentError,
"ITEM_QUANTITY_INSUFFICIENT_AMOUNT": PaymentError,
"SUBSCRIPTION_ALREADY_REFUNDED": PaymentError,
"ONE_TIME_PURCHASE_ALREADY_REFUNDED": PaymentError,
"TEST_MODE_PURCHASE_NON_REFUNDABLE": PaymentError,
"DEFAULT_PAYMENT_METHOD_REQUIRED": PaymentError,
"NEW_PURCHASES_BLOCKED": PaymentError,
# EmailError
"EMAIL_RENDERING_ERROR": EmailError,
"TEMPLATE_SOURCE_REWRITE_ERROR": EmailError,
"REQUIRES_CUSTOM_EMAIL_SERVER": EmailError,
"EMAIL_CAPACITY_BOOST_ALREADY_ACTIVE": EmailError,
"EMAIL_NOT_EDITABLE": EmailError,
# CliError
"INVALID_POLLING_CODE": CliError,
"CLI_AUTH_ERROR": CliError,
"CLI_AUTH_EXPIRED_ERROR": CliError,
"CLI_AUTH_USED_ERROR": CliError,
# AnalyticsError
"ANALYTICS_QUERY_TIMEOUT": AnalyticsError,
"ANALYTICS_QUERY_ERROR": AnalyticsError,
"ANALYTICS_NOT_ENABLED": AnalyticsError,
}

View File

@ -0,0 +1,52 @@
"""Stack Auth data models -- re-exports all model classes."""
from stack_auth.models.data_vault import AsyncDataVaultStore, DataVaultStore
from stack_auth.models.api_keys import (
ApiKey,
TeamApiKey,
TeamApiKeyFirstView,
UserApiKey,
UserApiKeyFirstView,
)
from stack_auth.models.contact_channels import ContactChannel
from stack_auth.models.notifications import NotificationCategory
from stack_auth.models.oauth import OAuthConnection, OAuthProvider
from stack_auth.models.email import EmailDeliveryInfo
from stack_auth.models.payments import AsyncServerItem, Item, Product, ServerItem
from stack_auth.models.permissions import ProjectPermission, TeamPermission
from stack_auth.models.projects import OAuthProviderConfig, Project, ProjectConfig
from stack_auth.models.sessions import ActiveSession, GeoInfo
from stack_auth.models.teams import ServerTeam, Team, TeamInvitation, TeamMemberProfile
from stack_auth.models.users import BaseUser, ServerUser
__all__ = [
"DataVaultStore",
"AsyncDataVaultStore",
"BaseUser",
"ServerUser",
"Team",
"ServerTeam",
"TeamMemberProfile",
"TeamInvitation",
"ActiveSession",
"GeoInfo",
"ContactChannel",
"TeamPermission",
"ProjectPermission",
"Project",
"ProjectConfig",
"OAuthProviderConfig",
"ApiKey",
"UserApiKey",
"UserApiKeyFirstView",
"TeamApiKey",
"TeamApiKeyFirstView",
"OAuthConnection",
"OAuthProvider",
"Product",
"Item",
"ServerItem",
"AsyncServerItem",
"EmailDeliveryInfo",
"NotificationCategory",
]

View File

@ -0,0 +1,31 @@
"""Base model for all Stack Auth Pydantic models."""
from __future__ import annotations
from datetime import datetime, timezone
from pydantic import BaseModel, ConfigDict
class StackAuthModel(BaseModel):
"""Base model with shared configuration for all Stack Auth models.
Provides:
- populate_by_name: Allow both alias and field name in input
- extra="ignore": Silently drop unknown fields (forward-compat)
- from_attributes: Allow ORM-style attribute access
- _millis_to_datetime: Convert millisecond timestamps to datetime
"""
model_config = ConfigDict(
populate_by_name=True,
extra="ignore",
from_attributes=True,
)
@staticmethod
def _millis_to_datetime(v: int | None) -> datetime | None:
"""Convert a millisecond Unix timestamp to a UTC datetime, or None."""
if v is None:
return None
return datetime.fromtimestamp(v / 1000.0, tz=timezone.utc)

View File

@ -0,0 +1,54 @@
"""API key models for Stack Auth."""
from __future__ import annotations
from datetime import datetime
from pydantic import Field
from stack_auth.models._base import StackAuthModel
class ApiKey(StackAuthModel):
"""Base type for API keys."""
id: str
description: str = ""
expires_at_millis: int | None = Field(None, alias="expiresAtMillis")
created_at_millis: int = Field(alias="createdAtMillis")
is_valid: bool = Field(True, alias="isValid")
@property
def expires_at(self) -> datetime | None:
"""Convert expires_at_millis to a UTC datetime, or None."""
return self._millis_to_datetime(self.expires_at_millis)
@property
def created_at(self) -> datetime:
"""Convert created_at_millis to a UTC datetime."""
return self._millis_to_datetime(self.created_at_millis) # type: ignore[return-value]
class UserApiKey(ApiKey):
"""An API key owned by a user."""
user_id: str = Field(alias="userId")
team_id: str | None = Field(None, alias="teamId")
class UserApiKeyFirstView(UserApiKey):
"""Returned only when creating a new user API key. Contains the secret."""
api_key: str = Field(alias="apiKey")
class TeamApiKey(ApiKey):
"""An API key owned by a team."""
team_id: str = Field(alias="teamId")
class TeamApiKeyFirstView(TeamApiKey):
"""Returned only when creating a new team API key. Contains the secret."""
api_key: str = Field(alias="apiKey")

View File

@ -0,0 +1,18 @@
"""Contact channel models for Stack Auth."""
from __future__ import annotations
from pydantic import Field
from stack_auth.models._base import StackAuthModel
class ContactChannel(StackAuthModel):
"""A contact channel (email address) associated with a user."""
id: str
value: str
type: str = "email"
is_primary: bool = Field(False, alias="isPrimary")
is_verified: bool = Field(False, alias="isVerified")
used_for_auth: bool = Field(False, alias="usedForAuth")

View File

@ -0,0 +1,121 @@
"""Data vault store classes for server-side key-value storage."""
from __future__ import annotations
from typing import Any
from stack_auth.errors import NotFoundError
# Only suppress key-not-found errors; store-not-found should propagate.
_KEY_NOT_FOUND_CODE = "DATA_VAULT_STORE_HASHED_KEY_DOES_NOT_EXIST"
def _is_key_not_found(err: NotFoundError) -> bool:
"""Return True if the error is specifically a missing key, not a missing store."""
return getattr(err, "code", None) == _KEY_NOT_FOUND_CODE
class DataVaultStore:
"""Synchronous data vault store -- a server-side key-value store.
Obtained via ``StackServerApp.get_data_vault_store(store_id)``.
"""
def __init__(self, store_id: str, *, _client: Any) -> None:
"""Initialize a synchronous data vault store.
Args:
store_id: The data vault store identifier.
_client: The internal HTTP client used for API requests.
"""
self.id = store_id
self._client = _client
self._base_path = f"/data-vault/stores/{store_id}/items"
def get(self, key: str) -> str | None:
"""Get the value for a key, or ``None`` if not found."""
try:
data = self._client.request("GET", f"{self._base_path}/{key}")
except NotFoundError as err:
if _is_key_not_found(err):
return None
raise
if data is None:
return None
return data.get("value") if isinstance(data, dict) else data
def set(self, key: str, value: str) -> None:
"""Set or update the value for a key."""
self._client.request("PUT", f"{self._base_path}/{key}", body={"value": value})
def delete(self, key: str) -> None:
"""Delete a key-value pair. No error if key doesn't exist."""
try:
self._client.request("DELETE", f"{self._base_path}/{key}")
except NotFoundError as err:
if _is_key_not_found(err):
return
raise
def list_keys(self) -> list[str]:
"""Return all keys in the store."""
data = self._client.request("GET", self._base_path)
if data is None:
return []
# Response may be {"items": [...]} or a list directly
if isinstance(data, dict):
return data.get("items", [])
return data
class AsyncDataVaultStore:
"""Asynchronous data vault store -- a server-side key-value store.
Obtained via ``AsyncStackServerApp.get_data_vault_store(store_id)``.
"""
def __init__(self, store_id: str, *, _client: Any) -> None:
"""Initialize an asynchronous data vault store.
Args:
store_id: The data vault store identifier.
_client: The internal async HTTP client used for API requests.
"""
self.id = store_id
self._client = _client
self._base_path = f"/data-vault/stores/{store_id}/items"
async def get(self, key: str) -> str | None:
"""Get the value for a key, or ``None`` if not found."""
try:
data = await self._client.request("GET", f"{self._base_path}/{key}")
except NotFoundError as err:
if _is_key_not_found(err):
return None
raise
if data is None:
return None
return data.get("value") if isinstance(data, dict) else data
async def set(self, key: str, value: str) -> None:
"""Set or update the value for a key."""
await self._client.request("PUT", f"{self._base_path}/{key}", body={"value": value})
async def delete(self, key: str) -> None:
"""Delete a key-value pair. No error if key doesn't exist."""
try:
await self._client.request("DELETE", f"{self._base_path}/{key}")
except NotFoundError as err:
if _is_key_not_found(err):
return
raise
async def list_keys(self) -> list[str]:
"""Return all keys in the store."""
data = await self._client.request("GET", self._base_path)
if data is None:
return []
# Response may be {"items": [...]} or a list directly
if isinstance(data, dict):
return data.get("items", [])
return data

View File

@ -0,0 +1,14 @@
"""Email models for Stack Auth."""
from __future__ import annotations
from stack_auth.models._base import StackAuthModel
class EmailDeliveryInfo(StackAuthModel):
"""Email delivery statistics."""
delivered: int = 0
bounced: int = 0
complained: int = 0
total: int = 0

View File

@ -0,0 +1,17 @@
"""Notification models for Stack Auth."""
from __future__ import annotations
from pydantic import Field
from stack_auth.models._base import StackAuthModel
class NotificationCategory(StackAuthModel):
"""A category of notifications users can subscribe to or unsubscribe from."""
id: str
display_name: str = Field(alias="displayName")
description: str | None = None
is_subscribed_by_default: bool = Field(True, alias="isSubscribedByDefault")
is_user_subscribed: bool = Field(True, alias="isUserSubscribed")

View File

@ -0,0 +1,25 @@
"""OAuth models for Stack Auth."""
from __future__ import annotations
from pydantic import Field
from stack_auth.models._base import StackAuthModel
class OAuthConnection(StackAuthModel):
"""A connected OAuth account for accessing third-party APIs."""
id: str
class OAuthProvider(StackAuthModel):
"""An OAuth provider linked to a user's account."""
id: str
type: str
user_id: str = Field(alias="userId")
account_id: str | None = Field(None, alias="accountId")
email: str | None = None
allow_sign_in: bool = Field(True, alias="allowSignIn")
allow_connected_accounts: bool = Field(False, alias="allowConnectedAccounts")

View File

@ -0,0 +1,183 @@
"""Payment models for Stack Auth."""
from __future__ import annotations
from typing import Any
from pydantic import Field
from stack_auth.models._base import StackAuthModel
class Item(StackAuthModel):
"""A quantifiable item owned by a customer (user or team)."""
display_name: str = Field(alias="displayName")
quantity: int = 0
non_negative_quantity: int = Field(0, alias="nonNegativeQuantity")
class Product(StackAuthModel):
"""A product associated with a customer."""
id: str | None = None
quantity: int = 0
display_name: str = Field(alias="displayName")
customer_type: str = Field(alias="customerType")
is_server_only: bool = Field(False, alias="isServerOnly")
stackable: bool = False
type: str = "one_time"
class ServerItem:
"""Server-side item with methods to modify quantity.
Wraps an :class:`Item` and provides ``increase_quantity``,
``decrease_quantity``, and ``try_decrease_quantity`` methods that
communicate with the Stack Auth API via the HTTP client.
"""
def __init__(
self,
item: Item,
*,
_client: Any,
_customer_path: str,
_item_id: str,
_customer_id_field: str,
_customer_id_value: str,
) -> None:
"""Initialize a server-side item from an Item model.
Args:
item: The underlying Item data model.
_client: The internal HTTP client used for API requests.
_customer_path: API path prefix for the customer.
_item_id: The item identifier.
_customer_id_field: Body field name for the customer ID.
_customer_id_value: The customer ID value.
"""
self.display_name = item.display_name
self.quantity = item.quantity
self.non_negative_quantity = item.non_negative_quantity
self._client = _client
self._customer_path = _customer_path
self._item_id = _item_id
self._customer_id_field = _customer_id_field
self._customer_id_value = _customer_id_value
def _quantity_body(self, quantity: int) -> dict[str, Any]:
"""Build the request body for a quantity change operation."""
return {
self._customer_id_field: self._customer_id_value,
"item_id": self._item_id,
"quantity": quantity,
}
def increase_quantity(self, amount: int) -> None:
"""Increase this item's quantity by *amount*."""
self._client.request(
"POST",
"/internal/items/quantity-changes",
body=self._quantity_body(amount),
)
def decrease_quantity(self, amount: int) -> None:
"""Decrease this item's quantity by *amount*."""
self._client.request(
"POST",
"/internal/items/quantity-changes",
body=self._quantity_body(-amount),
)
def try_decrease_quantity(self, amount: int) -> bool:
"""Try to decrease this item's quantity by *amount*.
Returns ``True`` if the decrease succeeded, ``False`` otherwise.
"""
data = self._client.request(
"POST",
"/internal/items/try-decrease",
body={
self._customer_id_field: self._customer_id_value,
"item_id": self._item_id,
"amount": amount,
},
)
return bool(data.get("success", False)) if data else False
class AsyncServerItem:
"""Async server-side item with methods to modify quantity.
Async counterpart of :class:`ServerItem`.
"""
def __init__(
self,
item: Item,
*,
_client: Any,
_customer_path: str,
_item_id: str,
_customer_id_field: str,
_customer_id_value: str,
) -> None:
"""Initialize an async server-side item from an Item model.
Args:
item: The underlying Item data model.
_client: The internal async HTTP client used for API requests.
_customer_path: API path prefix for the customer.
_item_id: The item identifier.
_customer_id_field: Body field name for the customer ID.
_customer_id_value: The customer ID value.
"""
self.display_name = item.display_name
self.quantity = item.quantity
self.non_negative_quantity = item.non_negative_quantity
self._client = _client
self._customer_path = _customer_path
self._item_id = _item_id
self._customer_id_field = _customer_id_field
self._customer_id_value = _customer_id_value
def _quantity_body(self, quantity: int) -> dict[str, Any]:
"""Build the request body for a quantity change operation."""
return {
self._customer_id_field: self._customer_id_value,
"item_id": self._item_id,
"quantity": quantity,
}
async def increase_quantity(self, amount: int) -> None:
"""Increase this item's quantity by *amount*."""
await self._client.request(
"POST",
"/internal/items/quantity-changes",
body=self._quantity_body(amount),
)
async def decrease_quantity(self, amount: int) -> None:
"""Decrease this item's quantity by *amount*."""
await self._client.request(
"POST",
"/internal/items/quantity-changes",
body=self._quantity_body(-amount),
)
async def try_decrease_quantity(self, amount: int) -> bool:
"""Try to decrease this item's quantity by *amount*.
Returns ``True`` if the decrease succeeded, ``False`` otherwise.
"""
data = await self._client.request(
"POST",
"/internal/items/try-decrease",
body={
self._customer_id_field: self._customer_id_value,
"item_id": self._item_id,
"amount": amount,
},
)
return bool(data.get("success", False)) if data else False

View File

@ -0,0 +1,17 @@
"""Permission models for Stack Auth."""
from __future__ import annotations
from stack_auth.models._base import StackAuthModel
class TeamPermission(StackAuthModel):
"""A permission granted to a user within a team."""
id: str
class ProjectPermission(StackAuthModel):
"""A project-level permission granted to a user."""
id: str

View File

@ -0,0 +1,39 @@
"""Project models for Stack Auth."""
from __future__ import annotations
from typing import Any
from pydantic import Field
from stack_auth.models._base import StackAuthModel
class OAuthProviderConfig(StackAuthModel):
"""Configuration for an OAuth provider."""
id: str
class ProjectConfig(StackAuthModel):
"""Client-visible project configuration."""
sign_up_enabled: bool = Field(True, alias="signUpEnabled")
credential_enabled: bool = Field(True, alias="credentialEnabled")
magic_link_enabled: bool = Field(False, alias="magicLinkEnabled")
passkey_enabled: bool = Field(False, alias="passkeyEnabled")
oauth_providers: list[OAuthProviderConfig] = Field(
default_factory=list, alias="oauthProviders"
)
client_team_creation_enabled: bool = Field(False, alias="clientTeamCreationEnabled")
client_user_deletion_enabled: bool = Field(False, alias="clientUserDeletionEnabled")
allow_user_api_keys: bool = Field(False, alias="allowUserApiKeys")
allow_team_api_keys: bool = Field(False, alias="allowTeamApiKeys")
class Project(StackAuthModel):
"""Basic project information."""
id: str
display_name: str = Field(alias="displayName")
config: ProjectConfig

View File

@ -0,0 +1,42 @@
"""Session models for Stack Auth."""
from __future__ import annotations
from datetime import datetime
from pydantic import Field
from stack_auth.models._base import StackAuthModel
class GeoInfo(StackAuthModel):
"""Geographic information derived from IP address."""
city: str | None = None
region: str | None = None
country: str | None = None
country_name: str | None = Field(None, alias="countryName")
latitude: float | None = None
longitude: float | None = None
class ActiveSession(StackAuthModel):
"""Represents an active login session for a user."""
id: str
user_id: str = Field(alias="userId")
created_at_millis: int = Field(alias="createdAtMillis")
is_impersonation: bool = Field(False, alias="isImpersonation")
last_used_at_millis: int | None = Field(None, alias="lastUsedAtMillis")
is_current_session: bool = Field(False, alias="isCurrentSession")
geo_info: GeoInfo | None = Field(None, alias="geoInfo")
@property
def created_at(self) -> datetime:
"""Convert created_at_millis to a UTC datetime."""
return self._millis_to_datetime(self.created_at_millis) # type: ignore[return-value]
@property
def last_used_at(self) -> datetime | None:
"""Convert last_used_at_millis to a UTC datetime, or None."""
return self._millis_to_datetime(self.last_used_at_millis)

View File

@ -0,0 +1,55 @@
"""Team models for Stack Auth."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import Field
from stack_auth.models._base import StackAuthModel
class Team(StackAuthModel):
"""A team/organization that users can belong to."""
id: str
display_name: str = Field(alias="displayName")
profile_image_url: str | None = Field(None, alias="profileImageUrl")
client_metadata: dict[str, Any] | None = Field(None, alias="clientMetadata")
client_read_only_metadata: dict[str, Any] | None = Field(
None, alias="clientReadOnlyMetadata"
)
class ServerTeam(Team):
"""Server-side team with additional management capabilities."""
server_metadata: dict[str, Any] | None = Field(None, alias="serverMetadata")
created_at_millis: int = Field(alias="createdAtMillis")
@property
def created_at(self) -> datetime:
"""Convert created_at_millis to a UTC datetime."""
return self._millis_to_datetime(self.created_at_millis) # type: ignore[return-value]
class TeamMemberProfile(StackAuthModel):
"""A user's profile within a specific team."""
user_id: str = Field(alias="userId")
display_name: str | None = Field(None, alias="displayName")
profile_image_url: str | None = Field(None, alias="profileImageUrl")
class TeamInvitation(StackAuthModel):
"""An invitation to join a team."""
id: str
recipient_email: str | None = Field(None, alias="recipientEmail")
expires_at_millis: int = Field(alias="expiresAtMillis")
@property
def expires_at(self) -> datetime:
"""Convert expires_at_millis to a UTC datetime."""
return self._millis_to_datetime(self.expires_at_millis) # type: ignore[return-value]

View File

@ -0,0 +1,55 @@
"""User models for Stack Auth."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import Field
from stack_auth.models._base import StackAuthModel
class BaseUser(StackAuthModel):
"""Base user type with publicly safe properties.
All fields use camelCase aliases matching the Stack Auth API JSON format.
"""
id: str
display_name: str | None = Field(None, alias="displayName")
primary_email: str | None = Field(None, alias="primaryEmail")
primary_email_verified: bool = Field(False, alias="primaryEmailVerified")
profile_image_url: str | None = Field(None, alias="profileImageUrl")
signed_up_at_millis: int = Field(alias="signedUpAtMillis")
last_active_at_millis: int | None = Field(None, alias="lastActiveAtMillis")
client_metadata: dict[str, Any] | None = Field(None, alias="clientMetadata")
client_read_only_metadata: dict[str, Any] | None = Field(
None, alias="clientReadOnlyMetadata"
)
has_password: bool = Field(False, alias="hasPassword")
otp_auth_enabled: bool = Field(False, alias="otpAuthEnabled")
passkey_auth_enabled: bool = Field(False, alias="passkeyAuthEnabled")
is_multi_factor_required: bool = Field(False, alias="isMultiFactorRequired")
is_anonymous: bool = Field(False, alias="isAnonymous")
is_restricted: bool = Field(False, alias="isRestricted")
restricted_reason: dict[str, Any] | None = Field(None, alias="restrictedReason")
@property
def signed_up_at(self) -> datetime:
"""Convert signed_up_at_millis to a UTC datetime."""
return self._millis_to_datetime(self.signed_up_at_millis) # type: ignore[return-value]
@property
def last_active_at(self) -> datetime | None:
"""Convert last_active_at_millis to a UTC datetime, or None."""
return self._millis_to_datetime(self.last_active_at_millis)
class ServerUser(BaseUser):
"""Server-side user with full access to sensitive fields.
Extends BaseUser with server-only metadata.
"""
server_metadata: dict[str, Any] | None = Field(None, alias="serverMetadata")

View File

@ -0,0 +1,6 @@
import pytest
@pytest.fixture
def base_url() -> str:
return "https://api.stack-auth.com"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,292 @@
"""Tests for authentication module: AuthState, TokenPartialUser, decode and authenticate."""
from __future__ import annotations
import base64
import json
import logging
import time
from typing import Any, Mapping
from unittest.mock import AsyncMock, MagicMock, patch
import jwt
import pytest
from stack_auth._auth import (
AuthState,
TokenPartialUser,
_extract_token_from_headers,
async_authenticate_request,
decode_access_token_claims,
sync_authenticate_request,
)
from stack_auth._jwt import AsyncJWKSFetcher, SyncJWKSFetcher
from stack_auth._types import RequestLike
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_fake_jwt(payload: dict[str, Any]) -> str:
"""Build a fake JWT with a valid base64url-encoded payload (no real signature)."""
header = base64.urlsafe_b64encode(json.dumps({"typ": "JWT"}).encode()).rstrip(b"=").decode()
body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=").decode()
return f"{header}.{body}.fakesignature"
class FakeRequest:
"""Minimal RequestLike implementation for tests."""
def __init__(self, headers: dict[str, str] | None = None) -> None:
self._headers = headers or {}
@property
def headers(self) -> Mapping[str, str]:
return self._headers
# ---------------------------------------------------------------------------
# decode_access_token_claims
# ---------------------------------------------------------------------------
class TestDecodeAccessTokenClaims:
"""Tests for decode_access_token_claims."""
def test_returns_token_partial_user_for_valid_jwt_with_all_claims(self) -> None:
payload = {
"sub": "user-abc",
"name": "Alice",
"email": "alice@example.com",
"email_verified": True,
"is_anonymous": False,
"is_multi_factor_required": True,
"is_restricted": True,
"restricted_reason": {"reason": "banned"},
}
token = _make_fake_jwt(payload)
result = decode_access_token_claims(token)
assert result is not None
assert isinstance(result, TokenPartialUser)
assert result.id == "user-abc"
assert result.display_name == "Alice"
assert result.primary_email == "alice@example.com"
assert result.primary_email_verified is True
assert result.is_anonymous is False
assert result.is_multi_factor_required is True
assert result.is_restricted is True
assert result.restricted_reason == {"reason": "banned"}
def test_returns_defaults_for_missing_optional_claims(self) -> None:
payload = {"sub": "user-minimal"}
token = _make_fake_jwt(payload)
result = decode_access_token_claims(token)
assert result is not None
assert result.id == "user-minimal"
assert result.display_name is None
assert result.primary_email is None
assert result.primary_email_verified is False
assert result.is_anonymous is False
assert result.is_multi_factor_required is False
assert result.is_restricted is False
assert result.restricted_reason is None
def test_returns_none_for_malformed_token_not_three_parts(self) -> None:
assert decode_access_token_claims("only.one") is None
assert decode_access_token_claims("") is None
assert decode_access_token_claims("single") is None
def test_returns_none_for_invalid_base64_payload(self) -> None:
assert decode_access_token_claims("header.!!!invalid-base64!!!.sig") is None
def test_returns_none_for_json_missing_sub_field(self) -> None:
payload = {"name": "NoSub"}
token = _make_fake_jwt(payload)
assert decode_access_token_claims(token) is None
def test_handles_base64url_without_padding(self) -> None:
# Ensure payload that requires padding still works
payload = {"sub": "u"}
raw = json.dumps(payload).encode()
encoded = base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
# Confirm padding was stripped
assert "=" not in encoded
token = f"eyJ0eXAiOiJKV1QifQ.{encoded}.sig"
result = decode_access_token_claims(token)
assert result is not None
assert result.id == "u"
# ---------------------------------------------------------------------------
# _extract_token_from_headers
# ---------------------------------------------------------------------------
class TestExtractTokenFromHeaders:
"""Tests for _extract_token_from_headers."""
def test_returns_token_from_authorization_bearer(self) -> None:
headers: dict[str, str] = {"Authorization": "Bearer mytoken123"}
assert _extract_token_from_headers(headers) == "mytoken123"
def test_returns_token_from_lowercase_authorization(self) -> None:
headers: dict[str, str] = {"authorization": "Bearer lowertoken"}
assert _extract_token_from_headers(headers) == "lowertoken"
def test_returns_access_token_from_x_stack_auth_json_fallback(self) -> None:
value = json.dumps({"accessToken": "stack-token-xyz"})
headers: dict[str, str] = {"x-stack-auth": value}
assert _extract_token_from_headers(headers) == "stack-token-xyz"
def test_returns_none_when_no_auth_headers(self) -> None:
assert _extract_token_from_headers({}) is None
assert _extract_token_from_headers({"Content-Type": "application/json"}) is None
def test_returns_none_for_malformed_x_stack_auth_json(self) -> None:
headers: dict[str, str] = {"x-stack-auth": "not-valid-json"}
assert _extract_token_from_headers(headers) is None
# ---------------------------------------------------------------------------
# sync_authenticate_request
# ---------------------------------------------------------------------------
class TestSyncAuthenticateRequest:
"""Tests for sync_authenticate_request."""
def test_returns_authenticated_for_valid_jwt(self) -> None:
fake_claims = {"sub": "user-42", "iss": "stack-auth", "exp": int(time.time()) + 3600}
fetcher = MagicMock(spec=SyncJWKSFetcher)
with patch("stack_auth._auth.sync_verify_token", return_value=fake_claims) as mock_verify:
request = FakeRequest({"Authorization": "Bearer valid-jwt"})
result = sync_authenticate_request(request, fetcher=fetcher)
assert result.status == "authenticated"
assert result.user_id == "user-42"
assert result.claims == fake_claims
assert result.token == "valid-jwt"
mock_verify.assert_called_once_with("valid-jwt", fetcher)
def test_returns_unauthenticated_when_no_token(self) -> None:
fetcher = MagicMock(spec=SyncJWKSFetcher)
request = FakeRequest({})
result = sync_authenticate_request(request, fetcher=fetcher)
assert result.status == "unauthenticated"
assert result.user_id is None
assert result.claims is None
assert result.token is None
def test_returns_unauthenticated_when_verification_fails(self) -> None:
fetcher = MagicMock(spec=SyncJWKSFetcher)
with patch("stack_auth._auth.sync_verify_token", side_effect=jwt.PyJWTError("bad token")):
request = FakeRequest({"Authorization": "Bearer expired-jwt"})
result = sync_authenticate_request(request, fetcher=fetcher)
assert result.status == "unauthenticated"
assert result.user_id is None
# ---------------------------------------------------------------------------
# async_authenticate_request
# ---------------------------------------------------------------------------
class TestAsyncAuthenticateRequest:
"""Tests for async_authenticate_request."""
async def test_returns_authenticated_for_valid_jwt(self) -> None:
fake_claims = {"sub": "user-99", "iss": "stack-auth", "exp": int(time.time()) + 3600}
fetcher = MagicMock(spec=AsyncJWKSFetcher)
with patch("stack_auth._auth.async_verify_token", new_callable=AsyncMock, return_value=fake_claims) as mock_verify:
request = FakeRequest({"Authorization": "Bearer async-jwt"})
result = await async_authenticate_request(request, fetcher=fetcher)
assert result.status == "authenticated"
assert result.user_id == "user-99"
assert result.claims == fake_claims
assert result.token == "async-jwt"
mock_verify.assert_called_once_with("async-jwt", fetcher)
async def test_returns_unauthenticated_when_no_token(self) -> None:
fetcher = MagicMock(spec=AsyncJWKSFetcher)
request = FakeRequest({})
result = await async_authenticate_request(request, fetcher=fetcher)
assert result.status == "unauthenticated"
assert result.user_id is None
async def test_returns_unauthenticated_when_verification_fails(self) -> None:
fetcher = MagicMock(spec=AsyncJWKSFetcher)
with patch("stack_auth._auth.async_verify_token", new_callable=AsyncMock, side_effect=jwt.PyJWTError("expired")):
request = FakeRequest({"Authorization": "Bearer bad-async-jwt"})
result = await async_authenticate_request(request, fetcher=fetcher)
assert result.status == "unauthenticated"
assert result.user_id is None
# ---------------------------------------------------------------------------
# Debug logging on authentication failure
# ---------------------------------------------------------------------------
class TestSyncAuthenticateRequestLogging:
"""Verify that sync_authenticate_request emits debug log on failure."""
def test_logs_debug_on_verification_failure(self, caplog: pytest.LogCaptureFixture) -> None:
fetcher = MagicMock(spec=SyncJWKSFetcher)
with (
patch("stack_auth._auth.sync_verify_token", side_effect=jwt.PyJWTError("bad token")),
caplog.at_level(logging.DEBUG, logger="stack_auth"),
):
request = FakeRequest({"Authorization": "Bearer invalid-jwt"})
result = sync_authenticate_request(request, fetcher=fetcher)
assert result.status == "unauthenticated"
assert any("authenticate_request failed" in record.message for record in caplog.records)
def test_still_returns_unauthenticated_after_logging(self) -> None:
fetcher = MagicMock(spec=SyncJWKSFetcher)
with patch("stack_auth._auth.sync_verify_token", side_effect=ValueError("corrupt")):
request = FakeRequest({"Authorization": "Bearer corrupt-jwt"})
result = sync_authenticate_request(request, fetcher=fetcher)
assert result.status == "unauthenticated"
assert result.user_id is None
class TestAsyncAuthenticateRequestLogging:
"""Verify that async_authenticate_request emits debug log on failure."""
async def test_logs_debug_on_verification_failure(self, caplog: pytest.LogCaptureFixture) -> None:
fetcher = MagicMock(spec=AsyncJWKSFetcher)
with (
patch("stack_auth._auth.async_verify_token", new_callable=AsyncMock, side_effect=jwt.PyJWTError("bad token")),
caplog.at_level(logging.DEBUG, logger="stack_auth"),
):
request = FakeRequest({"Authorization": "Bearer invalid-async-jwt"})
result = await async_authenticate_request(request, fetcher=fetcher)
assert result.status == "unauthenticated"
assert any("authenticate_request failed" in record.message for record in caplog.records)
async def test_still_returns_unauthenticated_after_logging(self) -> None:
fetcher = MagicMock(spec=AsyncJWKSFetcher)
with patch("stack_auth._auth.async_verify_token", new_callable=AsyncMock, side_effect=ValueError("corrupt")):
request = FakeRequest({"Authorization": "Bearer corrupt-async-jwt"})
result = await async_authenticate_request(request, fetcher=fetcher)
assert result.status == "unauthenticated"
assert result.user_id is None

View File

@ -0,0 +1,332 @@
"""Tests for the sync and async HTTP client classes."""
from __future__ import annotations
import asyncio
import re
from unittest.mock import AsyncMock, patch
import httpx
import pytest
import respx
from stack_auth._client import AsyncAPIClient, BaseAPIClient, SyncAPIClient
from stack_auth._version import __version__
from stack_auth.errors import AuthenticationError, NotFoundError, StackAuthError
# ---------------------------------------------------------------------------
# Construction tests
# ---------------------------------------------------------------------------
class TestClientConstruction:
def test_sync_client_constructs(self) -> None:
client = SyncAPIClient(project_id="proj", secret_server_key="sk")
assert client is not None
assert isinstance(client, SyncAPIClient)
def test_async_client_constructs(self) -> None:
client = AsyncAPIClient(project_id="proj", secret_server_key="sk")
assert client is not None
assert isinstance(client, AsyncAPIClient)
# ---------------------------------------------------------------------------
# Header tests
# ---------------------------------------------------------------------------
class TestBuildHeaders:
def setup_method(self) -> None:
self.client = SyncAPIClient(project_id="proj_123", secret_server_key="sk_secret")
def test_project_id_header(self) -> None:
headers = self.client._build_headers()
assert headers["x-stack-project-id"] == "proj_123"
def test_access_type_header(self) -> None:
headers = self.client._build_headers()
assert headers["x-stack-access-type"] == "server"
def test_secret_server_key_header(self) -> None:
headers = self.client._build_headers()
assert headers["x-stack-secret-server-key"] == "sk_secret"
def test_client_version_header(self) -> None:
headers = self.client._build_headers()
assert headers["x-stack-client-version"] == f"python@{__version__}"
def test_override_error_status_header(self) -> None:
headers = self.client._build_headers()
assert headers["x-stack-override-error-status"] == "true"
def test_random_nonce_header_is_uuid(self) -> None:
headers = self.client._build_headers()
nonce = headers["x-stack-random-nonce"]
# UUID v4 pattern
uuid_pattern = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
re.IGNORECASE,
)
assert uuid_pattern.match(nonce), f"Nonce {nonce!r} is not a valid UUID v4"
def test_publishable_client_key_header_included(self) -> None:
client = SyncAPIClient(project_id="proj_123", secret_server_key="sk_secret", publishable_client_key="pk_test")
headers = client._build_headers()
assert headers["x-stack-publishable-client-key"] == "pk_test"
def test_publishable_client_key_header_omitted_when_none(self) -> None:
client = SyncAPIClient(project_id="proj_123", secret_server_key="sk_secret")
headers = client._build_headers()
assert "x-stack-publishable-client-key" not in headers
# ---------------------------------------------------------------------------
# URL building tests
# ---------------------------------------------------------------------------
class TestBuildUrl:
def test_default_base_url(self) -> None:
client = SyncAPIClient(project_id="p", secret_server_key="s")
assert client._build_url("/users") == "https://api.stack-auth.com/api/v1/users"
def test_custom_base_url(self) -> None:
client = SyncAPIClient(project_id="p", secret_server_key="s", base_url="https://custom.host")
assert client._build_url("/users") == "https://custom.host/api/v1/users"
# ---------------------------------------------------------------------------
# Sync request tests
# ---------------------------------------------------------------------------
class TestSyncRequest:
@respx.mock
def test_get_returns_json(self) -> None:
route = respx.get("https://api.stack-auth.com/api/v1/users").mock(
return_value=httpx.Response(
200,
json={"id": "user_1"},
headers={"x-stack-actual-status": "200"},
)
)
client = SyncAPIClient(project_id="p", secret_server_key="s")
result = client.request("GET", "/users")
assert result == {"id": "user_1"}
@respx.mock
def test_known_error_raises_not_found(self) -> None:
respx.get("https://api.stack-auth.com/api/v1/users/123").mock(
return_value=httpx.Response(
200,
json={"code": "USER_NOT_FOUND", "message": "User not found"},
headers={
"x-stack-actual-status": "400",
"x-stack-known-error": "USER_NOT_FOUND",
},
)
)
client = SyncAPIClient(project_id="p", secret_server_key="s")
with pytest.raises(NotFoundError) as exc_info:
client.request("GET", "/users/123")
assert exc_info.value.code == "USER_NOT_FOUND"
@respx.mock
def test_known_error_raises_authentication_error(self) -> None:
respx.get("https://api.stack-auth.com/api/v1/me").mock(
return_value=httpx.Response(
200,
json={"code": "INVALID_ACCESS_TOKEN", "message": "Bad token"},
headers={
"x-stack-actual-status": "400",
"x-stack-known-error": "INVALID_ACCESS_TOKEN",
},
)
)
client = SyncAPIClient(project_id="p", secret_server_key="s")
with pytest.raises(AuthenticationError):
client.request("GET", "/me")
@respx.mock
def test_http_error_on_unknown_status(self) -> None:
respx.get("https://api.stack-auth.com/api/v1/fail").mock(
return_value=httpx.Response(
200,
json={},
headers={"x-stack-actual-status": "500"},
)
)
client = SyncAPIClient(project_id="p", secret_server_key="s")
with pytest.raises(StackAuthError) as exc_info:
client.request("GET", "/fail")
assert exc_info.value.code == "HTTP_ERROR"
@respx.mock
def test_post_with_none_body_sends_empty_json(self) -> None:
route = respx.post("https://api.stack-auth.com/api/v1/items").mock(
return_value=httpx.Response(
200,
json={"ok": True},
headers={"x-stack-actual-status": "200"},
)
)
client = SyncAPIClient(project_id="p", secret_server_key="s")
client.request("POST", "/items", body=None)
sent_request = route.calls[0].request
assert sent_request.content == b"{}"
@respx.mock
def test_get_with_none_body_sends_no_body(self) -> None:
route = respx.get("https://api.stack-auth.com/api/v1/items").mock(
return_value=httpx.Response(
200,
json={"ok": True},
headers={"x-stack-actual-status": "200"},
)
)
client = SyncAPIClient(project_id="p", secret_server_key="s")
client.request("GET", "/items", body=None)
sent_request = route.calls[0].request
assert sent_request.content == b""
# ---------------------------------------------------------------------------
# Async request tests
# ---------------------------------------------------------------------------
class TestAsyncRequest:
@respx.mock
@pytest.mark.asyncio
async def test_get_returns_json(self) -> None:
respx.get("https://api.stack-auth.com/api/v1/users").mock(
return_value=httpx.Response(
200,
json={"id": "user_1"},
headers={"x-stack-actual-status": "200"},
)
)
client = AsyncAPIClient(project_id="p", secret_server_key="s")
result = await client.request("GET", "/users")
assert result == {"id": "user_1"}
await client.aclose()
# ---------------------------------------------------------------------------
# Retry tests (sync)
# ---------------------------------------------------------------------------
class TestRetryLogic:
@respx.mock
@patch("time.sleep")
def test_get_retries_on_connect_error(self, mock_sleep: AsyncMock) -> None:
url = "https://api.stack-auth.com/api/v1/data"
route = respx.get(url).mock(side_effect=httpx.ConnectError("Connection refused"))
client = SyncAPIClient(project_id="p", secret_server_key="s")
with pytest.raises(httpx.ConnectError):
client.request("GET", "/data")
# 1 initial + 5 retries = 6 total attempts
assert route.call_count == 6
@respx.mock
@patch("time.sleep")
def test_post_does_not_retry_on_connect_error(self, mock_sleep: AsyncMock) -> None:
url = "https://api.stack-auth.com/api/v1/data"
route = respx.post(url).mock(side_effect=httpx.ConnectError("Connection refused"))
client = SyncAPIClient(project_id="p", secret_server_key="s")
with pytest.raises(httpx.ConnectError):
client.request("POST", "/data")
assert route.call_count == 1
@respx.mock
@patch("time.sleep")
def test_retry_exponential_backoff_delays(self, mock_sleep: AsyncMock) -> None:
url = "https://api.stack-auth.com/api/v1/data"
respx.get(url).mock(side_effect=httpx.ConnectError("fail"))
client = SyncAPIClient(project_id="p", secret_server_key="s")
with pytest.raises(httpx.ConnectError):
client.request("GET", "/data")
delays = [call.args[0] for call in mock_sleep.call_args_list]
assert delays == [1.0, 2.0, 4.0, 8.0, 16.0]
@respx.mock
@patch("time.sleep")
def test_429_retries_with_retry_after(self, mock_sleep: AsyncMock) -> None:
url = "https://api.stack-auth.com/api/v1/data"
# First call: 429 with Retry-After, second call: success
respx.get(url).mock(
side_effect=[
httpx.Response(
200,
json={},
headers={"x-stack-actual-status": "429", "Retry-After": "3"},
),
httpx.Response(
200,
json={"ok": True},
headers={"x-stack-actual-status": "200"},
),
]
)
client = SyncAPIClient(project_id="p", secret_server_key="s")
result = client.request("GET", "/data")
assert result == {"ok": True}
mock_sleep.assert_called_once_with(3.0)
@respx.mock
@patch("time.sleep")
def test_429_without_retry_after_uses_backoff(self, mock_sleep: AsyncMock) -> None:
url = "https://api.stack-auth.com/api/v1/data"
respx.get(url).mock(
side_effect=[
httpx.Response(
200,
json={},
headers={"x-stack-actual-status": "429"},
),
httpx.Response(
200,
json={"ok": True},
headers={"x-stack-actual-status": "200"},
),
]
)
client = SyncAPIClient(project_id="p", secret_server_key="s")
result = client.request("GET", "/data")
assert result == {"ok": True}
# First attempt is attempt=0, so backoff = 1.0 * (2 ** 0) = 1.0
mock_sleep.assert_called_once_with(1.0)
# ---------------------------------------------------------------------------
# Context manager tests
# ---------------------------------------------------------------------------
class TestContextManager:
def test_sync_context_manager(self) -> None:
with SyncAPIClient(project_id="p", secret_server_key="s") as client:
assert isinstance(client, SyncAPIClient)
@pytest.mark.asyncio
async def test_async_context_manager(self) -> None:
async with AsyncAPIClient(project_id="p", secret_server_key="s") as client:
assert isinstance(client, AsyncAPIClient)
def test_sync_close(self) -> None:
client = SyncAPIClient(project_id="p", secret_server_key="s")
# Force client creation by accessing it
_ = client._get_client()
client.close()
# After close, internal client should be None
assert client._client is None
@pytest.mark.asyncio
async def test_async_aclose(self) -> None:
client = AsyncAPIClient(project_id="p", secret_server_key="s")
_ = client._get_client()
await client.aclose()
assert client._client is None

View File

@ -0,0 +1,389 @@
"""End-to-end integration tests for the Stack Auth Python SDK.
These tests run against a live Stack Auth instance and verify real API
interactions. They require the Stack Auth dev environment to be running:
pnpm start-deps # Start Docker dependencies
pnpm dev # Start the backend server
Run with:
python3 -m pytest tests/test_e2e.py -v -s
Set STACK_E2E=1 to enable (skipped by default to avoid CI failures):
STACK_E2E=1 python3 -m pytest tests/test_e2e.py -v -s
"""
from __future__ import annotations
import os
import uuid
import pytest
from stack_auth import (
AsyncStackServerApp,
StackServerApp,
)
# ---------------------------------------------------------------------------
# Skip unless STACK_E2E=1 is set
# ---------------------------------------------------------------------------
pytestmark = pytest.mark.skipif(
os.environ.get("STACK_E2E") != "1",
reason="E2E tests require STACK_E2E=1 and a running Stack Auth instance",
)
# ---------------------------------------------------------------------------
# Dev environment credentials (from apps/backend/.env.development)
# ---------------------------------------------------------------------------
PROJECT_ID = "internal"
SECRET_SERVER_KEY = "this-secret-server-key-is-for-local-development-only"
BASE_URL = os.environ.get("STACK_BASE_URL", "http://localhost:8102")
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def app() -> StackServerApp:
"""Create a sync StackServerApp for testing."""
return StackServerApp(
project_id=PROJECT_ID,
secret_server_key=SECRET_SERVER_KEY,
base_url=BASE_URL,
)
@pytest.fixture
def async_app() -> AsyncStackServerApp:
"""Create an async AsyncStackServerApp for testing."""
return AsyncStackServerApp(
project_id=PROJECT_ID,
secret_server_key=SECRET_SERVER_KEY,
base_url=BASE_URL,
)
def _unique_email() -> str:
"""Generate a unique email for test isolation."""
return f"e2e-test-{uuid.uuid4().hex[:8]}@example.com"
# ===========================================================================
# E2E-01: User CRUD Lifecycle
# ===========================================================================
class TestUserCRUDLifecycle:
"""Full user lifecycle: create, get, update, list, search, delete."""
def test_create_get_update_delete(self, app: StackServerApp) -> None:
email = _unique_email()
# Create
user = app.create_user(
primary_email=email,
password="TestPassword123!",
display_name="E2E Test User",
)
assert user.id is not None
assert user.primary_email == email
assert user.display_name == "E2E Test User"
try:
# Get
fetched = app.get_user(user.id)
assert fetched is not None
assert fetched.id == user.id
assert fetched.primary_email == email
# Update
updated = app.update_user(user.id, display_name="Updated E2E User")
assert updated is not None
assert updated.display_name == "Updated E2E User"
# List with search
results = app.list_users(query=email)
assert len(results.items) >= 1
found = any(u.id == user.id for u in results.items)
assert found, f"User {user.id} not found in search results"
# List with pagination
paginated = app.list_users(limit=1)
assert len(paginated.items) == 1
finally:
# Delete (cleanup)
app.delete_user(user.id)
# Verify deletion
deleted = app.get_user(user.id)
assert deleted is None
def test_get_nonexistent_user_returns_none(self, app: StackServerApp) -> None:
result = app.get_user("nonexistent-user-id-12345")
assert result is None
# ===========================================================================
# E2E-02: Team Lifecycle
# ===========================================================================
class TestTeamLifecycle:
"""Full team lifecycle: create, members, invitations, permissions, delete."""
def test_create_team_add_member_delete(self, app: StackServerApp) -> None:
# Create a user to be team creator/member
email = _unique_email()
user = app.create_user(
primary_email=email,
password="TestPassword123!",
display_name="Team Test User",
)
try:
# Create team
team = app.create_team(
display_name="E2E Test Team",
creator_user_id=user.id,
)
assert team.id is not None
assert team.display_name == "E2E Test Team"
# List teams for user
teams = app.list_teams(user_id=user.id)
assert len(teams) >= 1
found = any(t.id == team.id for t in teams)
assert found, f"Team {team.id} not found in user's teams"
# Get team
fetched = app.get_team(team.id)
assert fetched is not None
assert fetched.id == team.id
# Update team
updated = app.update_team(team.id, display_name="Updated E2E Team")
assert updated is not None
assert updated.display_name == "Updated E2E Team"
# List member profiles
profiles = app.list_team_member_profiles(team.id)
assert len(profiles) >= 1
# Delete team
app.delete_team(team.id)
# Verify deletion
deleted_team = app.get_team(team.id)
assert deleted_team is None
finally:
app.delete_user(user.id)
def test_get_nonexistent_team_returns_none(self, app: StackServerApp) -> None:
result = app.get_team("nonexistent-team-id-12345")
assert result is None
# ===========================================================================
# E2E-03: Session Management
# ===========================================================================
class TestSessionManagement:
"""Session listing and management."""
def test_list_sessions_for_user(self, app: StackServerApp) -> None:
email = _unique_email()
user = app.create_user(
primary_email=email,
password="TestPassword123!",
display_name="Session Test User",
)
try:
# New users have no sessions (they haven't signed in via browser)
sessions = app.list_sessions(user_id=user.id)
assert isinstance(sessions, list)
finally:
app.delete_user(user.id)
# ===========================================================================
# E2E-04: API Key Management
# ===========================================================================
class TestAPIKeyManagement:
"""API key creation, validation, and revocation."""
def test_user_api_key_lifecycle(self, app: StackServerApp) -> None:
email = _unique_email()
user = app.create_user(
primary_email=email,
password="TestPassword123!",
display_name="API Key Test User",
)
try:
# Create API key
api_key = app.create_user_api_key(
user_id=user.id,
description="E2E test key",
expires_at_millis=None,
)
assert api_key is not None
# List API keys
keys = app.list_user_api_keys(user_id=user.id)
assert len(keys) >= 1
# Revoke
app.revoke_user_api_key(api_key_id=api_key.id)
# Verify revocation
keys_after = app.list_user_api_keys(user_id=user.id)
active_ids = [k.id for k in keys_after if not getattr(k, "is_revoked", False)]
# Key should either be absent or marked as revoked
assert api_key.id not in active_ids or len(keys_after) <= len(keys)
finally:
app.delete_user(user.id)
def test_team_api_key_lifecycle(self, app: StackServerApp) -> None:
email = _unique_email()
user = app.create_user(
primary_email=email,
password="TestPassword123!",
)
try:
team = app.create_team(
display_name="API Key Team",
creator_user_id=user.id,
)
# Create team API key
api_key = app.create_team_api_key(
team_id=team.id,
description="E2E team key",
expires_at_millis=None,
)
assert api_key is not None
# List team API keys
keys = app.list_team_api_keys(team_id=team.id)
assert len(keys) >= 1
# Cleanup
app.revoke_team_api_key(api_key_id=api_key.id)
app.delete_team(team.id)
finally:
app.delete_user(user.id)
# ===========================================================================
# E2E-05: authenticate_request with Real Tokens (limited without browser)
# ===========================================================================
class TestAuthenticateRequestE2E:
"""Test authenticate_request with real-world patterns.
Note: Full JWT-based auth testing requires a browser sign-in flow to get
a real access token. Without that, we test the unauthenticated paths
and verify the function handles real request shapes correctly.
"""
def test_unauthenticated_request_without_token(self, app: StackServerApp) -> None:
import httpx as httpx_client
from stack_auth._auth import sync_authenticate_request
from stack_auth._jwt import SyncJWKSFetcher
jwks_url = f"{BASE_URL}/api/v1/projects/{PROJECT_ID}/.well-known/jwks.json"
fetcher = SyncJWKSFetcher(
jwks_url=jwks_url,
http_client=httpx_client.Client(),
)
class FakeRequest:
def __init__(self, headers: dict[str, str]) -> None:
self.headers = headers
# No auth header → unauthenticated
request = FakeRequest({})
result = sync_authenticate_request(request, fetcher=fetcher)
assert result.status == "unauthenticated"
def test_invalid_token_returns_unauthenticated(self, app: StackServerApp) -> None:
import httpx as httpx_client
from stack_auth._auth import sync_authenticate_request
from stack_auth._jwt import SyncJWKSFetcher
jwks_url = f"{BASE_URL}/api/v1/projects/{PROJECT_ID}/.well-known/jwks.json"
fetcher = SyncJWKSFetcher(
jwks_url=jwks_url,
http_client=httpx_client.Client(),
)
class FakeRequest:
def __init__(self, headers: dict[str, str]) -> None:
self.headers = headers
# Invalid token → unauthenticated
request = FakeRequest({"Authorization": "Bearer invalid-token-value"})
result = sync_authenticate_request(request, fetcher=fetcher)
assert result.status == "unauthenticated"
# ===========================================================================
# Async variants (spot-check — not duplicating full suite)
# ===========================================================================
class TestAsyncE2E:
"""Spot-check that async variants work against live API."""
@pytest.mark.asyncio
async def test_async_user_create_delete(self, async_app: AsyncStackServerApp) -> None:
email = _unique_email()
user = await async_app.create_user(
primary_email=email,
password="TestPassword123!",
display_name="Async E2E User",
)
assert user.id is not None
assert user.display_name == "Async E2E User"
await async_app.delete_user(user.id)
deleted = await async_app.get_user(user.id)
assert deleted is None
@pytest.mark.asyncio
async def test_async_team_create_delete(self, async_app: AsyncStackServerApp) -> None:
email = _unique_email()
user = await async_app.create_user(
primary_email=email,
password="TestPassword123!",
)
try:
team = await async_app.create_team(
display_name="Async E2E Team",
creator_user_id=user.id,
)
assert team.id is not None
await async_app.delete_team(team.id)
finally:
await async_app.delete_user(user.id)

View File

@ -0,0 +1,187 @@
"""Tests for the Stack Auth error hierarchy and from_response() dispatch."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from stack_auth.errors import (
AnalyticsError,
ApiKeyError,
AuthenticationError,
CliError,
ConflictError,
EmailError,
NotFoundError,
OAuthError,
PasskeyError,
PaymentError,
PermissionDeniedError,
RateLimitError,
StackAuthError,
ValidationError,
)
from stack_auth.models._base import StackAuthModel
class TestStackAuthErrorBasics:
"""Test StackAuthError base class storage and string representation."""
def test_stores_code_and_message(self) -> None:
err = StackAuthError("CODE", "msg")
assert err.code == "CODE"
assert err.message == "msg"
def test_str_contains_code_and_message(self) -> None:
err = StackAuthError("CODE", "msg")
s = str(err)
assert "CODE" in s
assert "msg" in s
def test_stores_details(self) -> None:
err = StackAuthError("CODE", "msg", {"key": "val"})
assert err.details == {"key": "val"}
def test_details_default_none(self) -> None:
err = StackAuthError("CODE", "msg")
assert err.details is None
class TestErrorSubclasses:
"""Test that all category subclasses exist and inherit from StackAuthError."""
@pytest.mark.parametrize(
"cls",
[
AuthenticationError,
NotFoundError,
ValidationError,
PermissionDeniedError,
ConflictError,
OAuthError,
PasskeyError,
ApiKeyError,
PaymentError,
EmailError,
RateLimitError,
CliError,
AnalyticsError,
],
)
def test_is_subclass_of_stack_auth_error(self, cls: type) -> None:
assert issubclass(cls, StackAuthError)
@pytest.mark.parametrize(
"cls",
[
AuthenticationError,
NotFoundError,
ValidationError,
PermissionDeniedError,
ConflictError,
OAuthError,
PasskeyError,
ApiKeyError,
PaymentError,
EmailError,
RateLimitError,
CliError,
AnalyticsError,
],
)
def test_can_be_caught_with_except_stack_auth_error(self, cls: type) -> None:
with pytest.raises(StackAuthError):
raise cls("TEST_CODE", "test message")
class TestFromResponse:
"""Test from_response() factory dispatch for known and unknown codes."""
def test_invalid_access_token_returns_authentication_error(self) -> None:
err = StackAuthError.from_response("INVALID_ACCESS_TOKEN", "bad token")
assert isinstance(err, AuthenticationError)
assert err.code == "INVALID_ACCESS_TOKEN"
assert err.message == "bad token"
def test_user_not_found_returns_not_found_error(self) -> None:
err = StackAuthError.from_response("USER_NOT_FOUND", "no user")
assert isinstance(err, NotFoundError)
def test_schema_error_returns_validation_error(self) -> None:
err = StackAuthError.from_response("SCHEMA_ERROR", "bad schema")
assert isinstance(err, ValidationError)
def test_team_permission_required_returns_permission_error(self) -> None:
err = StackAuthError.from_response("TEAM_PERMISSION_REQUIRED", "denied")
assert isinstance(err, PermissionDeniedError)
def test_team_already_exists_returns_conflict_error(self) -> None:
err = StackAuthError.from_response("TEAM_ALREADY_EXISTS", "conflict")
assert isinstance(err, ConflictError)
def test_oauth_connection_not_connected_returns_oauth_error(self) -> None:
err = StackAuthError.from_response("OAUTH_CONNECTION_NOT_CONNECTED_TO_USER", "no conn")
assert isinstance(err, OAuthError)
def test_passkey_authentication_failed_returns_passkey_error(self) -> None:
err = StackAuthError.from_response("PASSKEY_AUTHENTICATION_FAILED", "failed")
assert isinstance(err, PasskeyError)
def test_api_key_not_valid_returns_api_key_error(self) -> None:
err = StackAuthError.from_response("API_KEY_NOT_VALID", "invalid")
assert isinstance(err, ApiKeyError)
def test_product_already_granted_returns_payment_error(self) -> None:
err = StackAuthError.from_response("PRODUCT_ALREADY_GRANTED", "granted")
assert isinstance(err, PaymentError)
def test_email_rendering_error_returns_email_error(self) -> None:
err = StackAuthError.from_response("EMAIL_RENDERING_ERROR", "render fail")
assert isinstance(err, EmailError)
def test_unknown_code_returns_base_stack_auth_error(self) -> None:
err = StackAuthError.from_response("TOTALLY_UNKNOWN_CODE", "surprise")
assert type(err) is StackAuthError
assert err.code == "TOTALLY_UNKNOWN_CODE"
assert err.message == "surprise"
def test_from_response_preserves_details(self) -> None:
err = StackAuthError.from_response("USER_NOT_FOUND", "no user", {"user_id": "abc"})
assert err.details == {"user_id": "abc"}
def test_cli_auth_error_returns_cli_error(self) -> None:
err = StackAuthError.from_response("CLI_AUTH_ERROR", "cli fail")
assert isinstance(err, CliError)
def test_analytics_query_timeout_returns_analytics_error(self) -> None:
err = StackAuthError.from_response("ANALYTICS_QUERY_TIMEOUT", "timeout")
assert isinstance(err, AnalyticsError)
class TestErrorCodeMapCoverage:
"""Test that the error code map has comprehensive coverage."""
def test_error_code_map_has_at_least_90_entries(self) -> None:
from stack_auth.errors import _ERROR_CODE_MAP
assert len(_ERROR_CODE_MAP) >= 90, f"Expected >= 90 entries, got {len(_ERROR_CODE_MAP)}"
class TestStackAuthModel:
"""Test the StackAuthModel base class."""
def test_model_config_populate_by_name(self) -> None:
assert StackAuthModel.model_config.get("populate_by_name") is True
def test_model_config_extra_ignore(self) -> None:
assert StackAuthModel.model_config.get("extra") == "ignore"
def test_millis_to_datetime_converts_correctly(self) -> None:
result = StackAuthModel._millis_to_datetime(1711296000000)
expected = datetime(2024, 3, 24, 16, 0, tzinfo=timezone.utc)
assert result == expected
def test_millis_to_datetime_none_returns_none(self) -> None:
result = StackAuthModel._millis_to_datetime(None)
assert result is None

View File

@ -0,0 +1,322 @@
"""Tests for JWT verification and JWKS fetching."""
from __future__ import annotations
import time
from typing import Any
from unittest.mock import patch
import httpx
import jwt as pyjwt
import pytest
import respx
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
)
from jwt.algorithms import RSAAlgorithm
from stack_auth._jwt import (
ALLOWED_ALGORITHMS,
JWKS_CACHE_TTL,
AsyncJWKSFetcher,
SyncJWKSFetcher,
async_verify_token,
sync_verify_token,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
JWKS_URL = "https://api.stack-auth.com/api/v1/projects/test-project/.well-known/jwks.json"
KID = "test-key-1"
@pytest.fixture()
def rsa_keypair() -> tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]:
"""Generate an RSA keypair for testing."""
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return private_key, private_key.public_key()
@pytest.fixture()
def jwks_response(rsa_keypair: tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]) -> dict[str, Any]:
"""Build a JWKS JSON response from the test keypair."""
_, public_key = rsa_keypair
jwk_dict = RSAAlgorithm.to_jwk(public_key, as_dict=True)
jwk_dict["kid"] = KID
jwk_dict["use"] = "sig"
jwk_dict["alg"] = "RS256"
return {"keys": [jwk_dict]}
@pytest.fixture()
def private_key_pem(rsa_keypair: tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]) -> bytes:
"""PEM-encoded private key for signing test tokens."""
private_key, _ = rsa_keypair
return private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
def _make_token(
private_key_pem: bytes,
kid: str = KID,
exp: int | None = None,
algorithm: str = "RS256",
include_kid: bool = True,
) -> str:
"""Create a signed JWT for testing."""
payload: dict[str, Any] = {"sub": "user-123", "iss": "stack-auth"}
if exp is not None:
payload["exp"] = exp
else:
payload["exp"] = int(time.time()) + 3600 # 1 hour from now
headers: dict[str, Any] = {}
if include_kid:
headers["kid"] = kid
return pyjwt.encode(payload, private_key_pem, algorithm=algorithm, headers=headers)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
def test_allowed_algorithms_is_rs256_only() -> None:
assert ALLOWED_ALGORITHMS == ["RS256"]
def test_cache_ttl_is_300_seconds() -> None:
assert JWKS_CACHE_TTL == 300.0
# ---------------------------------------------------------------------------
# AsyncJWKSFetcher
# ---------------------------------------------------------------------------
class TestAsyncJWKSFetcher:
"""Tests for the async JWKS fetcher."""
async def test_construction(self) -> None:
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
assert fetcher is not None
@respx.mock
async def test_get_signing_key_fetches_and_returns_rsa_key(
self,
jwks_response: dict[str, Any],
) -> None:
respx.get(JWKS_URL).respond(json=jwks_response)
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
key = await fetcher.get_signing_key(KID)
assert key is not None
@respx.mock
async def test_get_signing_key_caches_within_ttl(
self,
jwks_response: dict[str, Any],
) -> None:
route = respx.get(JWKS_URL).respond(json=jwks_response)
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
await fetcher.get_signing_key(KID)
await fetcher.get_signing_key(KID)
assert route.call_count == 1
@respx.mock
async def test_get_signing_key_refetches_after_ttl(
self,
jwks_response: dict[str, Any],
) -> None:
route = respx.get(JWKS_URL).respond(json=jwks_response)
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
with patch("stack_auth._jwt.time.monotonic", return_value=1000.0):
await fetcher.get_signing_key(KID)
# Advance 301 seconds past TTL
with patch("stack_auth._jwt.time.monotonic", return_value=1301.0):
await fetcher.get_signing_key(KID)
assert route.call_count == 2
@respx.mock
async def test_unknown_kid_force_refreshes_then_raises(
self,
jwks_response: dict[str, Any],
) -> None:
route = respx.get(JWKS_URL).respond(json=jwks_response)
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
# Pre-populate cache
await fetcher.get_signing_key(KID)
assert route.call_count == 1
with pytest.raises(ValueError, match="not found in JWKS"):
await fetcher.get_signing_key("unknown-kid")
# Should have force-refreshed once before raising
assert route.call_count == 2
# ---------------------------------------------------------------------------
# SyncJWKSFetcher
# ---------------------------------------------------------------------------
class TestSyncJWKSFetcher:
"""Tests for the sync JWKS fetcher."""
def test_construction(self) -> None:
with httpx.Client() as client:
fetcher = SyncJWKSFetcher(JWKS_URL, client)
assert fetcher is not None
@respx.mock
def test_get_signing_key_fetches_and_returns_rsa_key(
self,
jwks_response: dict[str, Any],
) -> None:
respx.get(JWKS_URL).respond(json=jwks_response)
with httpx.Client() as client:
fetcher = SyncJWKSFetcher(JWKS_URL, client)
key = fetcher.get_signing_key(KID)
assert key is not None
@respx.mock
def test_get_signing_key_caches_within_ttl(
self,
jwks_response: dict[str, Any],
) -> None:
route = respx.get(JWKS_URL).respond(json=jwks_response)
with httpx.Client() as client:
fetcher = SyncJWKSFetcher(JWKS_URL, client)
fetcher.get_signing_key(KID)
fetcher.get_signing_key(KID)
assert route.call_count == 1
# ---------------------------------------------------------------------------
# verify_token (async)
# ---------------------------------------------------------------------------
class TestAsyncVerifyToken:
"""Tests for async_verify_token."""
@respx.mock
async def test_valid_token_returns_claims(
self,
jwks_response: dict[str, Any],
private_key_pem: bytes,
) -> None:
respx.get(JWKS_URL).respond(json=jwks_response)
token = _make_token(private_key_pem)
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
claims = await async_verify_token(token, fetcher)
assert claims["sub"] == "user-123"
assert claims["iss"] == "stack-auth"
@respx.mock
async def test_expired_token_raises(
self,
jwks_response: dict[str, Any],
private_key_pem: bytes,
) -> None:
respx.get(JWKS_URL).respond(json=jwks_response)
token = _make_token(private_key_pem, exp=1) # expired in 1970
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
with pytest.raises(pyjwt.ExpiredSignatureError):
await async_verify_token(token, fetcher)
@respx.mock
async def test_invalid_signature_raises(
self,
jwks_response: dict[str, Any],
) -> None:
respx.get(JWKS_URL).respond(json=jwks_response)
# Sign with a DIFFERENT key
other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
other_pem = other_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
token = _make_token(other_pem)
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
with pytest.raises(pyjwt.InvalidSignatureError):
await async_verify_token(token, fetcher)
@respx.mock
async def test_missing_kid_raises(
self,
jwks_response: dict[str, Any],
private_key_pem: bytes,
) -> None:
respx.get(JWKS_URL).respond(json=jwks_response)
token = _make_token(private_key_pem, include_kid=False)
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
with pytest.raises(ValueError, match="missing 'kid'"):
await async_verify_token(token, fetcher)
@respx.mock
async def test_hs256_token_rejected(
self,
jwks_response: dict[str, Any],
) -> None:
"""HS256-signed tokens must be rejected even if alg header says HS256 (CVE-2022-29217)."""
respx.get(JWKS_URL).respond(json=jwks_response)
# HS256 token signed with a symmetric key
token = pyjwt.encode(
{"sub": "user-123", "exp": int(time.time()) + 3600},
"secret",
algorithm="HS256",
headers={"kid": KID},
)
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
with pytest.raises(pyjwt.InvalidAlgorithmError):
await async_verify_token(token, fetcher)
# ---------------------------------------------------------------------------
# verify_token (sync)
# ---------------------------------------------------------------------------
class TestSyncVerifyToken:
"""Tests for sync_verify_token."""
@respx.mock
def test_valid_token_returns_claims(
self,
jwks_response: dict[str, Any],
private_key_pem: bytes,
) -> None:
respx.get(JWKS_URL).respond(json=jwks_response)
token = _make_token(private_key_pem)
with httpx.Client() as client:
fetcher = SyncJWKSFetcher(JWKS_URL, client)
claims = sync_verify_token(token, fetcher)
assert claims["sub"] == "user-123"
@respx.mock
def test_expired_token_raises(
self,
jwks_response: dict[str, Any],
private_key_pem: bytes,
) -> None:
respx.get(JWKS_URL).respond(json=jwks_response)
token = _make_token(private_key_pem, exp=1)
with httpx.Client() as client:
fetcher = SyncJWKSFetcher(JWKS_URL, client)
with pytest.raises(pyjwt.ExpiredSignatureError):
sync_verify_token(token, fetcher)

View File

@ -0,0 +1,112 @@
"""Tests for PaginatedResult[T] generic pagination wrapper."""
import pytest
class TestPaginatedResult:
"""Tests for PaginatedResult with nested pagination shape."""
def test_parse_with_server_user(self) -> None:
from stack_auth._pagination import PaginatedResult
from stack_auth.models.users import ServerUser
data = {
"items": [
{
"id": "u1",
"signedUpAtMillis": 1711296000000,
"hasPassword": True,
"otpAuthEnabled": False,
"passkeyAuthEnabled": False,
"isMultiFactorRequired": False,
"isAnonymous": False,
"isRestricted": False,
}
],
"pagination": {"next_cursor": "abc123"},
}
result = PaginatedResult[ServerUser].model_validate(data)
assert len(result.items) == 1
assert isinstance(result.items[0], ServerUser)
assert result.items[0].id == "u1"
assert result.next_cursor == "abc123"
def test_no_next_cursor_means_no_next_page(self) -> None:
from stack_auth._pagination import PaginatedResult
from stack_auth.models.teams import Team
data = {
"items": [{"id": "t1", "displayName": "Team A"}],
"pagination": {},
}
result = PaginatedResult[Team].model_validate(data)
assert result.next_cursor is None
assert result.has_next_page is False
def test_next_cursor_present_means_has_next_page(self) -> None:
from stack_auth._pagination import PaginatedResult
from stack_auth.models.teams import Team
data = {
"items": [{"id": "t1", "displayName": "Team A"}],
"pagination": {"next_cursor": "cursor123"},
}
result = PaginatedResult[Team].model_validate(data)
assert result.has_next_page is True
def test_empty_items_list(self) -> None:
from stack_auth._pagination import PaginatedResult
from stack_auth.models.users import BaseUser
data = {"items": [], "pagination": {}}
result = PaginatedResult[BaseUser].model_validate(data)
assert len(result.items) == 0
assert result.has_next_page is False
def test_items_count(self) -> None:
from stack_auth._pagination import PaginatedResult
from stack_auth.models.teams import Team
data = {
"items": [
{"id": "t1", "displayName": "Team A"},
{"id": "t2", "displayName": "Team B"},
{"id": "t3", "displayName": "Team C"},
],
"pagination": {"next_cursor": "next"},
}
result = PaginatedResult[Team].model_validate(data)
assert len(result.items) == 3
def test_item_is_correct_type(self) -> None:
from stack_auth._pagination import PaginatedResult
from stack_auth.models.users import ServerUser
data = {
"items": [
{
"id": "u1",
"signedUpAtMillis": 1711296000000,
"hasPassword": False,
"otpAuthEnabled": False,
"passkeyAuthEnabled": False,
"isMultiFactorRequired": False,
"isAnonymous": False,
"isRestricted": False,
"serverMetadata": {},
}
],
"pagination": {},
}
result = PaginatedResult[ServerUser].model_validate(data)
assert isinstance(result.items[0], ServerUser)
def test_pagination_absent_uses_default(self) -> None:
"""If pagination key is missing entirely, default to no next page."""
from stack_auth._pagination import PaginatedResult
from stack_auth.models.teams import Team
data = {"items": [{"id": "t1", "displayName": "Team A"}]}
result = PaginatedResult[Team].model_validate(data)
assert result.next_cursor is None
assert result.has_next_page is False

View File

@ -0,0 +1,101 @@
"""Tests for team models (Team, ServerTeam, TeamMemberProfile, TeamInvitation)."""
from datetime import datetime, timezone
import pytest
class TestTeam:
"""Tests for the Team model."""
def test_parse_from_camel_case_json(self) -> None:
from stack_auth.models.teams import Team
data = {
"id": "t1",
"displayName": "Team A",
"clientMetadata": {},
}
team = Team.model_validate(data)
assert team.id == "t1"
assert team.display_name == "Team A"
assert team.client_metadata == {}
def test_optional_fields(self) -> None:
from stack_auth.models.teams import Team
data = {
"id": "t1",
"displayName": "Team A",
"profileImageUrl": "https://example.com/img.png",
"clientMetadata": {"key": "val"},
"clientReadOnlyMetadata": {"ro": True},
}
team = Team.model_validate(data)
assert team.profile_image_url == "https://example.com/img.png"
assert team.client_read_only_metadata == {"ro": True}
class TestServerTeam:
"""Tests for the ServerTeam model."""
def test_inherits_team(self) -> None:
from stack_auth.models.teams import ServerTeam, Team
assert issubclass(ServerTeam, Team)
def test_parse_with_server_fields(self) -> None:
from stack_auth.models.teams import ServerTeam
data = {
"id": "t1",
"displayName": "Team A",
"clientMetadata": {},
"serverMetadata": {"internal": True},
"createdAtMillis": 1711296000000,
}
team = ServerTeam.model_validate(data)
assert team.server_metadata == {"internal": True}
assert team.created_at == datetime(2024, 3, 24, 16, 0, tzinfo=timezone.utc)
class TestTeamMemberProfile:
"""Tests for the TeamMemberProfile model."""
def test_parse_from_camel_case(self) -> None:
from stack_auth.models.teams import TeamMemberProfile
data = {
"userId": "user1",
"displayName": "John",
"profileImageUrl": "https://example.com/img.png",
}
profile = TeamMemberProfile.model_validate(data)
assert profile.user_id == "user1"
assert profile.display_name == "John"
assert profile.profile_image_url == "https://example.com/img.png"
def test_nullable_fields(self) -> None:
from stack_auth.models.teams import TeamMemberProfile
data = {"userId": "user1"}
profile = TeamMemberProfile.model_validate(data)
assert profile.display_name is None
assert profile.profile_image_url is None
class TestTeamInvitation:
"""Tests for the TeamInvitation model."""
def test_parse_invitation(self) -> None:
from stack_auth.models.teams import TeamInvitation
data = {
"id": "inv1",
"recipientEmail": "test@example.com",
"expiresAtMillis": 1711296000000,
}
inv = TeamInvitation.model_validate(data)
assert inv.id == "inv1"
assert inv.recipient_email == "test@example.com"
assert inv.expires_at == datetime(2024, 3, 24, 16, 0, tzinfo=timezone.utc)

View File

@ -0,0 +1,157 @@
"""Tests for user models (BaseUser, ServerUser)."""
from datetime import datetime, timezone
import pytest
class TestBaseUser:
"""Tests for the BaseUser model."""
def test_parse_from_camel_case_json(self) -> None:
from stack_auth.models.users import BaseUser
data = {
"id": "u1",
"displayName": "John",
"primaryEmail": "j@e.com",
"primaryEmailVerified": True,
"signedUpAtMillis": 1711296000000,
"hasPassword": True,
"otpAuthEnabled": False,
"passkeyAuthEnabled": False,
"isMultiFactorRequired": False,
"isAnonymous": False,
"isRestricted": False,
}
user = BaseUser.model_validate(data)
assert user.id == "u1"
assert user.display_name == "John"
assert user.primary_email == "j@e.com"
assert user.primary_email_verified is True
assert user.has_password is True
assert user.otp_auth_enabled is False
assert user.passkey_auth_enabled is False
assert user.is_multi_factor_required is False
assert user.is_anonymous is False
assert user.is_restricted is False
def test_signed_up_at_millis_converts_to_datetime(self) -> None:
from stack_auth.models.users import BaseUser
data = {
"id": "u1",
"signedUpAtMillis": 1711296000000,
"hasPassword": True,
"otpAuthEnabled": False,
"passkeyAuthEnabled": False,
"isMultiFactorRequired": False,
"isAnonymous": False,
"isRestricted": False,
}
user = BaseUser.model_validate(data)
assert user.signed_up_at == datetime(2024, 3, 24, 16, 0, tzinfo=timezone.utc)
def test_nullable_timestamp_returns_none(self) -> None:
from stack_auth.models.users import BaseUser
data = {
"id": "u1",
"signedUpAtMillis": 1711296000000,
"hasPassword": True,
"otpAuthEnabled": False,
"passkeyAuthEnabled": False,
"isMultiFactorRequired": False,
"isAnonymous": False,
"isRestricted": False,
}
user = BaseUser.model_validate(data)
assert user.last_active_at is None
def test_extra_fields_ignored(self) -> None:
from stack_auth.models.users import BaseUser
data = {
"id": "u1",
"signedUpAtMillis": 1711296000000,
"hasPassword": True,
"otpAuthEnabled": False,
"passkeyAuthEnabled": False,
"isMultiFactorRequired": False,
"isAnonymous": False,
"isRestricted": False,
"futureField": 123,
"anotherUnknown": "hello",
}
user = BaseUser.model_validate(data)
assert user.id == "u1"
def test_populate_by_name_snake_case(self) -> None:
from stack_auth.models.users import BaseUser
user = BaseUser(
id="u1",
display_name="John",
signed_up_at_millis=1711296000000,
has_password=True,
otp_auth_enabled=False,
passkey_auth_enabled=False,
is_multi_factor_required=False,
is_anonymous=False,
is_restricted=False,
)
assert user.display_name == "John"
def test_restricted_reason_parses(self) -> None:
from stack_auth.models.users import BaseUser
data = {
"id": "u1",
"signedUpAtMillis": 1711296000000,
"hasPassword": False,
"otpAuthEnabled": False,
"passkeyAuthEnabled": False,
"isMultiFactorRequired": False,
"isAnonymous": False,
"isRestricted": True,
"restrictedReason": {"type": "email_not_verified"},
}
user = BaseUser.model_validate(data)
assert user.is_restricted is True
assert user.restricted_reason == {"type": "email_not_verified"}
def test_millis_to_datetime_none(self) -> None:
from stack_auth.models._base import StackAuthModel
assert StackAuthModel._millis_to_datetime(None) is None
class TestServerUser:
"""Tests for the ServerUser model."""
def test_inherits_base_user(self) -> None:
from stack_auth.models.users import BaseUser, ServerUser
assert issubclass(ServerUser, BaseUser)
def test_parse_with_server_fields(self) -> None:
from stack_auth.models.users import ServerUser
data = {
"id": "u1",
"displayName": "John",
"primaryEmail": "j@e.com",
"primaryEmailVerified": True,
"signedUpAtMillis": 1711296000000,
"lastActiveAtMillis": 1711296000000,
"hasPassword": True,
"otpAuthEnabled": False,
"passkeyAuthEnabled": False,
"isMultiFactorRequired": False,
"isAnonymous": False,
"isRestricted": False,
"serverMetadata": {"role": "admin"},
}
user = ServerUser.model_validate(data)
assert user.server_metadata == {"role": "admin"}
assert user.last_active_at == datetime(2024, 3, 24, 16, 0, tzinfo=timezone.utc)

View File

@ -0,0 +1,450 @@
"""Tests for the token store subsystem: ABC, concrete stores, registry, and CAS algorithm."""
from __future__ import annotations
import asyncio
import base64
import json
import time
from typing import Mapping
from unittest.mock import MagicMock, patch
import pytest
from stack_auth._token_store import (
ExplicitTokenStore,
MemoryTokenStore,
RequestTokenStore,
TokenStore,
_decode_jwt_payload,
_is_expired,
_is_fresh_enough,
get_or_fetch_likely_valid_tokens_async,
get_or_fetch_likely_valid_tokens_sync,
resolve_token_store,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_jwt(claims: dict) -> str:
"""Build a fake JWT with the given payload claims (no real signature)."""
header = base64.urlsafe_b64encode(
json.dumps({"typ": "JWT", "alg": "RS256"}).encode()
).rstrip(b"=").decode()
payload = base64.urlsafe_b64encode(
json.dumps(claims).encode()
).rstrip(b"=").decode()
return f"{header}.{payload}.fake_signature"
def _make_fresh_token(now: float) -> str:
"""Token that is fresh: exp > now+20 and iat > now-75."""
return _make_jwt({"sub": "user_1", "exp": now + 60, "iat": now - 10})
def _make_expiring_soon_token(now: float) -> str:
"""Token that expires within 20s."""
return _make_jwt({"sub": "user_1", "exp": now + 10, "iat": now - 10})
def _make_old_token(now: float) -> str:
"""Token issued > 75s ago (stale iat)."""
return _make_jwt({"sub": "user_1", "exp": now + 60, "iat": now - 100})
def _make_expired_token(now: float) -> str:
"""Token that is already expired."""
return _make_jwt({"sub": "user_1", "exp": now - 10, "iat": now - 100})
class _FakeRequest:
"""Simple request-like object implementing the RequestLike protocol."""
def __init__(self, headers: dict[str, str]) -> None:
self._headers = headers
@property
def headers(self) -> Mapping[str, str]:
return self._headers
# ---------------------------------------------------------------------------
# TokenStore ABC
# ---------------------------------------------------------------------------
class TestTokenStoreABC:
def test_cannot_instantiate_abstract(self) -> None:
with pytest.raises(TypeError):
TokenStore() # type: ignore[abstract]
def test_has_sync_and_async_locks(self) -> None:
store = MemoryTokenStore()
assert hasattr(store, "_sync_lock")
assert hasattr(store, "_async_lock")
# ---------------------------------------------------------------------------
# MemoryTokenStore
# ---------------------------------------------------------------------------
class TestMemoryTokenStore:
def test_stores_and_retrieves_tokens(self) -> None:
store = MemoryTokenStore()
assert store.get_stored_access_token() is None
assert store.get_stored_refresh_token() is None
store.compare_and_set(None, "rt_1", "at_1") # type: ignore[arg-type]
# compare against None should match initial state
assert store.get_stored_access_token() == "at_1"
assert store.get_stored_refresh_token() == "rt_1"
def test_compare_and_set_updates_when_matching(self) -> None:
store = MemoryTokenStore()
store.compare_and_set(None, "rt_1", "at_1") # type: ignore[arg-type]
store.compare_and_set("rt_1", "rt_2", "at_2")
assert store.get_stored_access_token() == "at_2"
assert store.get_stored_refresh_token() == "rt_2"
def test_compare_and_set_does_not_update_when_mismatched(self) -> None:
store = MemoryTokenStore()
store.compare_and_set(None, "rt_1", "at_1") # type: ignore[arg-type]
store.compare_and_set("wrong_rt", "rt_2", "at_2")
assert store.get_stored_access_token() == "at_1"
assert store.get_stored_refresh_token() == "rt_1"
# ---------------------------------------------------------------------------
# ExplicitTokenStore
# ---------------------------------------------------------------------------
class TestExplicitTokenStore:
def test_initializes_from_tokens(self) -> None:
store = ExplicitTokenStore(access_token="at_1", refresh_token="rt_1")
assert store.get_stored_access_token() == "at_1"
assert store.get_stored_refresh_token() == "rt_1"
def test_supports_cas_update(self) -> None:
store = ExplicitTokenStore(access_token="at_1", refresh_token="rt_1")
store.compare_and_set("rt_1", "rt_2", "at_2")
assert store.get_stored_access_token() == "at_2"
assert store.get_stored_refresh_token() == "rt_2"
def test_cas_does_not_update_when_mismatched(self) -> None:
store = ExplicitTokenStore(access_token="at_1", refresh_token="rt_1")
store.compare_and_set("wrong", "rt_2", "at_2")
assert store.get_stored_access_token() == "at_1"
assert store.get_stored_refresh_token() == "rt_1"
def test_defaults_to_none_without_arguments(self) -> None:
store = ExplicitTokenStore()
assert store.get_stored_access_token() is None
assert store.get_stored_refresh_token() is None
def test_partial_dict_defaults_missing_to_none(self) -> None:
store = resolve_token_store({"access_token": "at"}, "proj")
assert isinstance(store, ExplicitTokenStore)
assert store.get_stored_access_token() == "at"
assert store.get_stored_refresh_token() is None
# ---------------------------------------------------------------------------
# RequestTokenStore
# ---------------------------------------------------------------------------
class TestRequestTokenStore:
def test_extracts_tokens_from_header(self) -> None:
header_value = json.dumps({"accessToken": "at_req", "refreshToken": "rt_req"})
request = _FakeRequest({"x-stack-auth": header_value})
store = RequestTokenStore(request)
assert store.get_stored_access_token() == "at_req"
assert store.get_stored_refresh_token() == "rt_req"
def test_returns_none_when_header_missing(self) -> None:
request = _FakeRequest({})
store = RequestTokenStore(request)
assert store.get_stored_access_token() is None
assert store.get_stored_refresh_token() is None
def test_returns_none_when_header_is_malformed_json(self) -> None:
request = _FakeRequest({"x-stack-auth": "not-json"})
store = RequestTokenStore(request)
assert store.get_stored_access_token() is None
assert store.get_stored_refresh_token() is None
def test_supports_cas_update(self) -> None:
header_value = json.dumps({"accessToken": "at_req", "refreshToken": "rt_req"})
request = _FakeRequest({"x-stack-auth": header_value})
store = RequestTokenStore(request)
store.compare_and_set("rt_req", "rt_new", "at_new")
assert store.get_stored_access_token() == "at_new"
assert store.get_stored_refresh_token() == "rt_new"
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
class TestTokenStoreRegistry:
def test_same_project_returns_same_instance(self) -> None:
store1 = resolve_token_store("memory", "proj_1")
store2 = resolve_token_store("memory", "proj_1")
assert store1 is store2
def test_different_projects_return_different_instances(self) -> None:
store1 = resolve_token_store("memory", "proj_1")
store2 = resolve_token_store("memory", "proj_2")
assert store1 is not store2
def test_resolve_explicit_token_store(self) -> None:
store = resolve_token_store(
{"access_token": "at", "refresh_token": "rt"}, "proj"
)
assert isinstance(store, ExplicitTokenStore)
assert store.get_stored_access_token() == "at"
assert store.get_stored_refresh_token() == "rt"
def test_resolve_request_token_store(self) -> None:
header_value = json.dumps({"accessToken": "at", "refreshToken": "rt"})
request = _FakeRequest({"x-stack-auth": header_value})
store = resolve_token_store(request, "proj")
assert isinstance(store, RequestTokenStore)
def test_resolve_none(self) -> None:
assert resolve_token_store(None, "proj") is None
def test_resolve_raises_type_error_for_invalid_input(self) -> None:
with pytest.raises(TypeError, match="Invalid token store initializer"):
resolve_token_store(12345, "proj") # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# RequestLike runtime_checkable isinstance checks
# ---------------------------------------------------------------------------
class TestRequestLikeProtocol:
def test_isinstance_works_on_conforming_object(self) -> None:
"""RequestLike should be runtime_checkable so isinstance works."""
from stack_auth._types import RequestLike
request = _FakeRequest({"x-stack-auth": "{}"})
assert isinstance(request, RequestLike)
def test_isinstance_rejects_non_conforming_object(self) -> None:
"""Objects without a .headers property should not pass isinstance."""
from stack_auth._types import RequestLike
assert not isinstance("a string", RequestLike)
assert not isinstance(42, RequestLike)
assert not isinstance({}, RequestLike)
def test_resolve_token_store_returns_request_token_store_for_request_like(self) -> None:
"""resolve_token_store should detect RequestLike via isinstance and return RequestTokenStore."""
header_value = json.dumps({"accessToken": "at", "refreshToken": "rt"})
request = _FakeRequest({"x-stack-auth": header_value})
store = resolve_token_store(request, "proj")
assert isinstance(store, RequestTokenStore)
assert store.get_stored_access_token() == "at"
# ---------------------------------------------------------------------------
# Helper functions: _is_fresh_enough, _is_expired, _decode_jwt_payload
# ---------------------------------------------------------------------------
class TestIsFreshEnough:
def test_returns_true_for_fresh_token(self) -> None:
now = time.time()
token = _make_fresh_token(now)
with patch("stack_auth._token_store.time") as mock_time:
mock_time.time.return_value = now
assert _is_fresh_enough(token) is True
def test_returns_false_for_expiring_soon(self) -> None:
now = time.time()
token = _make_expiring_soon_token(now)
with patch("stack_auth._token_store.time") as mock_time:
mock_time.time.return_value = now
assert _is_fresh_enough(token) is False
def test_returns_false_for_old_iat(self) -> None:
now = time.time()
token = _make_old_token(now)
with patch("stack_auth._token_store.time") as mock_time:
mock_time.time.return_value = now
assert _is_fresh_enough(token) is False
def test_returns_false_for_none(self) -> None:
assert _is_fresh_enough(None) is False
class TestDecodeJwtPayload:
def test_decodes_valid_jwt(self) -> None:
token = _make_jwt({"sub": "user_1", "exp": 9999999999})
claims = _decode_jwt_payload(token)
assert claims is not None
assert claims["sub"] == "user_1"
def test_returns_none_for_invalid(self) -> None:
assert _decode_jwt_payload("not-a-jwt") is None
def test_returns_none_for_malformed_base64(self) -> None:
assert _decode_jwt_payload("a.!!!.c") is None
class TestIsExpired:
def test_expired_token(self) -> None:
now = time.time()
token = _make_expired_token(now)
with patch("stack_auth._token_store.time") as mock_time:
mock_time.time.return_value = now
assert _is_expired(token) is True
def test_valid_token(self) -> None:
now = time.time()
token = _make_fresh_token(now)
with patch("stack_auth._token_store.time") as mock_time:
mock_time.time.return_value = now
assert _is_expired(token) is False
# ---------------------------------------------------------------------------
# CAS Refresh: Sync
# ---------------------------------------------------------------------------
class TestCASRefreshSync:
def test_returns_fresh_access_token(self) -> None:
now = time.time()
store = MemoryTokenStore()
fresh_token = _make_fresh_token(now)
store.compare_and_set(None, "rt_1", fresh_token) # type: ignore[arg-type]
refresh_fn = MagicMock(return_value=(True, "new_at"))
with patch("stack_auth._token_store.time") as mock_time:
mock_time.time.return_value = now
rt, at = get_or_fetch_likely_valid_tokens_sync(store, refresh_fn)
assert rt == "rt_1"
assert at == fresh_token
refresh_fn.assert_not_called()
def test_refreshes_when_expiring_soon(self) -> None:
now = time.time()
store = MemoryTokenStore()
expiring_token = _make_expiring_soon_token(now)
store.compare_and_set(None, "rt_1", expiring_token) # type: ignore[arg-type]
refresh_fn = MagicMock(return_value=(True, "new_at"))
with patch("stack_auth._token_store.time") as mock_time:
mock_time.time.return_value = now
rt, at = get_or_fetch_likely_valid_tokens_sync(store, refresh_fn)
assert rt == "rt_1"
assert at == "new_at"
refresh_fn.assert_called_once_with("rt_1")
def test_refreshes_when_old_iat(self) -> None:
now = time.time()
store = MemoryTokenStore()
old_token = _make_old_token(now)
store.compare_and_set(None, "rt_1", old_token) # type: ignore[arg-type]
refresh_fn = MagicMock(return_value=(True, "new_at"))
with patch("stack_auth._token_store.time") as mock_time:
mock_time.time.return_value = now
rt, at = get_or_fetch_likely_valid_tokens_sync(store, refresh_fn)
assert rt == "rt_1"
assert at == "new_at"
refresh_fn.assert_called_once_with("rt_1")
def test_clears_tokens_on_invalid_refresh(self) -> None:
now = time.time()
store = MemoryTokenStore()
expiring_token = _make_expiring_soon_token(now)
store.compare_and_set(None, "rt_1", expiring_token) # type: ignore[arg-type]
refresh_fn = MagicMock(return_value=(False, None))
with patch("stack_auth._token_store.time") as mock_time:
mock_time.time.return_value = now
rt, at = get_or_fetch_likely_valid_tokens_sync(store, refresh_fn)
assert rt is None
assert at is None
# Store should be cleared
assert store.get_stored_refresh_token() is None
assert store.get_stored_access_token() is None
def test_returns_access_without_refresh_when_not_expired(self) -> None:
now = time.time()
store = MemoryTokenStore()
fresh_token = _make_fresh_token(now)
# Set access token but no refresh token
store._access_token = fresh_token
refresh_fn = MagicMock()
with patch("stack_auth._token_store.time") as mock_time:
mock_time.time.return_value = now
rt, at = get_or_fetch_likely_valid_tokens_sync(store, refresh_fn)
assert rt is None
assert at == fresh_token
refresh_fn.assert_not_called()
def test_returns_none_none_when_no_refresh_and_expired(self) -> None:
now = time.time()
store = MemoryTokenStore()
expired_token = _make_expired_token(now)
store._access_token = expired_token
refresh_fn = MagicMock()
with patch("stack_auth._token_store.time") as mock_time:
mock_time.time.return_value = now
rt, at = get_or_fetch_likely_valid_tokens_sync(store, refresh_fn)
assert rt is None
assert at is None
refresh_fn.assert_not_called()
# ---------------------------------------------------------------------------
# CAS Refresh: Async
# ---------------------------------------------------------------------------
class TestCASRefreshAsync:
async def test_async_returns_fresh_access_token(self) -> None:
now = time.time()
store = MemoryTokenStore()
fresh_token = _make_fresh_token(now)
store.compare_and_set(None, "rt_1", fresh_token) # type: ignore[arg-type]
async def refresh_fn(rt: str) -> tuple[bool, str | None]:
return (True, "new_at")
with patch("stack_auth._token_store.time") as mock_time:
mock_time.time.return_value = now
rt, at = await get_or_fetch_likely_valid_tokens_async(store, refresh_fn)
assert rt == "rt_1"
assert at == fresh_token
async def test_async_refreshes_when_needed(self) -> None:
now = time.time()
store = MemoryTokenStore()
expiring_token = _make_expiring_soon_token(now)
store.compare_and_set(None, "rt_1", expiring_token) # type: ignore[arg-type]
called = False
async def refresh_fn(rt: str) -> tuple[bool, str | None]:
nonlocal called
called = True
return (True, "new_at")
with patch("stack_auth._token_store.time") as mock_time:
mock_time.time.return_value = now
rt, at = await get_or_fetch_likely_valid_tokens_async(store, refresh_fn)
assert rt == "rt_1"
assert at == "new_at"
assert called