mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Merge a26a68225e into 7ed89a2a9c
This commit is contained in:
commit
b933f9de90
218
sdks/implementations/python/README.md
Normal file
218
sdks/implementations/python/README.md
Normal 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).
|
||||
64
sdks/implementations/python/pyproject.toml
Normal file
64
sdks/implementations/python/pyproject.toml
Normal 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
|
||||
165
sdks/implementations/python/src/stack_auth/__init__.py
Normal file
165
sdks/implementations/python/src/stack_auth/__init__.py
Normal 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",
|
||||
]
|
||||
2760
sdks/implementations/python/src/stack_auth/_app.py
Normal file
2760
sdks/implementations/python/src/stack_auth/_app.py
Normal file
File diff suppressed because it is too large
Load Diff
205
sdks/implementations/python/src/stack_auth/_auth.py
Normal file
205
sdks/implementations/python/src/stack_auth/_auth.py
Normal 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")
|
||||
349
sdks/implementations/python/src/stack_auth/_client.py
Normal file
349
sdks/implementations/python/src/stack_auth/_client.py
Normal 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()
|
||||
3
sdks/implementations/python/src/stack_auth/_constants.py
Normal file
3
sdks/implementations/python/src/stack_auth/_constants.py
Normal file
@ -0,0 +1,3 @@
|
||||
DEFAULT_BASE_URL = "https://api.stack-auth.com"
|
||||
SDK_NAME = "python"
|
||||
API_VERSION = "v1"
|
||||
254
sdks/implementations/python/src/stack_auth/_jwt.py
Normal file
254
sdks/implementations/python/src/stack_auth/_jwt.py
Normal 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,
|
||||
)
|
||||
38
sdks/implementations/python/src/stack_auth/_pagination.py
Normal file
38
sdks/implementations/python/src/stack_auth/_pagination.py
Normal 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
|
||||
441
sdks/implementations/python/src/stack_auth/_token_store.py
Normal file
441
sdks/implementations/python/src/stack_auth/_token_store.py
Normal 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)
|
||||
13
sdks/implementations/python/src/stack_auth/_types.py
Normal file
13
sdks/implementations/python/src/stack_auth/_types.py
Normal 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."""
|
||||
...
|
||||
1
sdks/implementations/python/src/stack_auth/_version.py
Normal file
1
sdks/implementations/python/src/stack_auth/_version.py
Normal file
@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
259
sdks/implementations/python/src/stack_auth/errors.py
Normal file
259
sdks/implementations/python/src/stack_auth/errors.py
Normal 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,
|
||||
}
|
||||
@ -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",
|
||||
]
|
||||
31
sdks/implementations/python/src/stack_auth/models/_base.py
Normal file
31
sdks/implementations/python/src/stack_auth/models/_base.py
Normal 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)
|
||||
@ -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")
|
||||
@ -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")
|
||||
121
sdks/implementations/python/src/stack_auth/models/data_vault.py
Normal file
121
sdks/implementations/python/src/stack_auth/models/data_vault.py
Normal 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
|
||||
14
sdks/implementations/python/src/stack_auth/models/email.py
Normal file
14
sdks/implementations/python/src/stack_auth/models/email.py
Normal 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
|
||||
@ -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")
|
||||
25
sdks/implementations/python/src/stack_auth/models/oauth.py
Normal file
25
sdks/implementations/python/src/stack_auth/models/oauth.py
Normal 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")
|
||||
183
sdks/implementations/python/src/stack_auth/models/payments.py
Normal file
183
sdks/implementations/python/src/stack_auth/models/payments.py
Normal 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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
55
sdks/implementations/python/src/stack_auth/models/teams.py
Normal file
55
sdks/implementations/python/src/stack_auth/models/teams.py
Normal 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]
|
||||
55
sdks/implementations/python/src/stack_auth/models/users.py
Normal file
55
sdks/implementations/python/src/stack_auth/models/users.py
Normal 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")
|
||||
0
sdks/implementations/python/src/stack_auth/py.typed
Normal file
0
sdks/implementations/python/src/stack_auth/py.typed
Normal file
0
sdks/implementations/python/tests/__init__.py
Normal file
0
sdks/implementations/python/tests/__init__.py
Normal file
6
sdks/implementations/python/tests/conftest.py
Normal file
6
sdks/implementations/python/tests/conftest.py
Normal file
@ -0,0 +1,6 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_url() -> str:
|
||||
return "https://api.stack-auth.com"
|
||||
3139
sdks/implementations/python/tests/test_app.py
Normal file
3139
sdks/implementations/python/tests/test_app.py
Normal file
File diff suppressed because it is too large
Load Diff
292
sdks/implementations/python/tests/test_auth.py
Normal file
292
sdks/implementations/python/tests/test_auth.py
Normal 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
|
||||
332
sdks/implementations/python/tests/test_client.py
Normal file
332
sdks/implementations/python/tests/test_client.py
Normal 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
|
||||
389
sdks/implementations/python/tests/test_e2e.py
Normal file
389
sdks/implementations/python/tests/test_e2e.py
Normal 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)
|
||||
187
sdks/implementations/python/tests/test_errors.py
Normal file
187
sdks/implementations/python/tests/test_errors.py
Normal 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
|
||||
322
sdks/implementations/python/tests/test_jwt.py
Normal file
322
sdks/implementations/python/tests/test_jwt.py
Normal 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)
|
||||
112
sdks/implementations/python/tests/test_models/test_pagination.py
Normal file
112
sdks/implementations/python/tests/test_models/test_pagination.py
Normal 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
|
||||
101
sdks/implementations/python/tests/test_models/test_teams.py
Normal file
101
sdks/implementations/python/tests/test_models/test_teams.py
Normal 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)
|
||||
157
sdks/implementations/python/tests/test_models/test_users.py
Normal file
157
sdks/implementations/python/tests/test_models/test_users.py
Normal 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)
|
||||
450
sdks/implementations/python/tests/test_token_store.py
Normal file
450
sdks/implementations/python/tests/test_token_store.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user