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.
Files changed (43) hide show
  1. stwrd_auth-0.1.0/.gitignore +36 -0
  2. stwrd_auth-0.1.0/CHANGELOG.md +43 -0
  3. stwrd_auth-0.1.0/LICENSE +21 -0
  4. stwrd_auth-0.1.0/PKG-INFO +577 -0
  5. stwrd_auth-0.1.0/README.md +529 -0
  6. stwrd_auth-0.1.0/SECURITY.md +20 -0
  7. stwrd_auth-0.1.0/docs/management.md +111 -0
  8. stwrd_auth-0.1.0/docs/webhooks.md +201 -0
  9. stwrd_auth-0.1.0/pyproject.toml +153 -0
  10. stwrd_auth-0.1.0/scripts/generate_management.py +450 -0
  11. stwrd_auth-0.1.0/stwrd/__init__.py +49 -0
  12. stwrd_auth-0.1.0/stwrd/client.py +466 -0
  13. stwrd_auth-0.1.0/stwrd/config.py +240 -0
  14. stwrd_auth-0.1.0/stwrd/fastapi.py +733 -0
  15. stwrd_auth-0.1.0/stwrd/management.py +539 -0
  16. stwrd_auth-0.1.0/stwrd/management_generated.py +2180 -0
  17. stwrd_auth-0.1.0/stwrd/oidc.py +537 -0
  18. stwrd_auth-0.1.0/stwrd/organizations.py +79 -0
  19. stwrd_auth-0.1.0/stwrd/postgres.py +336 -0
  20. stwrd_auth-0.1.0/stwrd/py.typed +0 -0
  21. stwrd_auth-0.1.0/stwrd/sessions.py +362 -0
  22. stwrd_auth-0.1.0/stwrd/webhooks.py +251 -0
  23. stwrd_auth-0.1.0/tests/__init__.py +0 -0
  24. stwrd_auth-0.1.0/tests/conftest.py +53 -0
  25. stwrd_auth-0.1.0/tests/fake_idp.py +289 -0
  26. stwrd_auth-0.1.0/tests/test_auth_router.py +965 -0
  27. stwrd_auth-0.1.0/tests/test_client.py +292 -0
  28. stwrd_auth-0.1.0/tests/test_config.py +136 -0
  29. stwrd_auth-0.1.0/tests/test_distribution.py +71 -0
  30. stwrd_auth-0.1.0/tests/test_management.py +554 -0
  31. stwrd_auth-0.1.0/tests/test_management_generator.py +422 -0
  32. stwrd_auth-0.1.0/tests/test_management_transport_properties.py +99 -0
  33. stwrd_auth-0.1.0/tests/test_oidc.py +320 -0
  34. stwrd_auth-0.1.0/tests/test_organization_switch.py +267 -0
  35. stwrd_auth-0.1.0/tests/test_postgres_requirement.py +44 -0
  36. stwrd_auth-0.1.0/tests/test_postgres_store.py +323 -0
  37. stwrd_auth-0.1.0/tests/test_public_surface.py +162 -0
  38. stwrd_auth-0.1.0/tests/test_readme.py +143 -0
  39. stwrd_auth-0.1.0/tests/test_refresh.py +517 -0
  40. stwrd_auth-0.1.0/tests/test_security_policy.py +45 -0
  41. stwrd_auth-0.1.0/tests/test_session_coordination.py +171 -0
  42. stwrd_auth-0.1.0/tests/test_sessions.py +261 -0
  43. 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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/stwrd-auth)](https://pypi.org/project/stwrd-auth/)
52
+ [![Python versions](https://img.shields.io/pypi/pyversions/stwrd-auth)](https://pypi.org/project/stwrd-auth/)
53
+ [![License: MIT](https://img.shields.io/pypi/l/stwrd-auth)](https://github.com/stwrd-dev/sdk-python/blob/main/LICENSE)
54
+ ![Types included](https://img.shields.io/badge/types-included-blue)
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.