wred-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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ *.egg-info/
8
+ .coverage
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Harrison Erd
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its contributors
17
+ may be used to endorse or promote products derived from this software
18
+ without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,359 @@
1
+ Metadata-Version: 2.4
2
+ Name: wred-auth
3
+ Version: 0.1.0
4
+ Summary: Optional auth integration for Wred
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ License-Expression: BSD-3-Clause
8
+ License-File: LICENSE
9
+ Requires-Dist: wred>=0.13,<0.14
10
+ Requires-Dist: argon2-cffi>=23
11
+ Requires-Dist: PyJWT[crypto]>=2.9,<3
12
+ Requires-Dist: cryptography>=43
13
+ Requires-Dist: authlib>=1.3,<2
14
+ Requires-Dist: httpx>=0.27
15
+ Requires-Dist: pyotp>=2.9,<3
16
+ Requires-Dist: wred-ratelimit>=0.1,<0.2
17
+ Requires-Dist: pymongo>=4.13,<5 ; extra == "mongodb"
18
+ Requires-Dist: sqlalchemy[asyncio]>=2.0,<3 ; extra == "sqlalchemy"
19
+ Requires-Dist: alembic>=1.13 ; extra == "sqlalchemy"
20
+ Requires-Dist: aiosqlite>=0.20 ; extra == "sqlalchemy"
21
+ Requires-Dist: asyncpg>=0.29 ; extra == "sqlalchemy"
22
+ Requires-Dist: pytest>=8 ; extra == "test"
23
+ Requires-Dist: pytest-asyncio>=0.24 ; extra == "test"
24
+ Requires-Dist: httpx>=0.27 ; extra == "test"
25
+ Requires-Dist: wred-pydantic>=0.1,<0.2 ; extra == "test"
26
+ Provides-Extra: mongodb
27
+ Provides-Extra: sqlalchemy
28
+ Provides-Extra: test
29
+
30
+ # wred-auth
31
+
32
+ Bearer authorization and an opt-in account service for Wred 0.13. Python 3.11+.
33
+ This package owns no UI, email service, or application account data beyond its
34
+ explicit identity tables. Version 0.1.0 APIs are not yet stable.
35
+
36
+ ```sh
37
+ pip install './extensions/wred-auth[sqlalchemy]' './extensions/wred-ratelimit[redis]'
38
+ ```
39
+
40
+ ## Authorization without account storage
41
+
42
+ ```python
43
+ from wred import App
44
+ from wred_auth import Auth, AuthError, Principal, principal
45
+
46
+
47
+ async def verify(token):
48
+ # Replace with your token verifier/account lookup.
49
+ if token != "development-only-token":
50
+ raise AuthError()
51
+ return Principal("alice", ("admin",))
52
+
53
+
54
+ auth = Auth(verifier=verify)
55
+ app = App()
56
+
57
+
58
+ @app.route("/orders/{order_id}")
59
+ @auth.require_auth()
60
+ @auth.require_role("admin")
61
+ async def order(request, order_id):
62
+ return {"id": order_id, "user": principal(request).id}
63
+ ```
64
+
65
+ `Principal(id, roles=(), session_id=None, auth_time=0)` is the verifier result.
66
+ Sync and async verifiers are supported. Raise `AuthError` for expected rejection;
67
+ unexpected failures propagate. The principal is stored at `request.scope['wred.auth']`.
68
+ Missing/invalid credentials return 401 with a Bearer challenge; missing roles return
69
+ 403. `require_role` authenticates itself if no principal is present.
70
+
71
+ `JWTVerifier(key, algorithms=[...], issuer=..., audience=..., lookup=None)` verifies
72
+ external JWTs. Algorithms are application configuration, never derived from tokens.
73
+ `lookup(claims)` optionally returns a Principal and can be async; without it, the
74
+ verified subject gets no roles. This adapter does not issue tokens or infer accounts.
75
+
76
+ ## Account setup
77
+
78
+ ```python
79
+ import os
80
+ from wred import App
81
+ from wred_auth import Auth
82
+ from wred_auth.sqlalchemy import SQLAlchemyStore
83
+ from wred_auth.providers import Google, GitHub
84
+ from wred_ratelimit import Limiter
85
+
86
+
87
+ async def send_email(kind, email, url):
88
+ await application_mailer(kind, email, url) # Your implementation.
89
+
90
+
91
+ app = App()
92
+ limiter = Limiter.redis(os.environ["REDIS_URL"], namespace="my-app-auth")
93
+ auth = Auth(
94
+ store=SQLAlchemyStore(os.environ["DATABASE_URL"]),
95
+ signing_key=os.environ["AUTH_SIGNING_KEY"],
96
+ encryption_key=os.environ["AUTH_ENCRYPTION_KEY"],
97
+ issuer="my-app",
98
+ audience="my-api",
99
+ public_url="https://api.example.com",
100
+ send_email=send_email,
101
+ limiter=limiter,
102
+ providers={
103
+ "google": Google(
104
+ os.environ["GOOGLE_CLIENT_ID"], os.environ["GOOGLE_CLIENT_SECRET"]
105
+ ),
106
+ "github": GitHub(
107
+ os.environ["GITHUB_CLIENT_ID"], os.environ["GITHUB_CLIENT_SECRET"]
108
+ ),
109
+ },
110
+ )
111
+ auth.mount(app)
112
+ ```
113
+
114
+ Use a randomly generated signing key of at least 32 bytes and a separate Fernet
115
+ key (`Fernet.generate_key()`). Keep them stable across workers and restarts.
116
+ Encryption protects persisted TOTP secrets. Key rotation currently requires an
117
+ application migration/re-encryption process; no automatic key ring is provided.
118
+
119
+ Account access tokens use HS256 with explicit issuer/audience and a 15-minute
120
+ lifetime. Refresh sessions expire after 30 days by default, without sliding renewal.
121
+ Every access-token authentication checks account/session state. Refresh rotates a
122
+ hashed opaque token transactionally; reuse revokes that session's entire token family.
123
+ Passwords use Argon2id and must contain 15–128 characters. Email identifiers are
124
+ case-folded; whitespace and malformed basic address structure are rejected.
125
+
126
+ SQLAlchemy supports PostgreSQL and SQLite. Use `postgresql+asyncpg://...` or
127
+ `sqlite+aiosqlite:///...`. PostgreSQL uses row locks and serializable write
128
+ transactions with bounded retries; access-token checks use non-locking repeatable
129
+ read snapshots. There is no store-wide advisory lock. SQLite uses `BEGIN IMMEDIATE`
130
+ for writes and remains intended for local/small deployments.
131
+
132
+ Password hashing and verification run outside database transactions. Password and
133
+ session state are rechecked before committing credentials. `max_password_jobs=2`
134
+ limits concurrent Argon2 jobs per `Auth` instance, with no waiting queue; excess
135
+ work returns 503. Cancellation retains the slot until its thread finishes. Size
136
+ this limit for your process memory/CPU budget (the current Argon2 defaults use
137
+ 64 MiB per operation), and normally share one `Auth` instance per worker. Redis
138
+ quotas limit requests across workers; they do not replace this process-local
139
+ resource limit. Invalid reset tokens are rejected before password hashing.
140
+
141
+ Both stores accept `transaction_timeout=5` seconds and `retries=5` by default.
142
+ Retries cover database conflicts, not password hashing, email, or audit callbacks.
143
+ Deadline/retry exhaustion returns 503 through account HTTP endpoints. SQLAlchemy
144
+ engine options and PyMongo client options can be supplied to their store constructors.
145
+
146
+ ## MongoDB
147
+
148
+ Install `pip install 'wred-auth[mongodb]'`. Use MongoDB 6.0+ as a replica set or
149
+ sharded cluster, including Atlas; standalone servers cannot provide the required
150
+ multi-document transactions. Use PyMongo's async driver, not Motor.
151
+
152
+ ```python
153
+ from wred_auth.mongodb import MongoDBStore
154
+
155
+ store = MongoDBStore(os.environ["MONGODB_URL"], database="accounts")
156
+ await store.initialize() # Explicit deployment step: creates collections/indexes.
157
+ # Pass store=store to Auth, with the same remaining configuration as above.
158
+ ```
159
+
160
+ `initialize()` is idempotent and must complete before serving traffic. It checks
161
+ the topology and creates unique email and provider/subject indexes. Transactions
162
+ use primary reads, snapshot read concern, and majority writes. Conflicting writes
163
+ are retried; read-only access checks do not touch documents. MongoDB uses the same
164
+ account fields as SQL and separate `wred_auth_*` collections. Keep those collections
165
+ unsharded unless you design shard keys and uniqueness constraints for your deployment.
166
+
167
+ ## Explicit migrations
168
+
169
+ Create an application `alembic.ini`:
170
+
171
+ ```ini
172
+ [alembic]
173
+ script_location = wred_auth:migrations
174
+ sqlalchemy.url = sqlite+aiosqlite:///accounts.db
175
+ ```
176
+
177
+ Then run `alembic -c alembic.ini upgrade head`. No schema is created during requests.
178
+ Revision `0001` creates users, sessions, tokens, and identities; `0002` adds live-record
179
+ and expiration indexes. Upgrade existing SQL installations before deploying this version.
180
+ Account tables are separate from application domain tables. Custom storage adapters
181
+ must now implement `run(callback, read_only=False)`, `cleanup`, filtered/paginated
182
+ reads, and bulk updates in addition to the transaction API. Callbacks can execute
183
+ more than once and must contain only transactional database work. See the contract
184
+ in `wred_auth.storage` and the bundled implementations. A context-managed
185
+ `store.transaction()` remains available for application code, but does not retry
186
+ its body automatically; use `run()` for conflict retries.
187
+
188
+ ## Retention
189
+
190
+ Schedule cleanup in an application maintenance job; it is never run during requests:
191
+
192
+ ```python
193
+ counts = await auth.cleanup(batch_size=500)
194
+ ```
195
+
196
+ Each call removes at most 500 expired tokens and 500 expired sessions using expiration
197
+ indexes. Run additional batches with a job time budget when a backlog exists.
198
+ Used refresh tokens and revoked sessions are retained until expiry, so cleanup does
199
+ not erase replay detection for live refresh families. Revocation uses bulk database
200
+ updates. Account/user and external-identity records are not deleted by cleanup.
201
+
202
+ ## HTTP endpoints
203
+
204
+ `auth.mount(app, prefix='/auth', rule='10/minute', account_rule='5/minute', key=None)` is opt-in.
205
+ It rejects existing routes. A configured limiter is required. All operations return
206
+ JSON with `Cache-Control: no-store`; requests use JSON bodies except GET operations.
207
+ Service methods also exist, but callers of those methods must apply their own rate
208
+ limits and authorization. `Principal` arguments are trusted application inputs.
209
+
210
+ With `key=None`, mount honors the configured `Limiter(key=...)`. Supply `key=...`
211
+ to override it for account routes; both synchronous and asynchronous functions work.
212
+ The default limiter uses the ASGI peer address. Behind a reverse proxy, configure
213
+ your server's trusted proxy handling to populate the real client address, or provide
214
+ a key from trusted middleware. Never trust arbitrary forwarded headers from clients.
215
+ Use Redis and a shared namespace across workers; the in-memory limiter is process-local.
216
+
217
+ | Method/path under `/auth` | Input |
218
+ |---|---|
219
+ | POST `/register` | `email`, `password` |
220
+ | POST `/verify` | `token` |
221
+ | POST `/request-verification` | `email` |
222
+ | POST `/login` | `email`, `password` |
223
+ | POST `/forgot-password` | `email` |
224
+ | POST `/reset-password` | `token`, `password` |
225
+ | POST `/refresh` | `refresh_token` |
226
+ | POST `/logout` | Bearer credentials, empty object |
227
+ | GET `/sessions` | Bearer credentials; optional `limit` (1–1000, default 100), `after` session ID |
228
+ | POST `/revoke` | Bearer credentials, `session_id` |
229
+ | POST `/change-password` | Bearer credentials, `old_password`, `password` |
230
+ | POST `/mfa-setup` | Recent Bearer credentials, empty object |
231
+ | POST `/mfa-confirm` | Recent Bearer credentials, `code` |
232
+ | POST `/mfa-login` | `challenge`, `code` |
233
+ | POST `/mfa-disable` | Recent Bearer credentials, `code` |
234
+ | GET `/oauth/{provider}/start` | Browser navigation |
235
+ | POST `/oauth/{provider}/link` | Recent Bearer credentials |
236
+ | GET `/oauth/{provider}/callback` | Provider callback query and browser cookie |
237
+
238
+ Registration/recovery return generic responses. Mail delivery is an async callback
239
+ `send_email(kind, email, url)`, where kind is `verify` or `reset`. The URL points to
240
+ `public_url/{kind}?token=...`; your frontend handles it and POSTs the token to the API.
241
+ Delivery failures propagate; users can request new verification/reset instructions.
242
+ Verification/reset tokens expire in 30 minutes. Password changes and resets revoke
243
+ existing sessions, pending MFA challenges, and outstanding password-reset tokens.
244
+ Session pages are ordered by ID; pass the last returned ID as `after` for the next page.
245
+
246
+ Successful password login returns bearer and refresh tokens, or an MFA challenge
247
+ that expires in five minutes. MFA setup returns a provisioning URI; confirmation
248
+ returns ten one-use recovery codes and revokes existing sessions. Used TOTP time
249
+ steps cannot be replayed, with one adjacent step of clock tolerance. MFA changes
250
+ and identity linking require authentication within five minutes; sign in again when
251
+ prompted. Password recovery does not disable MFA.
252
+
253
+ Google uses OIDC issuer/audience/signature/nonce checks. GitHub requires a verified
254
+ primary email. OAuth uses state, PKCE, and a Secure/HttpOnly/SameSite browser-binding
255
+ cookie. Register exact callbacks at `public_url/auth/oauth/{provider}/callback`.
256
+ Callback results return JSON tokens, never credentials in redirect URLs. No provider
257
+ refresh tokens are retained. Existing accounts are never merged by email: users must
258
+ sign in and explicitly link. One browser login flow at a time is supported.
259
+
260
+ Administrative `set_roles(user_id, roles)` and `set_active(user_id, active)` methods
261
+ are intentionally not mounted as public endpoints. Applications enforce administrator
262
+ permissions before invoking them. Optional `audit(event)` receives event, user ID,
263
+ and timestamp; it must be async and must not fail during ordinary operation.
264
+
265
+ Use `async with auth` or `await auth.aclose()` to close its store and limiter.
266
+ Handle lifespan in the hosting application. Configure request body limits at the
267
+ server/proxy; Wred buffers bodies. Credentials, mail, DNS, TLS, and live provider
268
+ configuration belong to the application.
269
+
270
+ ## With wred-pydantic and wred-ratelimit
271
+
272
+ `wred-ratelimit` supplies `Limiter` for request quotas; install its `redis` extra
273
+ to share quotas across workers. `wred-pydantic` supplies the optional `body` and
274
+ `response` decorators for input validation and response conversion.
275
+
276
+ ```sh
277
+ pip install wred-auth 'wred-ratelimit[redis]' wred-pydantic
278
+ ```
279
+
280
+ This example defines its own app, limiter, and auth instance using external RS256
281
+ JWTs. Configure `REDIS_URL`, `AUTH_PUBLIC_KEY` (a PEM public key), `AUTH_ISSUER`, and
282
+ `AUTH_AUDIENCE` in the environment. For the built-in account service, reuse the
283
+ `app`, `auth`, and `limiter` from Account setup instead of creating them again.
284
+
285
+ ```python
286
+ import os
287
+
288
+ from pydantic import BaseModel, Field
289
+ from wred import App
290
+ from wred_auth import Auth, JWTVerifier, principal
291
+ from wred_pydantic import body, response
292
+ from wred_ratelimit import Limiter
293
+
294
+ app = App()
295
+ limiter = Limiter.redis(os.environ["REDIS_URL"], namespace="my-app-auth")
296
+ auth = Auth(
297
+ verifier=JWTVerifier(
298
+ os.environ["AUTH_PUBLIC_KEY"],
299
+ algorithms=["RS256"],
300
+ issuer=os.environ["AUTH_ISSUER"],
301
+ audience=os.environ["AUTH_AUDIENCE"],
302
+ ),
303
+ limiter=limiter,
304
+ )
305
+
306
+ class OrderInput(BaseModel):
307
+ quantity: int = Field(gt=0)
308
+
309
+ class OrderOutput(BaseModel):
310
+ user: str
311
+ quantity: int
312
+
313
+ @app.route("/orders", methods=["POST"])
314
+ @limiter.limit("30/minute")
315
+ @auth.require_auth()
316
+ @body(OrderInput, max_bytes=4096)
317
+ @response(OrderOutput)
318
+ async def create_order(request, body):
319
+ return {"user": principal(request).id, "quantity": body.quantity}
320
+ ```
321
+
322
+ Auth decorators preserve injected keyword arguments and OpenAPI metadata.
323
+ Close `auth` during application shutdown with `await auth.aclose()`; it also closes
324
+ the supplied limiter. Authentication, authorization, validation, and quota failures
325
+ retain their HTTP statuses; the response adapter does not validate non-success payloads. All three extensions
326
+ support Wred 0.13 together with their current dependency constraints.
327
+
328
+ ## Tests
329
+
330
+ ```sh
331
+ pip install -e '.[test,sqlalchemy,mongodb]' '../wred-ratelimit[redis]' '../wred-pydantic'
332
+ pytest
333
+ ```
334
+
335
+ SQLite tests always run. Set `WRED_TEST_POSTGRES` to an isolated PostgreSQL test
336
+ DB to exercise migrations and concurrent refresh. Tests downgrade their account
337
+ tables after each scenario; never point them at application data. Provider tests
338
+ use signed OIDC responses and simulated HTTP exchanges, not live provider accounts.
339
+ Set `WRED_TEST_MONGODB` to a test replica-set URI to run the same account contract
340
+ against MongoDB (each test creates and drops a uniquely named database).
341
+ Set `WRED_TEST_REDIS` to a test Redis URI for shared-quota integration tests.
342
+ Coverage includes transaction rollback/deadlines, independent account progress,
343
+ hash admission/cancellation, password races, retention, and decorator composition.
344
+
345
+ For a repeatable multi-process service benchmark against a disposable database:
346
+
347
+ ```sh
348
+ WRED_BENCH_URL='postgresql+asyncpg://...' python benchmarks/auth_load.py sql
349
+ WRED_BENCH_URL='mongodb://...?replicaSet=rs0' python benchmarks/auth_load.py mongodb
350
+ ```
351
+
352
+ The default workload runs 1,000 operations across two processes: 90% access-token
353
+ checks and 10% password logins. It reports latency percentiles and every error/503,
354
+ and excludes HTTP/proxy, Redis, email, and provider latency. SQL runs leave seeded
355
+ accounts in the supplied disposable database; MongoDB runs remove their uniquely
356
+ named benchmark database. Tune `--workers`, `--concurrency`, `--password-jobs`,
357
+ `--users`, and `--requests` for your workload. This is a local capacity probe, not
358
+ a production SLO or failover certification.
359
+