stwrd-auth 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stwrd_auth-0.1.0/.gitignore +36 -0
- stwrd_auth-0.1.0/CHANGELOG.md +43 -0
- stwrd_auth-0.1.0/LICENSE +21 -0
- stwrd_auth-0.1.0/PKG-INFO +577 -0
- stwrd_auth-0.1.0/README.md +529 -0
- stwrd_auth-0.1.0/SECURITY.md +20 -0
- stwrd_auth-0.1.0/docs/management.md +111 -0
- stwrd_auth-0.1.0/docs/webhooks.md +201 -0
- stwrd_auth-0.1.0/pyproject.toml +153 -0
- stwrd_auth-0.1.0/scripts/generate_management.py +450 -0
- stwrd_auth-0.1.0/stwrd/__init__.py +49 -0
- stwrd_auth-0.1.0/stwrd/client.py +466 -0
- stwrd_auth-0.1.0/stwrd/config.py +240 -0
- stwrd_auth-0.1.0/stwrd/fastapi.py +733 -0
- stwrd_auth-0.1.0/stwrd/management.py +539 -0
- stwrd_auth-0.1.0/stwrd/management_generated.py +2180 -0
- stwrd_auth-0.1.0/stwrd/oidc.py +537 -0
- stwrd_auth-0.1.0/stwrd/organizations.py +79 -0
- stwrd_auth-0.1.0/stwrd/postgres.py +336 -0
- stwrd_auth-0.1.0/stwrd/py.typed +0 -0
- stwrd_auth-0.1.0/stwrd/sessions.py +362 -0
- stwrd_auth-0.1.0/stwrd/webhooks.py +251 -0
- stwrd_auth-0.1.0/tests/__init__.py +0 -0
- stwrd_auth-0.1.0/tests/conftest.py +53 -0
- stwrd_auth-0.1.0/tests/fake_idp.py +289 -0
- stwrd_auth-0.1.0/tests/test_auth_router.py +965 -0
- stwrd_auth-0.1.0/tests/test_client.py +292 -0
- stwrd_auth-0.1.0/tests/test_config.py +136 -0
- stwrd_auth-0.1.0/tests/test_distribution.py +71 -0
- stwrd_auth-0.1.0/tests/test_management.py +554 -0
- stwrd_auth-0.1.0/tests/test_management_generator.py +422 -0
- stwrd_auth-0.1.0/tests/test_management_transport_properties.py +99 -0
- stwrd_auth-0.1.0/tests/test_oidc.py +320 -0
- stwrd_auth-0.1.0/tests/test_organization_switch.py +267 -0
- stwrd_auth-0.1.0/tests/test_postgres_requirement.py +44 -0
- stwrd_auth-0.1.0/tests/test_postgres_store.py +323 -0
- stwrd_auth-0.1.0/tests/test_public_surface.py +162 -0
- stwrd_auth-0.1.0/tests/test_readme.py +143 -0
- stwrd_auth-0.1.0/tests/test_refresh.py +517 -0
- stwrd_auth-0.1.0/tests/test_security_policy.py +45 -0
- stwrd_auth-0.1.0/tests/test_session_coordination.py +171 -0
- stwrd_auth-0.1.0/tests/test_sessions.py +261 -0
- stwrd_auth-0.1.0/tests/test_webhooks.py +355 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
.env
|
|
2
|
+
.env.*
|
|
3
|
+
!.env.example
|
|
4
|
+
!.env.*.example
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.pyc
|
|
7
|
+
.venv/
|
|
8
|
+
# La BD del IdP: la crea cualquier `alembic upgrade` local y no tiene nada que hacer en el repo.
|
|
9
|
+
*.db
|
|
10
|
+
*.db-wal
|
|
11
|
+
*.db-shm
|
|
12
|
+
.coverage
|
|
13
|
+
node_modules/
|
|
14
|
+
dist/
|
|
15
|
+
*.tsbuildinfo
|
|
16
|
+
# El build de la consola copiado dentro del árbol del IdP. En la imagen lo pone
|
|
17
|
+
# la etapa `console` del `infra/Dockerfile`; acá solo aparece si alguien lo
|
|
18
|
+
# copió a mano para probar el montaje, y no es fuente.
|
|
19
|
+
idp/stwrd_idp/web/static/console/
|
|
20
|
+
# El sha del build: lo escribe `infra/Dockerfile` dentro de la imagen
|
|
21
|
+
# (`outbox/health.image_version`). No es fuente.
|
|
22
|
+
idp/stwrd_idp/BUILD
|
|
23
|
+
.DS_Store
|
|
24
|
+
.claude/*
|
|
25
|
+
!.claude/rules/
|
|
26
|
+
!.claude/rules/*.md
|
|
27
|
+
.codegraph/
|
|
28
|
+
*.out
|
|
29
|
+
*.out
|
|
30
|
+
# El estado local de `wrangler dev` (KV de Miniflare, caché del runtime): lo
|
|
31
|
+
# crea probar la página de estado en local y no es fuente.
|
|
32
|
+
status/.wrangler/
|
|
33
|
+
# Los cachés de pytest y ruff: los crea cada corrida, y con ellos el árbol
|
|
34
|
+
# nunca queda limpio y el gate no puede dejar su marca (`scripts/gate-key.sh`).
|
|
35
|
+
.pytest_cache/
|
|
36
|
+
.ruff_cache/
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
Before 1.0, minor releases may contain breaking changes.
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - Unreleased
|
|
10
|
+
|
|
11
|
+
Initial release.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `Stwrd` client and `Stwrd.from_env()` / `StwrdConfig.from_env()` configuration
|
|
16
|
+
loaders that fail at startup on missing or invalid settings.
|
|
17
|
+
- FastAPI integration (`stwrd.fastapi`, installed with the `fastapi` extra):
|
|
18
|
+
`auth_router()` mounting `/auth/sign-in`, `/auth/callback`, `/auth/sign-out`,
|
|
19
|
+
`/auth/session`, `/auth/organizations`, `/auth/organization`,
|
|
20
|
+
`/auth/back-channel` and `/auth/webhook`.
|
|
21
|
+
- FastAPI dependencies `optional_user`, `current_user`, `require_auth`,
|
|
22
|
+
`require_role`, `require_org`, `require_permission` and `api_mode`, and
|
|
23
|
+
`protect()` for fail-closed protection of an entire app by path.
|
|
24
|
+
- Authorization code flow with PKCE, state and nonce; ID token validation
|
|
25
|
+
(signature, issuer, audience, expiry, nonce and `at_hash`) with JWKS caching.
|
|
26
|
+
- Server-side sessions behind a cookie that carries only an opaque, signed
|
|
27
|
+
session id, with CSRF protection for sign-out and organization switching.
|
|
28
|
+
- Automatic token refresh that exchanges each refresh token at most once across
|
|
29
|
+
workers, and treats an unreachable server as unavailable instead of signing
|
|
30
|
+
users out.
|
|
31
|
+
- Session stores: in-memory `MemoryStore` and, with the `postgres` extra,
|
|
32
|
+
`PostgresSessionStore` with encrypted token storage and key rotation.
|
|
33
|
+
- Capabilities (roles and permissions) taken only from verified ID tokens, for
|
|
34
|
+
individual and organization contexts, and organization selection through the
|
|
35
|
+
`org` scope.
|
|
36
|
+
- Back-channel logout and Standard Webhooks verification (`verify_webhook`,
|
|
37
|
+
`verify_webhook_signature`, `WebhookEvent`) with replay-window enforcement and
|
|
38
|
+
de-duplication.
|
|
39
|
+
- Typed Management API client (`create_management`) for server-side use, with
|
|
40
|
+
ETags, idempotency keys, step-up tokens, file uploads and cursor iteration.
|
|
41
|
+
- `connect_to` and `connect_to_hook` for developing against a local server
|
|
42
|
+
without DNS or `/etc/hosts` changes.
|
|
43
|
+
- `py.typed` marker; support for Python 3.12, 3.13 and 3.14.
|
stwrd_auth-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 stwrd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: stwrd-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for stwrd: OpenID Connect sign-in, server-side sessions, FastAPI integration and webhook verification
|
|
5
|
+
Project-URL: Homepage, https://stwrd.dev
|
|
6
|
+
Project-URL: Documentation, https://github.com/stwrd-dev/sdk-python/blob/main/docs/webhooks.md
|
|
7
|
+
Project-URL: Repository, https://github.com/stwrd-dev/sdk-python
|
|
8
|
+
Project-URL: Issues, https://github.com/stwrd-dev/sdk-python/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/stwrd-dev/sdk-python/blob/main/CHANGELOG.md
|
|
10
|
+
Author: stwrd
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: authentication,bff,fastapi,identity,oauth2,oidc,openid-connect,sessions,starlette,stwrd,webhooks
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Framework :: FastAPI
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Session
|
|
26
|
+
Classifier: Topic :: Security
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.12
|
|
29
|
+
Requires-Dist: httpx>=0.27
|
|
30
|
+
Requires-Dist: joserfc>=1.0
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: fastapi>=0.115; extra == 'dev'
|
|
33
|
+
Requires-Dist: mypy>=1.13; extra == 'dev'
|
|
34
|
+
Requires-Dist: psycopg[binary,pool]>=3.2; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest-xdist>=3.6; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest>=8.3; extra == 'dev'
|
|
38
|
+
Requires-Dist: python-multipart>=0.0.17; extra == 'dev'
|
|
39
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
40
|
+
Requires-Dist: starlette>=0.40; extra == 'dev'
|
|
41
|
+
Provides-Extra: fastapi
|
|
42
|
+
Requires-Dist: fastapi>=0.115; extra == 'fastapi'
|
|
43
|
+
Requires-Dist: python-multipart>=0.0.17; extra == 'fastapi'
|
|
44
|
+
Requires-Dist: starlette>=0.40; extra == 'fastapi'
|
|
45
|
+
Provides-Extra: postgres
|
|
46
|
+
Requires-Dist: psycopg[pool]>=3.2; extra == 'postgres'
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
|
|
49
|
+
# stwrd-auth
|
|
50
|
+
|
|
51
|
+
[](https://pypi.org/project/stwrd-auth/)
|
|
52
|
+
[](https://pypi.org/project/stwrd-auth/)
|
|
53
|
+
[](https://github.com/stwrd-dev/sdk-python/blob/main/LICENSE)
|
|
54
|
+

|
|
55
|
+
|
|
56
|
+
Python SDK for [stwrd](https://stwrd.dev): OpenID Connect sign-in, server-side
|
|
57
|
+
sessions, FastAPI dependencies and webhook verification.
|
|
58
|
+
|
|
59
|
+
The package implements the backend-for-frontend (BFF) profile. It mounts the
|
|
60
|
+
`/auth/*` routes on a FastAPI app, keeps the session on the server and exposes
|
|
61
|
+
dependencies to protect your own routes. The browser only receives a signed
|
|
62
|
+
cookie; access, refresh and ID tokens never leave your process. The
|
|
63
|
+
framework-independent parts (OIDC client, session store, webhook verification,
|
|
64
|
+
Management API client) work without FastAPI.
|
|
65
|
+
|
|
66
|
+
This is the Python counterpart of `@stwrd-auth/node`: same routes, same
|
|
67
|
+
responses, same `/auth/session` JSON and same environment variables. The
|
|
68
|
+
browser client is `@stwrd-auth/react`.
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
The package is installed as `stwrd-auth` and imported as `stwrd`.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# with uv
|
|
76
|
+
uv add "stwrd-auth[fastapi]"
|
|
77
|
+
|
|
78
|
+
# with pip
|
|
79
|
+
pip install "stwrd-auth[fastapi]"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
| Extra | Adds | Needed for |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `fastapi` | `fastapi`, `starlette`, `python-multipart` | `stwrd.fastapi`: the router, the dependencies and `protect()` |
|
|
85
|
+
| `postgres` | `psycopg[pool]` | `stwrd.postgres.PostgresSessionStore`, a session store shared by several workers |
|
|
86
|
+
|
|
87
|
+
Without extras you get the OIDC client, the session types, webhook
|
|
88
|
+
verification and the Management API client, which depend only on `httpx` and
|
|
89
|
+
`joserfc`.
|
|
90
|
+
|
|
91
|
+
Requires Python 3.12 or newer.
|
|
92
|
+
|
|
93
|
+
## Quick start
|
|
94
|
+
|
|
95
|
+
Register an application in stwrd, allow `{STWRD_BASE_URL}/auth/callback` as a
|
|
96
|
+
redirect URI, and export the required settings:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
export STWRD_ISSUER=https://idp.example.com
|
|
100
|
+
export STWRD_CLIENT_ID=your-client-id
|
|
101
|
+
export STWRD_CLIENT_SECRET=your-client-secret
|
|
102
|
+
export STWRD_BASE_URL=https://app.example.com
|
|
103
|
+
export STWRD_COOKIE_SECRET="$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Then create the app:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from contextlib import asynccontextmanager
|
|
110
|
+
|
|
111
|
+
from fastapi import Depends, FastAPI
|
|
112
|
+
|
|
113
|
+
from stwrd import Stwrd, StwrdUser
|
|
114
|
+
from stwrd.fastapi import auth_router, current_user, optional_user
|
|
115
|
+
|
|
116
|
+
stwrd = Stwrd.from_env()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@asynccontextmanager
|
|
120
|
+
async def lifespan(app: FastAPI):
|
|
121
|
+
yield
|
|
122
|
+
await stwrd.close()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
app = FastAPI(lifespan=lifespan)
|
|
126
|
+
app.include_router(auth_router(stwrd))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.get("/")
|
|
130
|
+
async def home(user: StwrdUser | None = Depends(optional_user(stwrd))):
|
|
131
|
+
if user is None:
|
|
132
|
+
return {"signed_in": False, "sign_in": "/auth/sign-in?return_to=/me"}
|
|
133
|
+
return {"signed_in": True, "email": user.email}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.get("/me")
|
|
137
|
+
async def me(user: StwrdUser = Depends(current_user(stwrd))):
|
|
138
|
+
return {"id": user.id, "email": user.email, "roles": user.roles}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Run it with `uvicorn main:app`. Opening `/auth/sign-in?return_to=/me` redirects
|
|
142
|
+
to stwrd, and `/auth/callback` completes the sign-in and returns the person to
|
|
143
|
+
`/me`. `GET /auth/session` returns the JSON that `@stwrd-auth/react` consumes:
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{"authenticated": true,
|
|
147
|
+
"user": {"id": "…", "email": "…", "email_verified": true,
|
|
148
|
+
"display_name": null, "avatar_url": null, "roles": [], "permissions": []},
|
|
149
|
+
"organization": null, "consents": {},
|
|
150
|
+
"csrf_token": "…", "account_url": "https://idp.example.com/me"}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Roles, permissions, sign-out and webhooks
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from fastapi import Depends, FastAPI, Request
|
|
157
|
+
from fastapi.responses import HTMLResponse
|
|
158
|
+
|
|
159
|
+
from stwrd import Stwrd, StwrdUser, WebhookEvent
|
|
160
|
+
from stwrd.fastapi import (
|
|
161
|
+
auth_router, current_user, optional_user, require_permission, require_role,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
stwrd = Stwrd.from_env()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def on_event(event: WebhookEvent) -> None:
|
|
168
|
+
# `event.type` is the event name; `event.data`, its payload.
|
|
169
|
+
print("webhook", event.type, event.data)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
app = FastAPI()
|
|
173
|
+
app.include_router(auth_router(stwrd, on_event=on_event))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@app.get("/", response_class=HTMLResponse)
|
|
177
|
+
async def home(user: StwrdUser | None = Depends(optional_user(stwrd))):
|
|
178
|
+
if user is None:
|
|
179
|
+
return '<a href="/auth/sign-in?return_to=/private">Sign in</a>'
|
|
180
|
+
return f'<p>Hello, {user.email}.</p><a href="/private">Private area</a>'
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.get("/private", response_class=HTMLResponse)
|
|
184
|
+
async def private(request: Request, user: StwrdUser = Depends(current_user(stwrd))):
|
|
185
|
+
# Sign-out is a POST with a CSRF token derived from the local session
|
|
186
|
+
# (the same value `GET /auth/session` returns as `csrf_token`).
|
|
187
|
+
session = await stwrd.session_from_cookie(request.cookies.get(stwrd.config.session_cookie))
|
|
188
|
+
csrf = stwrd.csrf(session.id)
|
|
189
|
+
return (
|
|
190
|
+
f"<p>Welcome, {user.email}. Your id is {user.id}.</p>"
|
|
191
|
+
'<form method="post" action="/auth/sign-out">'
|
|
192
|
+
f'<input type="hidden" name="csrf_token" value="{csrf}"><button>Sign out</button></form>'
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.get("/admin")
|
|
197
|
+
async def admin(user: StwrdUser = Depends(require_role(stwrd, "org:admin"))):
|
|
198
|
+
return {"actor_id": user.id}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@app.post("/invitations")
|
|
202
|
+
async def invite(user: StwrdUser = Depends(require_permission(stwrd, "members:invite"))):
|
|
203
|
+
return {"invited_by": user.id}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`org:admin` and `members:invite` are examples: use the role and permission names
|
|
207
|
+
defined in your tenant. Organization roles need the `org` scope, see
|
|
208
|
+
[Scopes](#scopes).
|
|
209
|
+
|
|
210
|
+
### Without a framework
|
|
211
|
+
|
|
212
|
+
Webhook verification does not depend on FastAPI. This receiver works with any
|
|
213
|
+
web framework: pass it the raw request body and the headers.
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
import os
|
|
217
|
+
from collections.abc import Mapping
|
|
218
|
+
|
|
219
|
+
from stwrd import DuplicateEventError, InvalidSignatureError, verify_webhook
|
|
220
|
+
from stwrd.webhooks import SeenWebhookIds
|
|
221
|
+
|
|
222
|
+
WEBHOOK_SECRET = os.environ["STWRD_WEBHOOK_SECRET"]
|
|
223
|
+
seen = SeenWebhookIds() # in-memory; keep your own record of processed ids too
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def receive(body: bytes, headers: Mapping[str, str]) -> int:
|
|
227
|
+
"""Return the HTTP status code to answer with."""
|
|
228
|
+
try:
|
|
229
|
+
event = verify_webhook(body, headers, WEBHOOK_SECRET, seen=seen)
|
|
230
|
+
except DuplicateEventError:
|
|
231
|
+
return 200 # already processed
|
|
232
|
+
except InvalidSignatureError:
|
|
233
|
+
return 400
|
|
234
|
+
print("webhook", event.type, event.data)
|
|
235
|
+
return 200
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Sessions work the same way outside FastAPI: `await stwrd.resolve_session(cookie_value)`
|
|
239
|
+
returns the current `StwrdSession` or `None`. See the [API reference](#api-reference).
|
|
240
|
+
|
|
241
|
+
## Configuration
|
|
242
|
+
|
|
243
|
+
`Stwrd.from_env()` and `StwrdConfig.from_env()` read these variables. The same
|
|
244
|
+
names are read by `@stwrd-auth/node`, so an app can switch languages without
|
|
245
|
+
touching its `.env`. Every setting is also a field of `StwrdConfig`, and
|
|
246
|
+
keyword arguments to `from_env()` override the environment.
|
|
247
|
+
|
|
248
|
+
| Variable | Required | Default | Description |
|
|
249
|
+
|---|---|---|---|
|
|
250
|
+
| `STWRD_ISSUER` | yes | | Issuer URL of the tenant, for example `https://idp.example.com`. |
|
|
251
|
+
| `STWRD_CLIENT_ID` | yes | | OIDC client registered for this application. |
|
|
252
|
+
| `STWRD_CLIENT_SECRET` | yes | | Secret of that client. |
|
|
253
|
+
| `STWRD_BASE_URL` | yes | | Public URL of this app. `{base_url}{prefix}/callback` is the redirect URI. |
|
|
254
|
+
| `STWRD_COOKIE_SECRET` | yes | | HMAC key for the session cookie, **at least 32 characters**. |
|
|
255
|
+
| `STWRD_SCOPE` | no | `openid profile email offline_access` | Requested scopes, see [Scopes](#scopes). |
|
|
256
|
+
| `STWRD_WEBHOOK_SECRET` | no | | Signing secret for `/auth/webhook`. Without it the route answers 503. |
|
|
257
|
+
| `STWRD_PREFIX` | no | `/auth` | Path prefix of the routes. |
|
|
258
|
+
| `STWRD_SESSION_TTL_S` | no | `28800` (8 h) | Sliding lifetime of the local session. |
|
|
259
|
+
| `STWRD_COOKIE_SECURE` | no | `true` | Set to `false` only for plain-HTTP local development. |
|
|
260
|
+
| `STWRD_POST_LOGIN_REDIRECT` | no | `/` | Where to go after sign-in when no `return_to` was given. |
|
|
261
|
+
| `STWRD_POST_LOGOUT_REDIRECT` | no | `/` | Where to go after sign-out. |
|
|
262
|
+
| `STWRD_CONNECT_TO` | no | | Connect elsewhere while keeping the issuer's `Host`, see [Developing against a local server](#developing-against-a-local-server). |
|
|
263
|
+
|
|
264
|
+
If a required variable is missing or `STWRD_COOKIE_SECRET` is too short,
|
|
265
|
+
`from_env()` raises `ConfigError` and the process does not start. That is on
|
|
266
|
+
purpose: a misconfigured integration fails at startup, not on the third request.
|
|
267
|
+
|
|
268
|
+
Fields without a variable: `session_cookie` (default `__Host-stwrd_session`),
|
|
269
|
+
`tx_cookie` (default `__Host-stwrd_tx`) and `transaction_ttl_s` (default `600`).
|
|
270
|
+
Browsers reject a `__Host-` cookie that is not `Secure`, so when you set
|
|
271
|
+
`STWRD_COOKIE_SECURE=false` for local development also pass cookie names
|
|
272
|
+
without that prefix:
|
|
273
|
+
`Stwrd.from_env(session_cookie="stwrd_session", tx_cookie="stwrd_tx")`.
|
|
274
|
+
|
|
275
|
+
## Routes
|
|
276
|
+
|
|
277
|
+
`auth_router(stwrd)` mounts these routes under the prefix (default `/auth`):
|
|
278
|
+
|
|
279
|
+
| Route | Purpose |
|
|
280
|
+
|---|---|
|
|
281
|
+
| `GET /sign-in?return_to=…` | Starts the authorization-code flow with PKCE. `return_to` accepts only a relative path of this app; anything else falls back to `/`. |
|
|
282
|
+
| `GET /callback` | Completes sign-in, creates the local session and sets the cookie. |
|
|
283
|
+
| `POST /sign-out` | Ends the local session and signs out at stwrd. Requires the CSRF token (`csrf_token` form field or `X-CSRF-Token` header). |
|
|
284
|
+
| `GET /session` | Current session as JSON (anonymous shape when there is none). |
|
|
285
|
+
| `GET /organizations`, `POST /organization` | List the person's organizations and switch the active one. Available only when the `org` scope is requested. |
|
|
286
|
+
| `POST /back-channel` | Receives back-channel logout notices from stwrd. |
|
|
287
|
+
| `POST /webhook` | Receives and verifies webhooks. |
|
|
288
|
+
|
|
289
|
+
`auth_router` also accepts two hooks: `on_user_registered(user)`, called on every
|
|
290
|
+
successful sign-in (make it an idempotent upsert), and `on_event(event)`, called
|
|
291
|
+
once per accepted webhook delivery. Both may be sync or async.
|
|
292
|
+
|
|
293
|
+
## Protecting routes
|
|
294
|
+
|
|
295
|
+
All dependencies are factories: they take the `Stwrd` instance and return the
|
|
296
|
+
callable FastAPI runs under `Depends(...)`. There is no implicit registration
|
|
297
|
+
in `app.state`.
|
|
298
|
+
|
|
299
|
+
| Factory | Returns | If it does not hold |
|
|
300
|
+
|---|---|---|
|
|
301
|
+
| `optional_user(stwrd)` | `StwrdUser \| None` | never fails; `None` means no session |
|
|
302
|
+
| `current_user(stwrd)` | `StwrdUser` | 303 to sign-in, or 401 for API requests |
|
|
303
|
+
| `require_auth(stwrd)` | `None` | 303 to sign-in, or 401 for API requests |
|
|
304
|
+
| `require_role(stwrd, role)` | `StwrdUser` | 403 `Missing role {role}.` |
|
|
305
|
+
| `require_org(stwrd)` | `StwrdUser` | 403 `The session has no organization.` |
|
|
306
|
+
| `require_permission(stwrd, permission)` | `StwrdUser` | 403 `Missing permission {permission}.` |
|
|
307
|
+
| `api_mode(stwrd)` | `None` | makes that route answer a JSON 401 instead of a 303 |
|
|
308
|
+
|
|
309
|
+
`protect(app, stwrd, public=("/", "/static/*"))` is the opt-in fail-closed mode:
|
|
310
|
+
it requires a session on every path not declared public. Everything under the
|
|
311
|
+
auth prefix is always public, and `*` is a wildcard only at the end of a pattern.
|
|
312
|
+
|
|
313
|
+
`require_role` and `require_permission` evaluate the session's coherent
|
|
314
|
+
organization or individual capability family and never combine the two.
|
|
315
|
+
`require_org` raises `ConfigError` when it is built without the `org` scope.
|
|
316
|
+
Dependencies also store `request.state.stwrd_user`, `stwrd_organization` and
|
|
317
|
+
`stwrd_consents`.
|
|
318
|
+
|
|
319
|
+
When stwrd does not answer, every entry point responds 503 and keeps the
|
|
320
|
+
stored session: an unreachable server never looks like a signed-out user.
|
|
321
|
+
|
|
322
|
+
## Scopes
|
|
323
|
+
|
|
324
|
+
The default scope is `openid profile email offline_access`. It does not include
|
|
325
|
+
`org`; organization claims, `require_org` and the organization routes need it
|
|
326
|
+
requested explicitly, in two places:
|
|
327
|
+
|
|
328
|
+
1. In the app: `STWRD_SCOPE="openid profile email offline_access org"`.
|
|
329
|
+
2. In stwrd: the scope must be allowed for the client.
|
|
330
|
+
|
|
331
|
+
Requesting a scope the client is not allowed to use makes the authorization
|
|
332
|
+
request fail with `invalid_scope`; it is not trimmed silently. Without
|
|
333
|
+
`offline_access` there is no refresh token and the local session lasts as long
|
|
334
|
+
as the access token.
|
|
335
|
+
|
|
336
|
+
## Sessions and session stores
|
|
337
|
+
|
|
338
|
+
Tokens and claims are stored server-side in a `SessionStore`; the cookie only
|
|
339
|
+
carries an opaque session id. Expired access tokens are renewed with the
|
|
340
|
+
refresh token on the next request, and a renewal that stwrd rejects ends the
|
|
341
|
+
session.
|
|
342
|
+
|
|
343
|
+
| Store | Use |
|
|
344
|
+
|---|---|
|
|
345
|
+
| `MemoryStore` (default) | One process. Sessions are lost on restart and not shared between workers. |
|
|
346
|
+
| `stwrd.postgres.PostgresSessionStore` | Several workers or restarts. Requires the `postgres` extra. |
|
|
347
|
+
|
|
348
|
+
`PostgresSessionStore` encrypts the stored tokens (JWE, `A256GCM`) with a
|
|
349
|
+
keyring you provide and coordinates refresh across processes, so a refresh token
|
|
350
|
+
is exchanged at most once. Create the table by running `stwrd.postgres.SCHEMA_SQL`
|
|
351
|
+
from your own migration; the store never creates tables.
|
|
352
|
+
|
|
353
|
+
```python
|
|
354
|
+
from psycopg_pool import AsyncConnectionPool
|
|
355
|
+
|
|
356
|
+
from stwrd import Stwrd
|
|
357
|
+
from stwrd.postgres import Keyring, PostgresSessionStore
|
|
358
|
+
|
|
359
|
+
pool = AsyncConnectionPool("postgresql://user:password@db.example.com/app", open=False)
|
|
360
|
+
keyring = Keyring({"k1": key_bytes_32}, current="k1") # 32 random bytes per key
|
|
361
|
+
stwrd = Stwrd.from_env(sessions=PostgresSessionStore(pool, namespace="my-app", keyring=keyring))
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Open the pool during application startup (`await pool.open()`). Any object that
|
|
365
|
+
implements the `SessionStore` protocol can be used instead.
|
|
366
|
+
|
|
367
|
+
## Webhooks
|
|
368
|
+
|
|
369
|
+
`auth_router` verifies webhooks at `POST /auth/webhook` when
|
|
370
|
+
`STWRD_WEBHOOK_SECRET` is set. To receive them in your own code, or to
|
|
371
|
+
implement a receiver in another language, see the
|
|
372
|
+
[webhook contract](https://github.com/stwrd-dev/sdk-python/blob/main/docs/webhooks.md):
|
|
373
|
+
headers, signature scheme, replay window, retries and a test vector.
|
|
374
|
+
|
|
375
|
+
## Management API
|
|
376
|
+
|
|
377
|
+
`create_management(ManagementOptions(...))` returns a typed, server-side client
|
|
378
|
+
for the Management API, authenticated with client credentials. Keep these
|
|
379
|
+
credentials on your server.
|
|
380
|
+
|
|
381
|
+
```python
|
|
382
|
+
import os
|
|
383
|
+
|
|
384
|
+
from stwrd import ManagementOptions, create_management
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
async def list_user_ids() -> list[str]:
|
|
388
|
+
options = ManagementOptions(
|
|
389
|
+
issuer=os.environ["STWRD_ISSUER"],
|
|
390
|
+
client_id=os.environ["STWRD_MANAGEMENT_CLIENT_ID"],
|
|
391
|
+
client_secret=os.environ["STWRD_MANAGEMENT_CLIENT_SECRET"],
|
|
392
|
+
)
|
|
393
|
+
async with create_management(options) as management:
|
|
394
|
+
return [user["id"] async for user in management.users.iterate()]
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Configuration, ETags and write options, file uploads and cursor traversal are
|
|
398
|
+
covered in the [Management transport guide](https://github.com/stwrd-dev/sdk-python/blob/main/docs/management.md).
|
|
399
|
+
|
|
400
|
+
## Developing against a local server
|
|
401
|
+
|
|
402
|
+
The server resolves the tenant from the `Host` header, so an SDK talking to a
|
|
403
|
+
server on `127.0.0.1` has to do it *as* the tenant's host. `connect_to` (or
|
|
404
|
+
`STWRD_CONNECT_TO`) does exactly that for discovery, token, JWKS and userinfo
|
|
405
|
+
requests: it connects to the given address while keeping the issuer's URL and
|
|
406
|
+
`Host`, with no DNS or `/etc/hosts` changes.
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
STWRD_ISSUER=https://tenant.example.com
|
|
410
|
+
STWRD_CONNECT_TO=http://127.0.0.1:3005
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
If the app builds its own `httpx.AsyncClient` (a proxy, a custom transport),
|
|
414
|
+
`connect_to_hook(connect_to)` is the same request hook:
|
|
415
|
+
`httpx.AsyncClient(event_hooks={"request": [connect_to_hook(...)]})`.
|
|
416
|
+
|
|
417
|
+
`connect_to` does not affect the front channel: the browser still has to resolve
|
|
418
|
+
the issuer's host. For browserless tests, mount the server and the app with
|
|
419
|
+
`httpx.ASGITransport` instead.
|
|
420
|
+
|
|
421
|
+
## Running the tests
|
|
422
|
+
|
|
423
|
+
```bash
|
|
424
|
+
uv sync --all-extras --all-groups
|
|
425
|
+
uv run pytest tests/
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
The PostgreSQL session-store tests need a PostgreSQL server whose user can
|
|
429
|
+
create and drop databases (each test module gets its own throwaway database).
|
|
430
|
+
They look for it at `postgresql+psycopg://stwrd:stwrd@localhost:55432/stwrd_test`;
|
|
431
|
+
set `STWRD_TEST_DB_URL` to point somewhere else. If no server is reachable those
|
|
432
|
+
tests are skipped with a message saying so, and everything else still runs.
|
|
433
|
+
Set `STWRD_REQUIRE_DB=1` to make an unreachable server a failure instead of a
|
|
434
|
+
skip, which is what a CI pipeline should do. A throwaway server:
|
|
435
|
+
|
|
436
|
+
```bash
|
|
437
|
+
docker run --rm -d --name stwrd-test-pg -p 55432:5432 \
|
|
438
|
+
-e POSTGRES_USER=stwrd -e POSTGRES_PASSWORD=stwrd -e POSTGRES_DB=stwrd_test \
|
|
439
|
+
postgres:16
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
## API reference
|
|
443
|
+
|
|
444
|
+
Everything below is importable from `stwrd` unless noted.
|
|
445
|
+
|
|
446
|
+
### Client and configuration
|
|
447
|
+
|
|
448
|
+
| Name | Description |
|
|
449
|
+
|---|---|
|
|
450
|
+
| `Stwrd(config, *, sessions=None, http_client=None)` | The object an app builds once. `Stwrd.from_env(environ=None, **overrides)` builds it from the environment. |
|
|
451
|
+
| `Stwrd.resolve_session(cookie)` | Current `StwrdSession` or `None`; renews tokens when possible. Raises `IdpUnavailable` when stwrd cannot be reached. |
|
|
452
|
+
| `Stwrd.session_from_cookie(cookie)` | Raw store lookup: no renewal, no expiry side effects. |
|
|
453
|
+
| `Stwrd.csrf(session_id)` | CSRF token for a session. |
|
|
454
|
+
| `Stwrd.seal(value)` / `Stwrd.unseal(cookie)` | Sign and verify the opaque cookie value. |
|
|
455
|
+
| `Stwrd.verify_webhook(body, headers)` | Verify a webhook with the configured secret and de-duplicate it. |
|
|
456
|
+
| `Stwrd.close()` | Close the HTTP client, if the instance created it. |
|
|
457
|
+
| `StwrdConfig` | Frozen configuration; fields as in [Configuration](#configuration). `StwrdConfig.from_env()`, `replace(**changes)`. |
|
|
458
|
+
| `connect_to_hook(connect_to)` | `httpx` request hook behind `connect_to`. |
|
|
459
|
+
|
|
460
|
+
### Session types and stores
|
|
461
|
+
|
|
462
|
+
| Name | Description |
|
|
463
|
+
|---|---|
|
|
464
|
+
| `StwrdSession` | `id`, `sub`, `claims`, `tokens`, `expires_at`; `user`, `organization` and `consents` projections; `has_role()` and `has_permission()`. |
|
|
465
|
+
| `StwrdUser` | `id`, `email`, `email_verified`, `display_name`, `avatar_url`, `roles`, `permissions`. |
|
|
466
|
+
| `StwrdOrganization` | `id`, `display_name`, `roles`, `permissions`. |
|
|
467
|
+
| `Tokens` | `access_token`, `id_token`, `token_type`, `expires_at`, `refresh_token`. |
|
|
468
|
+
| `SessionStore` | Protocol for session storage. |
|
|
469
|
+
| `MemoryStore` | In-process implementation. |
|
|
470
|
+
| `stwrd.postgres.PostgresSessionStore`, `Keyring`, `SCHEMA_SQL` | Shared store, see [Sessions and session stores](#sessions-and-session-stores). |
|
|
471
|
+
|
|
472
|
+
### FastAPI (`stwrd.fastapi`)
|
|
473
|
+
|
|
474
|
+
`auth_router`, `optional_user`, `current_user`, `require_auth`, `require_role`,
|
|
475
|
+
`require_org`, `require_permission`, `api_mode`, `protect` and `safe_target`.
|
|
476
|
+
`safe_target(candidate, fallback="/")` applies the redirect rule used by
|
|
477
|
+
`return_to` and is exported for apps that handle redirect targets of their own.
|
|
478
|
+
|
|
479
|
+
### Webhooks
|
|
480
|
+
|
|
481
|
+
| Name | Description |
|
|
482
|
+
|---|---|
|
|
483
|
+
| `verify_webhook(body, headers, secret, tolerance_s=300, *, seen=None)` | Verify the signature and parse a v1 event into a `WebhookEvent`. |
|
|
484
|
+
| `verify_webhook_signature(body, headers, secret, tolerance_s=300)` | Verify the signature only and return the event id. |
|
|
485
|
+
| `WebhookEvent` | `id`, `type`, `api_version`, `created_at`, `data`. |
|
|
486
|
+
| `safe_equal(a, b)` | Constant-time string comparison. |
|
|
487
|
+
|
|
488
|
+
### Management
|
|
489
|
+
|
|
490
|
+
`create_management`, `ManagementClient`, `ManagementOptions`, `WriteOptions`,
|
|
491
|
+
`ApiResult` and `ManagementError`; the lower-level `ManagementTransport` and
|
|
492
|
+
`validate_management_discovery` live in `stwrd.management`.
|
|
493
|
+
|
|
494
|
+
## Security
|
|
495
|
+
|
|
496
|
+
- **Tokens stay on the server.** The browser only holds an HMAC-signed cookie
|
|
497
|
+
with an opaque session id: `__Host-` prefixed, `HttpOnly`, `Secure`,
|
|
498
|
+
`SameSite=Lax`, path `/`.
|
|
499
|
+
- **Authorization code flow with PKCE (S256).** `state`, `nonce` and the PKCE
|
|
500
|
+
verifier live in a short-lived signed transaction cookie.
|
|
501
|
+
- **ID tokens are verified.** Signature (RS256 and EdDSA only, never taken from
|
|
502
|
+
the token header), `iss`, `aud`, `exp`, `nonce` and `at_hash`. Capabilities
|
|
503
|
+
such as roles and permissions come only from verified ID tokens, never from
|
|
504
|
+
userinfo.
|
|
505
|
+
- **CSRF.** `sign-out` and organization switching require a token derived from
|
|
506
|
+
the session; `GET /auth/session` returns it as `csrf_token`.
|
|
507
|
+
- **Open redirects.** `return_to` accepts only an absolute path of this app,
|
|
508
|
+
and rejects `//host`, backslash forms, control characters and schemes.
|
|
509
|
+
- **Refresh.** A refresh token is exchanged at most once across workers when a
|
|
510
|
+
shared store is used. A silent server never ends a session, and a request
|
|
511
|
+
that may have consumed a refresh token is never replayed.
|
|
512
|
+
- **Webhooks.** HMAC-SHA256 over the raw body, constant-time comparison,
|
|
513
|
+
300-second replay window and de-duplication by event id.
|
|
514
|
+
- **No hand-written cryptography.** JOSE operations use `joserfc`.
|
|
515
|
+
- **Secrets.** Load `STWRD_COOKIE_SECRET`, `STWRD_CLIENT_SECRET` and
|
|
516
|
+
`STWRD_WEBHOOK_SECRET` from your secret manager, never from source control.
|
|
517
|
+
Rotating the cookie secret signs everyone out. Management credentials belong
|
|
518
|
+
on the server only, and the Management client refuses to follow redirects.
|
|
519
|
+
- **Reporting a vulnerability.** See the
|
|
520
|
+
[security policy](https://github.com/stwrd-dev/sdk-python/security/policy)
|
|
521
|
+
for supported versions and how to report a problem privately.
|
|
522
|
+
|
|
523
|
+
## Errors and troubleshooting
|
|
524
|
+
|
|
525
|
+
| Situation | Response |
|
|
526
|
+
|---|---|
|
|
527
|
+
| No session, navigation (`Accept: text/html`) | 303 to `{prefix}/sign-in?return_to=…` |
|
|
528
|
+
| No session, API request (or `api_mode`) | 401 `{"detail": "No session."}` |
|
|
529
|
+
| Role missing | 403 `{"detail": "Missing role org:admin."}` |
|
|
530
|
+
| Permission missing | 403 `{"detail": "Missing permission members:invite."}` |
|
|
531
|
+
| No organization (`require_org`) | 403 `{"detail": "The session has no organization."}` |
|
|
532
|
+
| `POST /auth/sign-out` without a valid CSRF token | 403 `{"detail": "Invalid CSRF token."}` |
|
|
533
|
+
| stwrd does not answer (any route that needs it) | 503 `{"detail": "The IdP did not respond."}`; the session is kept |
|
|
534
|
+
| `GET /auth/callback` rejected (state, signature, nonce…) | 400 plain text |
|
|
535
|
+
| `POST /auth/webhook` | 200 `{"status":"ok"}` · 200 `{"status":"duplicate"}` · 400 `{"error":"invalid_signature"}` · 503 `{"error":"webhook_not_configured"}` |
|
|
536
|
+
| `POST /auth/back-channel` invalid | 400 `{"error": …}` |
|
|
537
|
+
| Refresh rejected by stwrd | The local session ends; the request looks like "no session". |
|
|
538
|
+
| Incomplete configuration or a short cookie secret | `ConfigError` at construction |
|
|
539
|
+
|
|
540
|
+
Exceptions, all importable from `stwrd`: `ConfigError` (invalid configuration),
|
|
541
|
+
`OidcError` (stwrd answered and rejected the request), `IdpUnavailable` (stwrd
|
|
542
|
+
did not answer: connection error, timeout, 5xx, 408, 425 or 429),
|
|
543
|
+
`RefreshUncertain` (a refresh may have been processed without its result being
|
|
544
|
+
stored; the person must sign in again), `InvalidSignatureError` and
|
|
545
|
+
`DuplicateEventError` (webhooks), and `ManagementError` (HTTP errors from the
|
|
546
|
+
Management API, with `status`, `request_id`, `error` and `body`).
|
|
547
|
+
|
|
548
|
+
Common problems:
|
|
549
|
+
|
|
550
|
+
- **Sign-in loops back to the login page on `http://localhost`.** The default
|
|
551
|
+
cookies are `Secure` and `__Host-` prefixed. See the note under
|
|
552
|
+
[Configuration](#configuration) for local development.
|
|
553
|
+
- **`invalid_scope` when starting sign-in.** The scope is requested by the app
|
|
554
|
+
but not allowed for the client, see [Scopes](#scopes).
|
|
555
|
+
- **Users are signed out when the app restarts or scales to several workers.**
|
|
556
|
+
`MemoryStore` is per process; use `PostgresSessionStore`.
|
|
557
|
+
- **503 from every protected route.** The app cannot reach stwrd; check
|
|
558
|
+
`STWRD_ISSUER` and the network. Sessions are preserved meanwhile.
|
|
559
|
+
|
|
560
|
+
## Compatibility
|
|
561
|
+
|
|
562
|
+
- Python 3.12, 3.13 and 3.14.
|
|
563
|
+
- FastAPI 0.115 or newer and Starlette 0.40 or newer (the `fastapi` extra).
|
|
564
|
+
- `httpx` 0.27 or newer and `joserfc` 1.0 or newer.
|
|
565
|
+
- PostgreSQL through `psycopg` 3.2 or newer (the `postgres` extra).
|
|
566
|
+
- Type hints throughout; the package ships a `py.typed` marker.
|
|
567
|
+
|
|
568
|
+
## Versioning
|
|
569
|
+
|
|
570
|
+
The package follows [Semantic Versioning](https://semver.org/). Before 1.0, minor
|
|
571
|
+
releases may contain breaking changes; they are listed in the
|
|
572
|
+
[changelog](https://github.com/stwrd-dev/sdk-python/blob/main/CHANGELOG.md).
|
|
573
|
+
Pin an exact or compatible-release version, for example `stwrd-auth~=0.1.0`.
|
|
574
|
+
|
|
575
|
+
## License
|
|
576
|
+
|
|
577
|
+
[MIT](https://github.com/stwrd-dev/sdk-python/blob/main/LICENSE). Copyright (c) 2026 stwrd.
|