py-auth-sqlalchemy 0.0.1__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.
- py_auth_sqlalchemy-0.0.1/LICENSE +21 -0
- py_auth_sqlalchemy-0.0.1/PKG-INFO +330 -0
- py_auth_sqlalchemy-0.0.1/README.md +306 -0
- py_auth_sqlalchemy-0.0.1/pyproject.toml +38 -0
- py_auth_sqlalchemy-0.0.1/setup.cfg +4 -0
- py_auth_sqlalchemy-0.0.1/src/py_auth_sqlalchemy/__init__.py +10 -0
- py_auth_sqlalchemy-0.0.1/src/py_auth_sqlalchemy/core.py +111 -0
- py_auth_sqlalchemy-0.0.1/src/py_auth_sqlalchemy/py.typed +1 -0
- py_auth_sqlalchemy-0.0.1/src/py_auth_sqlalchemy/utils.py +100 -0
- py_auth_sqlalchemy-0.0.1/src/py_auth_sqlalchemy.egg-info/PKG-INFO +330 -0
- py_auth_sqlalchemy-0.0.1/src/py_auth_sqlalchemy.egg-info/SOURCES.txt +12 -0
- py_auth_sqlalchemy-0.0.1/src/py_auth_sqlalchemy.egg-info/dependency_links.txt +1 -0
- py_auth_sqlalchemy-0.0.1/src/py_auth_sqlalchemy.egg-info/requires.txt +3 -0
- py_auth_sqlalchemy-0.0.1/src/py_auth_sqlalchemy.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) [2026] [Olatunji Jamaldeen]
|
|
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,330 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-auth-sqlalchemy
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: High-performance, async SQLAlchemy adapter for py-auth-core.
|
|
5
|
+
Author-email: Olatunji Jamaldeen Omotoyosi <jamaldeen.o@yahoo.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jamaldeen09/py-auth
|
|
8
|
+
Project-URL: Repository, https://github.com/jamaldeen09/py-auth
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: sqlalchemy>=2.0.0
|
|
21
|
+
Requires-Dist: greenlet>=3.0.0
|
|
22
|
+
Requires-Dist: py-auth-core>=0.0.1
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# py-auth-sqlalchemy
|
|
26
|
+
|
|
27
|
+
**High-performance, async SQLAlchemy 2.0 adapter for [py-auth-core](https://pypi.org/project/py-auth-core/).**
|
|
28
|
+
|
|
29
|
+
Plug this adapter into `PyAuth` to get automatic, async session persistence across PostgreSQL, MySQL, and SQLite — with built-in error translation from SQLAlchemy exceptions into `py-auth-core` exceptions.
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/py-auth-sqlalchemy/)
|
|
32
|
+
[](https://pypi.org/project/py-auth-sqlalchemy/)
|
|
33
|
+
[](https://opensource.org/licenses/MIT)
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Table of Contents
|
|
38
|
+
|
|
39
|
+
- [Features](#features)
|
|
40
|
+
- [Installation](#installation)
|
|
41
|
+
- [Supported Databases](#supported-databases)
|
|
42
|
+
- [Quick Start](#quick-start)
|
|
43
|
+
- [Session Model Requirements](#session-model-requirements)
|
|
44
|
+
- [API Reference](#api-reference)
|
|
45
|
+
- [SqlAlchemyAdapter](#sqlalchemyadapter)
|
|
46
|
+
- [Error Translation](#error-translation)
|
|
47
|
+
- [Full FastAPI Example](#full-fastapi-example)
|
|
48
|
+
- [Security Notes](#security-notes)
|
|
49
|
+
- [License](#license)
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Features
|
|
54
|
+
|
|
55
|
+
- ✅ **Async-native** — built entirely on SQLAlchemy `AsyncEngine` + `AsyncSession`
|
|
56
|
+
- ✅ **BYO model** — bring your own declarative SQLAlchemy model; extra columns are fine
|
|
57
|
+
- ✅ **Strict validation at startup** — engine type and required model columns are checked immediately, before any request is served
|
|
58
|
+
- ✅ **Automatic error translation** — `IntegrityError` → `DuplicateEntryError` / `ForeignKeyViolationError`, `NoResultFound` → `RecordNotFoundError`
|
|
59
|
+
- ✅ **Session-only** — user lookup and creation live in your `authorize()` callback, giving you full control
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install py-auth-sqlalchemy
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Then install the async driver for your database:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# PostgreSQL
|
|
73
|
+
pip install asyncpg
|
|
74
|
+
|
|
75
|
+
# MySQL / MariaDB
|
|
76
|
+
pip install aiomysql
|
|
77
|
+
|
|
78
|
+
# SQLite
|
|
79
|
+
pip install aiosqlite
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Requirements:** Python ≥ 3.9, SQLAlchemy ≥ 2.0, py-auth-core ≥ 0.0.1.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Supported Databases
|
|
87
|
+
|
|
88
|
+
| Database | Async driver | Connection URL prefix |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| PostgreSQL | `asyncpg` | `postgresql+asyncpg://` |
|
|
91
|
+
| MySQL / MariaDB | `aiomysql` | `mysql+aiomysql://` |
|
|
92
|
+
| SQLite | `aiosqlite` | `sqlite+aiosqlite:///` |
|
|
93
|
+
|
|
94
|
+
The adapter validates the driver at startup and raises `AdapterError` for unsupported drivers.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Quick Start
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
102
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
103
|
+
from sqlalchemy import DateTime, String
|
|
104
|
+
import uuid, datetime
|
|
105
|
+
|
|
106
|
+
from py_auth_sqlalchemy import SqlAlchemyAdapter
|
|
107
|
+
from py_auth import PyAuth, CredentialsProvider
|
|
108
|
+
|
|
109
|
+
# 1. Define your SQLAlchemy session model
|
|
110
|
+
class Base(DeclarativeBase):
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
class Session(Base):
|
|
114
|
+
__tablename__ = "sessions"
|
|
115
|
+
|
|
116
|
+
# Required columns — do NOT rename these
|
|
117
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
118
|
+
session_token_hash: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
119
|
+
user_id: Mapped[str] = mapped_column(String, nullable=False)
|
|
120
|
+
csrf_token: Mapped[str] = mapped_column(String, nullable=False)
|
|
121
|
+
expires: Mapped[datetime.datetime] = mapped_column(DateTime, nullable=False)
|
|
122
|
+
|
|
123
|
+
# Any extra columns you want are fine
|
|
124
|
+
created_at: Mapped[datetime.datetime] = mapped_column(
|
|
125
|
+
DateTime, default=datetime.datetime.utcnow
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# 2. Create the async engine
|
|
129
|
+
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/mydb")
|
|
130
|
+
|
|
131
|
+
# 3. Create the adapter
|
|
132
|
+
adapter = SqlAlchemyAdapter(engine=engine, session_model=Session)
|
|
133
|
+
|
|
134
|
+
# 4. Wire up PyAuth
|
|
135
|
+
auth = PyAuth(adapter=adapter, providers=[credentials_provider])
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Session Model Requirements
|
|
141
|
+
|
|
142
|
+
Your SQLAlchemy session model **must** define the following columns with exactly these names:
|
|
143
|
+
|
|
144
|
+
| Column | Recommended type | Description |
|
|
145
|
+
|---|---|---|
|
|
146
|
+
| `id` | `String` / `UUID` (primary key) | Unique session identifier |
|
|
147
|
+
| `session_token_hash` | `String` (unique, non-null) | SHA-256 hash of the raw session token |
|
|
148
|
+
| `user_id` | `String` / `UUID` (non-null) | Reference to the owning user |
|
|
149
|
+
| `csrf_token` | `String` (non-null) | Raw CSRF token for double-submit validation |
|
|
150
|
+
| `expires` | `DateTime` (non-null) | Naive UTC datetime when the session expires |
|
|
151
|
+
|
|
152
|
+
> **Extra columns are allowed.** You can freely add `created_at`, `ip_address`, `user_agent`, or any other columns your application needs.
|
|
153
|
+
|
|
154
|
+
If any required column is missing, `SqlAlchemyAdapter.__init__` raises `AdapterError` with a descriptive message listing the missing columns.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## API Reference
|
|
159
|
+
|
|
160
|
+
### `SqlAlchemyAdapter`
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
SqlAlchemyAdapter(
|
|
164
|
+
engine: AsyncEngine,
|
|
165
|
+
session_model: Type[Any],
|
|
166
|
+
)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
| Parameter | Type | Description |
|
|
170
|
+
|---|---|---|
|
|
171
|
+
| `engine` | `AsyncEngine` | An async SQLAlchemy engine created via `create_async_engine` |
|
|
172
|
+
| `session_model` | `Type[DeclarativeBase]` | Your SQLAlchemy declarative model class for sessions |
|
|
173
|
+
|
|
174
|
+
Both parameters are validated immediately in `__init__`:
|
|
175
|
+
- `engine` must be an `AsyncEngine` using a supported async driver (`asyncpg`, `aiomysql`, `aiosqlite`)
|
|
176
|
+
- `session_model` must be a valid SQLAlchemy declarative class with all required columns present
|
|
177
|
+
|
|
178
|
+
**Methods** (all async)
|
|
179
|
+
|
|
180
|
+
| Method | Signature | Description |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| `create_session` | `(session_data: dict) -> dict` | Inserts a new session row and returns it as a dict |
|
|
183
|
+
| `get_session_by_session_token_hash` | `(token_hash: str) -> dict \| None` | Fetches a session by hashed token, or `None` if not found |
|
|
184
|
+
| `update_session` | `(session_id: str, updates: dict) -> dict \| None` | Updates fields on an existing session row and returns the updated record as a dict, or `None` if not found |
|
|
185
|
+
| `delete_session_by_session_token_hash` | `(token_hash: str) -> None` | Deletes a session by hashed token |
|
|
186
|
+
| `delete_session` | `(session_id: Any) -> None` | Deletes a session by its primary key / ID |
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
### Error Translation
|
|
191
|
+
|
|
192
|
+
The adapter automatically maps SQLAlchemy exceptions to `py-auth-core` exceptions so `PyAuth` can handle them uniformly:
|
|
193
|
+
|
|
194
|
+
| SQLAlchemy exception | py-auth-core exception | Trigger |
|
|
195
|
+
|---|---|---|
|
|
196
|
+
| `IntegrityError` (unique / duplicate) | `DuplicateEntryError` (409) | Duplicate `session_token_hash` |
|
|
197
|
+
| `IntegrityError` (foreign key) | `ForeignKeyViolationError` (400) | `user_id` references a non-existent user |
|
|
198
|
+
| `NoResultFound` | `RecordNotFoundError` (404) | Query returned no rows |
|
|
199
|
+
| Any other `Exception` | `PyAuthError` (500) | Unexpected database error |
|
|
200
|
+
|
|
201
|
+
`PyAuth.signin_with_credentials` automatically retries on `DuplicateEntryError` (up to 3 times with a fresh token) without any extra code on your part.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Full FastAPI Example
|
|
206
|
+
|
|
207
|
+
This example shows the full flow: user lookup, sign-in, and sign-up all handled inside a single `authorize()` callback — no separate signup endpoint needed.
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
import datetime, uuid
|
|
211
|
+
from fastapi import FastAPI, Request, Response
|
|
212
|
+
from pydantic import BaseModel, EmailStr
|
|
213
|
+
from passlib.context import CryptContext
|
|
214
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
|
215
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
216
|
+
from sqlalchemy import DateTime, String, select
|
|
217
|
+
|
|
218
|
+
from py_auth import PyAuth, CredentialsProvider
|
|
219
|
+
from py_auth_sqlalchemy import SqlAlchemyAdapter
|
|
220
|
+
|
|
221
|
+
pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
222
|
+
|
|
223
|
+
# --- SQLAlchemy models ---
|
|
224
|
+
class Base(DeclarativeBase):
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
class User(Base):
|
|
228
|
+
__tablename__ = "users"
|
|
229
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
230
|
+
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
231
|
+
hashed_password: Mapped[str] = mapped_column(String, nullable=False)
|
|
232
|
+
|
|
233
|
+
class Session(Base):
|
|
234
|
+
__tablename__ = "sessions"
|
|
235
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
236
|
+
session_token_hash: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
237
|
+
user_id: Mapped[str] = mapped_column(String, nullable=False)
|
|
238
|
+
csrf_token: Mapped[str] = mapped_column(String, nullable=False)
|
|
239
|
+
expires: Mapped[datetime.datetime] = mapped_column(DateTime, nullable=False)
|
|
240
|
+
|
|
241
|
+
# --- Engine and adapter ---
|
|
242
|
+
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/mydb")
|
|
243
|
+
adapter = SqlAlchemyAdapter(engine=engine, session_model=Session)
|
|
244
|
+
|
|
245
|
+
# --- Credentials schema ---
|
|
246
|
+
class LoginSchema(BaseModel):
|
|
247
|
+
email: EmailStr
|
|
248
|
+
password: str
|
|
249
|
+
|
|
250
|
+
# --- authorize() handles both sign-in and sign-up ---
|
|
251
|
+
async def authorize(credentials: dict) -> dict | None:
|
|
252
|
+
"""
|
|
253
|
+
Check if the user exists:
|
|
254
|
+
- If yes, verify their password and return their details.
|
|
255
|
+
- If no, create them and return the new user's details.
|
|
256
|
+
- Return None to reject (wrong password).
|
|
257
|
+
"""
|
|
258
|
+
async with AsyncSession(engine) as db:
|
|
259
|
+
result = await db.execute(select(User).where(User.email == credentials["email"]))
|
|
260
|
+
user = result.scalar_one_or_none()
|
|
261
|
+
|
|
262
|
+
if user:
|
|
263
|
+
# Existing user — verify credentials
|
|
264
|
+
if not pwd_ctx.verify(credentials["password"], user.hashed_password):
|
|
265
|
+
return None
|
|
266
|
+
return {"id": user.id, "email": user.email}
|
|
267
|
+
|
|
268
|
+
# New user — create and return
|
|
269
|
+
async with db.begin():
|
|
270
|
+
new_user = User(
|
|
271
|
+
email=credentials["email"],
|
|
272
|
+
hashed_password=pwd_ctx.hash(credentials["password"]),
|
|
273
|
+
)
|
|
274
|
+
db.add(new_user)
|
|
275
|
+
await db.flush()
|
|
276
|
+
await db.refresh(new_user)
|
|
277
|
+
return {"id": new_user.id, "email": new_user.email}
|
|
278
|
+
|
|
279
|
+
# --- Wire up PyAuth ---
|
|
280
|
+
auth = PyAuth(
|
|
281
|
+
adapter=adapter,
|
|
282
|
+
providers=[CredentialsProvider(model=LoginSchema, authorize=authorize)],
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# --- FastAPI routes ---
|
|
286
|
+
app = FastAPI()
|
|
287
|
+
|
|
288
|
+
@app.on_event("startup")
|
|
289
|
+
async def startup():
|
|
290
|
+
async with engine.begin() as conn:
|
|
291
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
292
|
+
|
|
293
|
+
@app.post("/auth/signin")
|
|
294
|
+
async def signin(request: Request, response: Response):
|
|
295
|
+
body = await request.json()
|
|
296
|
+
result = await auth.signin_with_credentials(body)
|
|
297
|
+
if result["error"]:
|
|
298
|
+
return result
|
|
299
|
+
data = result["data"]
|
|
300
|
+
response.set_cookie(auth.cookies["session_token"]["name"], data["session_token"],
|
|
301
|
+
**auth.cookies["session_token"]["options"])
|
|
302
|
+
response.set_cookie(auth.cookies["csrf_token"]["name"], data["csrf_token"],
|
|
303
|
+
**auth.cookies["csrf_token"]["options"])
|
|
304
|
+
return {"user": data["user"]}
|
|
305
|
+
|
|
306
|
+
@app.get("/auth/verify")
|
|
307
|
+
async def verify(request: Request):
|
|
308
|
+
session_token = request.cookies.get(auth.cookies["session_token"]["name"])
|
|
309
|
+
csrf_token = request.cookies.get(auth.cookies["csrf_token"]["name"])
|
|
310
|
+
return await auth.verify_session(session_token, csrf_token)
|
|
311
|
+
|
|
312
|
+
@app.post("/auth/signout")
|
|
313
|
+
async def signout(session_id: str):
|
|
314
|
+
return await auth.signout(session_id)
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
## Security Notes
|
|
320
|
+
|
|
321
|
+
- **Only hashed session tokens are stored in the database.** The raw token is set in a `httpOnly` cookie and never persisted.
|
|
322
|
+
- **Always hash passwords** before persisting — use bcrypt, argon2, or scrypt. Never store plain text.
|
|
323
|
+
- Use `expire_on_commit=False` (set by the adapter automatically) so ORM instances remain accessible after `session.commit()`.
|
|
324
|
+
- For PostgreSQL, ensure your `Session` table has a **unique index** on `session_token_hash` at the database level to guarantee the uniqueness constraint that `DuplicateEntryError` depends on.
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## License
|
|
329
|
+
|
|
330
|
+
MIT — see [LICENSE](./LICENSE) for details.
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# py-auth-sqlalchemy
|
|
2
|
+
|
|
3
|
+
**High-performance, async SQLAlchemy 2.0 adapter for [py-auth-core](https://pypi.org/project/py-auth-core/).**
|
|
4
|
+
|
|
5
|
+
Plug this adapter into `PyAuth` to get automatic, async session persistence across PostgreSQL, MySQL, and SQLite — with built-in error translation from SQLAlchemy exceptions into `py-auth-core` exceptions.
|
|
6
|
+
|
|
7
|
+
[](https://pypi.org/project/py-auth-sqlalchemy/)
|
|
8
|
+
[](https://pypi.org/project/py-auth-sqlalchemy/)
|
|
9
|
+
[](https://opensource.org/licenses/MIT)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Table of Contents
|
|
14
|
+
|
|
15
|
+
- [Features](#features)
|
|
16
|
+
- [Installation](#installation)
|
|
17
|
+
- [Supported Databases](#supported-databases)
|
|
18
|
+
- [Quick Start](#quick-start)
|
|
19
|
+
- [Session Model Requirements](#session-model-requirements)
|
|
20
|
+
- [API Reference](#api-reference)
|
|
21
|
+
- [SqlAlchemyAdapter](#sqlalchemyadapter)
|
|
22
|
+
- [Error Translation](#error-translation)
|
|
23
|
+
- [Full FastAPI Example](#full-fastapi-example)
|
|
24
|
+
- [Security Notes](#security-notes)
|
|
25
|
+
- [License](#license)
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- ✅ **Async-native** — built entirely on SQLAlchemy `AsyncEngine` + `AsyncSession`
|
|
32
|
+
- ✅ **BYO model** — bring your own declarative SQLAlchemy model; extra columns are fine
|
|
33
|
+
- ✅ **Strict validation at startup** — engine type and required model columns are checked immediately, before any request is served
|
|
34
|
+
- ✅ **Automatic error translation** — `IntegrityError` → `DuplicateEntryError` / `ForeignKeyViolationError`, `NoResultFound` → `RecordNotFoundError`
|
|
35
|
+
- ✅ **Session-only** — user lookup and creation live in your `authorize()` callback, giving you full control
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install py-auth-sqlalchemy
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then install the async driver for your database:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# PostgreSQL
|
|
49
|
+
pip install asyncpg
|
|
50
|
+
|
|
51
|
+
# MySQL / MariaDB
|
|
52
|
+
pip install aiomysql
|
|
53
|
+
|
|
54
|
+
# SQLite
|
|
55
|
+
pip install aiosqlite
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Requirements:** Python ≥ 3.9, SQLAlchemy ≥ 2.0, py-auth-core ≥ 0.0.1.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Supported Databases
|
|
63
|
+
|
|
64
|
+
| Database | Async driver | Connection URL prefix |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| PostgreSQL | `asyncpg` | `postgresql+asyncpg://` |
|
|
67
|
+
| MySQL / MariaDB | `aiomysql` | `mysql+aiomysql://` |
|
|
68
|
+
| SQLite | `aiosqlite` | `sqlite+aiosqlite:///` |
|
|
69
|
+
|
|
70
|
+
The adapter validates the driver at startup and raises `AdapterError` for unsupported drivers.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Quick Start
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
78
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
79
|
+
from sqlalchemy import DateTime, String
|
|
80
|
+
import uuid, datetime
|
|
81
|
+
|
|
82
|
+
from py_auth_sqlalchemy import SqlAlchemyAdapter
|
|
83
|
+
from py_auth import PyAuth, CredentialsProvider
|
|
84
|
+
|
|
85
|
+
# 1. Define your SQLAlchemy session model
|
|
86
|
+
class Base(DeclarativeBase):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
class Session(Base):
|
|
90
|
+
__tablename__ = "sessions"
|
|
91
|
+
|
|
92
|
+
# Required columns — do NOT rename these
|
|
93
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
94
|
+
session_token_hash: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
95
|
+
user_id: Mapped[str] = mapped_column(String, nullable=False)
|
|
96
|
+
csrf_token: Mapped[str] = mapped_column(String, nullable=False)
|
|
97
|
+
expires: Mapped[datetime.datetime] = mapped_column(DateTime, nullable=False)
|
|
98
|
+
|
|
99
|
+
# Any extra columns you want are fine
|
|
100
|
+
created_at: Mapped[datetime.datetime] = mapped_column(
|
|
101
|
+
DateTime, default=datetime.datetime.utcnow
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# 2. Create the async engine
|
|
105
|
+
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/mydb")
|
|
106
|
+
|
|
107
|
+
# 3. Create the adapter
|
|
108
|
+
adapter = SqlAlchemyAdapter(engine=engine, session_model=Session)
|
|
109
|
+
|
|
110
|
+
# 4. Wire up PyAuth
|
|
111
|
+
auth = PyAuth(adapter=adapter, providers=[credentials_provider])
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Session Model Requirements
|
|
117
|
+
|
|
118
|
+
Your SQLAlchemy session model **must** define the following columns with exactly these names:
|
|
119
|
+
|
|
120
|
+
| Column | Recommended type | Description |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `id` | `String` / `UUID` (primary key) | Unique session identifier |
|
|
123
|
+
| `session_token_hash` | `String` (unique, non-null) | SHA-256 hash of the raw session token |
|
|
124
|
+
| `user_id` | `String` / `UUID` (non-null) | Reference to the owning user |
|
|
125
|
+
| `csrf_token` | `String` (non-null) | Raw CSRF token for double-submit validation |
|
|
126
|
+
| `expires` | `DateTime` (non-null) | Naive UTC datetime when the session expires |
|
|
127
|
+
|
|
128
|
+
> **Extra columns are allowed.** You can freely add `created_at`, `ip_address`, `user_agent`, or any other columns your application needs.
|
|
129
|
+
|
|
130
|
+
If any required column is missing, `SqlAlchemyAdapter.__init__` raises `AdapterError` with a descriptive message listing the missing columns.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## API Reference
|
|
135
|
+
|
|
136
|
+
### `SqlAlchemyAdapter`
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
SqlAlchemyAdapter(
|
|
140
|
+
engine: AsyncEngine,
|
|
141
|
+
session_model: Type[Any],
|
|
142
|
+
)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
| Parameter | Type | Description |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `engine` | `AsyncEngine` | An async SQLAlchemy engine created via `create_async_engine` |
|
|
148
|
+
| `session_model` | `Type[DeclarativeBase]` | Your SQLAlchemy declarative model class for sessions |
|
|
149
|
+
|
|
150
|
+
Both parameters are validated immediately in `__init__`:
|
|
151
|
+
- `engine` must be an `AsyncEngine` using a supported async driver (`asyncpg`, `aiomysql`, `aiosqlite`)
|
|
152
|
+
- `session_model` must be a valid SQLAlchemy declarative class with all required columns present
|
|
153
|
+
|
|
154
|
+
**Methods** (all async)
|
|
155
|
+
|
|
156
|
+
| Method | Signature | Description |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| `create_session` | `(session_data: dict) -> dict` | Inserts a new session row and returns it as a dict |
|
|
159
|
+
| `get_session_by_session_token_hash` | `(token_hash: str) -> dict \| None` | Fetches a session by hashed token, or `None` if not found |
|
|
160
|
+
| `update_session` | `(session_id: str, updates: dict) -> dict \| None` | Updates fields on an existing session row and returns the updated record as a dict, or `None` if not found |
|
|
161
|
+
| `delete_session_by_session_token_hash` | `(token_hash: str) -> None` | Deletes a session by hashed token |
|
|
162
|
+
| `delete_session` | `(session_id: Any) -> None` | Deletes a session by its primary key / ID |
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
### Error Translation
|
|
167
|
+
|
|
168
|
+
The adapter automatically maps SQLAlchemy exceptions to `py-auth-core` exceptions so `PyAuth` can handle them uniformly:
|
|
169
|
+
|
|
170
|
+
| SQLAlchemy exception | py-auth-core exception | Trigger |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| `IntegrityError` (unique / duplicate) | `DuplicateEntryError` (409) | Duplicate `session_token_hash` |
|
|
173
|
+
| `IntegrityError` (foreign key) | `ForeignKeyViolationError` (400) | `user_id` references a non-existent user |
|
|
174
|
+
| `NoResultFound` | `RecordNotFoundError` (404) | Query returned no rows |
|
|
175
|
+
| Any other `Exception` | `PyAuthError` (500) | Unexpected database error |
|
|
176
|
+
|
|
177
|
+
`PyAuth.signin_with_credentials` automatically retries on `DuplicateEntryError` (up to 3 times with a fresh token) without any extra code on your part.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Full FastAPI Example
|
|
182
|
+
|
|
183
|
+
This example shows the full flow: user lookup, sign-in, and sign-up all handled inside a single `authorize()` callback — no separate signup endpoint needed.
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
import datetime, uuid
|
|
187
|
+
from fastapi import FastAPI, Request, Response
|
|
188
|
+
from pydantic import BaseModel, EmailStr
|
|
189
|
+
from passlib.context import CryptContext
|
|
190
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
|
191
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
192
|
+
from sqlalchemy import DateTime, String, select
|
|
193
|
+
|
|
194
|
+
from py_auth import PyAuth, CredentialsProvider
|
|
195
|
+
from py_auth_sqlalchemy import SqlAlchemyAdapter
|
|
196
|
+
|
|
197
|
+
pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
198
|
+
|
|
199
|
+
# --- SQLAlchemy models ---
|
|
200
|
+
class Base(DeclarativeBase):
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
class User(Base):
|
|
204
|
+
__tablename__ = "users"
|
|
205
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
206
|
+
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
207
|
+
hashed_password: Mapped[str] = mapped_column(String, nullable=False)
|
|
208
|
+
|
|
209
|
+
class Session(Base):
|
|
210
|
+
__tablename__ = "sessions"
|
|
211
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
212
|
+
session_token_hash: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
213
|
+
user_id: Mapped[str] = mapped_column(String, nullable=False)
|
|
214
|
+
csrf_token: Mapped[str] = mapped_column(String, nullable=False)
|
|
215
|
+
expires: Mapped[datetime.datetime] = mapped_column(DateTime, nullable=False)
|
|
216
|
+
|
|
217
|
+
# --- Engine and adapter ---
|
|
218
|
+
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/mydb")
|
|
219
|
+
adapter = SqlAlchemyAdapter(engine=engine, session_model=Session)
|
|
220
|
+
|
|
221
|
+
# --- Credentials schema ---
|
|
222
|
+
class LoginSchema(BaseModel):
|
|
223
|
+
email: EmailStr
|
|
224
|
+
password: str
|
|
225
|
+
|
|
226
|
+
# --- authorize() handles both sign-in and sign-up ---
|
|
227
|
+
async def authorize(credentials: dict) -> dict | None:
|
|
228
|
+
"""
|
|
229
|
+
Check if the user exists:
|
|
230
|
+
- If yes, verify their password and return their details.
|
|
231
|
+
- If no, create them and return the new user's details.
|
|
232
|
+
- Return None to reject (wrong password).
|
|
233
|
+
"""
|
|
234
|
+
async with AsyncSession(engine) as db:
|
|
235
|
+
result = await db.execute(select(User).where(User.email == credentials["email"]))
|
|
236
|
+
user = result.scalar_one_or_none()
|
|
237
|
+
|
|
238
|
+
if user:
|
|
239
|
+
# Existing user — verify credentials
|
|
240
|
+
if not pwd_ctx.verify(credentials["password"], user.hashed_password):
|
|
241
|
+
return None
|
|
242
|
+
return {"id": user.id, "email": user.email}
|
|
243
|
+
|
|
244
|
+
# New user — create and return
|
|
245
|
+
async with db.begin():
|
|
246
|
+
new_user = User(
|
|
247
|
+
email=credentials["email"],
|
|
248
|
+
hashed_password=pwd_ctx.hash(credentials["password"]),
|
|
249
|
+
)
|
|
250
|
+
db.add(new_user)
|
|
251
|
+
await db.flush()
|
|
252
|
+
await db.refresh(new_user)
|
|
253
|
+
return {"id": new_user.id, "email": new_user.email}
|
|
254
|
+
|
|
255
|
+
# --- Wire up PyAuth ---
|
|
256
|
+
auth = PyAuth(
|
|
257
|
+
adapter=adapter,
|
|
258
|
+
providers=[CredentialsProvider(model=LoginSchema, authorize=authorize)],
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# --- FastAPI routes ---
|
|
262
|
+
app = FastAPI()
|
|
263
|
+
|
|
264
|
+
@app.on_event("startup")
|
|
265
|
+
async def startup():
|
|
266
|
+
async with engine.begin() as conn:
|
|
267
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
268
|
+
|
|
269
|
+
@app.post("/auth/signin")
|
|
270
|
+
async def signin(request: Request, response: Response):
|
|
271
|
+
body = await request.json()
|
|
272
|
+
result = await auth.signin_with_credentials(body)
|
|
273
|
+
if result["error"]:
|
|
274
|
+
return result
|
|
275
|
+
data = result["data"]
|
|
276
|
+
response.set_cookie(auth.cookies["session_token"]["name"], data["session_token"],
|
|
277
|
+
**auth.cookies["session_token"]["options"])
|
|
278
|
+
response.set_cookie(auth.cookies["csrf_token"]["name"], data["csrf_token"],
|
|
279
|
+
**auth.cookies["csrf_token"]["options"])
|
|
280
|
+
return {"user": data["user"]}
|
|
281
|
+
|
|
282
|
+
@app.get("/auth/verify")
|
|
283
|
+
async def verify(request: Request):
|
|
284
|
+
session_token = request.cookies.get(auth.cookies["session_token"]["name"])
|
|
285
|
+
csrf_token = request.cookies.get(auth.cookies["csrf_token"]["name"])
|
|
286
|
+
return await auth.verify_session(session_token, csrf_token)
|
|
287
|
+
|
|
288
|
+
@app.post("/auth/signout")
|
|
289
|
+
async def signout(session_id: str):
|
|
290
|
+
return await auth.signout(session_id)
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Security Notes
|
|
296
|
+
|
|
297
|
+
- **Only hashed session tokens are stored in the database.** The raw token is set in a `httpOnly` cookie and never persisted.
|
|
298
|
+
- **Always hash passwords** before persisting — use bcrypt, argon2, or scrypt. Never store plain text.
|
|
299
|
+
- Use `expire_on_commit=False` (set by the adapter automatically) so ORM instances remain accessible after `session.commit()`.
|
|
300
|
+
- For PostgreSQL, ensure your `Session` table has a **unique index** on `session_token_hash` at the database level to guarantee the uniqueness constraint that `DuplicateEntryError` depends on.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## License
|
|
305
|
+
|
|
306
|
+
MIT — see [LICENSE](./LICENSE) for details.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "py-auth-sqlalchemy"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Olatunji Jamaldeen Omotoyosi", email = "jamaldeen.o@yahoo.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "High-performance, async SQLAlchemy adapter for py-auth-core."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.9",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
]
|
|
24
|
+
license = "MIT"
|
|
25
|
+
license-files = ["LICENSE"]
|
|
26
|
+
|
|
27
|
+
dependencies = [
|
|
28
|
+
"sqlalchemy>=2.0.0",
|
|
29
|
+
"greenlet>=3.0.0",
|
|
30
|
+
"py-auth-core>=0.0.1",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
"Homepage" = "https://github.com/jamaldeen09/py-auth"
|
|
35
|
+
"Repository" = "https://github.com/jamaldeen09/py-auth"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from .utils import handle_db_errors, validate_async_engine, validate_sqlalchemy_model
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional, Type
|
|
4
|
+
from sqlalchemy import delete, select
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
|
6
|
+
|
|
7
|
+
class SqlAlchemyAdapter:
|
|
8
|
+
"""SQLAlchemy ORM adapter for py-auth.
|
|
9
|
+
|
|
10
|
+
Provides asynchronous session persistence using SQLAlchemy 2.0.
|
|
11
|
+
User lookup and creation is handled entirely within the authorize()
|
|
12
|
+
callback of your CredentialsProvider.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
engine: AsyncEngine,
|
|
18
|
+
session_model: Optional[Type[Any]] = None,
|
|
19
|
+
):
|
|
20
|
+
self.session_model = validate_sqlalchemy_model(
|
|
21
|
+
model=session_model,
|
|
22
|
+
model_name="Session",
|
|
23
|
+
required_cols={
|
|
24
|
+
"id",
|
|
25
|
+
"session_token_hash",
|
|
26
|
+
"user_id",
|
|
27
|
+
"expires",
|
|
28
|
+
"csrf_token",
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
self.engine = validate_async_engine(engine=engine)
|
|
33
|
+
self.session_maker = async_sessionmaker(
|
|
34
|
+
bind=self.engine, class_=AsyncSession, expire_on_commit=False
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def _row_to_dict(self, instance: Any) -> Optional[Dict[str, Any]]:
|
|
38
|
+
"""Convert ORM instance to a dictionary using its table column names."""
|
|
39
|
+
if instance is None:
|
|
40
|
+
return None
|
|
41
|
+
cols = [c.name for c in instance.__table__.columns]
|
|
42
|
+
return {name: getattr(instance, name) for name in cols}
|
|
43
|
+
|
|
44
|
+
async def create_session(self, session_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
45
|
+
"""Create and persist a new session record."""
|
|
46
|
+
async with handle_db_errors(operation="create_session"):
|
|
47
|
+
async with self.session_maker() as session:
|
|
48
|
+
async with session.begin():
|
|
49
|
+
s = self.session_model(**session_data)
|
|
50
|
+
session.add(s)
|
|
51
|
+
await session.flush()
|
|
52
|
+
await session.refresh(s)
|
|
53
|
+
return self._row_to_dict(s)
|
|
54
|
+
|
|
55
|
+
async def update_session(
|
|
56
|
+
self, session_id: str, updates: Dict[str, Any]
|
|
57
|
+
) -> Optional[Dict[str, Any]]:
|
|
58
|
+
async with handle_db_errors(operation="update_session"):
|
|
59
|
+
async with self.session_maker() as session:
|
|
60
|
+
async with session.begin():
|
|
61
|
+
stmt = (
|
|
62
|
+
select(self.session_model)
|
|
63
|
+
.where(self.session_model.id == session_id)
|
|
64
|
+
.with_for_update()
|
|
65
|
+
)
|
|
66
|
+
result = await session.execute(stmt)
|
|
67
|
+
s = result.scalars().first()
|
|
68
|
+
if s is None:
|
|
69
|
+
return None
|
|
70
|
+
for k, v in updates.items():
|
|
71
|
+
if hasattr(s, k) and k != "id":
|
|
72
|
+
setattr(s, k, v)
|
|
73
|
+
await session.refresh(s)
|
|
74
|
+
return self._row_to_dict(s)
|
|
75
|
+
|
|
76
|
+
async def get_session_by_session_token_hash(
|
|
77
|
+
self, session_token_hash: str
|
|
78
|
+
) -> Optional[Dict[str, Any]]:
|
|
79
|
+
"""Retrieve a session record by its hashed session token."""
|
|
80
|
+
async with handle_db_errors(operation="get_session_by_session_token_hash"):
|
|
81
|
+
async with self.session_maker() as session:
|
|
82
|
+
stmt = select(self.session_model).where(
|
|
83
|
+
self.session_model.session_token_hash == session_token_hash
|
|
84
|
+
)
|
|
85
|
+
result = await session.execute(stmt)
|
|
86
|
+
s = result.scalar_one_or_none()
|
|
87
|
+
if not s:
|
|
88
|
+
return None
|
|
89
|
+
return self._row_to_dict(s)
|
|
90
|
+
|
|
91
|
+
async def delete_session_by_session_token_hash(
|
|
92
|
+
self, session_token_hash: str
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Delete a session record by its hashed session token."""
|
|
95
|
+
async with handle_db_errors(operation="delete_session_by_session_token_hash"):
|
|
96
|
+
async with self.session_maker() as session:
|
|
97
|
+
async with session.begin():
|
|
98
|
+
stmt = delete(self.session_model).where(
|
|
99
|
+
self.session_model.session_token_hash == session_token_hash
|
|
100
|
+
)
|
|
101
|
+
await session.execute(stmt)
|
|
102
|
+
|
|
103
|
+
async def delete_session(self, session_id: Any) -> None:
|
|
104
|
+
"""Delete a session record by its unique identifier."""
|
|
105
|
+
async with handle_db_errors(operation="delete_session_by_id"):
|
|
106
|
+
async with self.session_maker() as session:
|
|
107
|
+
async with session.begin():
|
|
108
|
+
stmt = delete(self.session_model).where(
|
|
109
|
+
self.session_model.id == session_id
|
|
110
|
+
)
|
|
111
|
+
await session.execute(stmt)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from py_auth.exceptions import (
|
|
5
|
+
AdapterError,
|
|
6
|
+
DuplicateEntryError,
|
|
7
|
+
ForeignKeyViolationError,
|
|
8
|
+
PyAuthError,
|
|
9
|
+
RecordNotFoundError,
|
|
10
|
+
)
|
|
11
|
+
from sqlalchemy import inspect
|
|
12
|
+
from sqlalchemy.exc import IntegrityError, NoResultFound
|
|
13
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_async_engine(engine: object) -> AsyncEngine:
|
|
17
|
+
"""Validate that the engine is an asynchronous SQLAlchemy AsyncEngine
|
|
18
|
+
and uses a supported asynchronous database driver.
|
|
19
|
+
"""
|
|
20
|
+
if not isinstance(engine, AsyncEngine):
|
|
21
|
+
raise TypeError(
|
|
22
|
+
"Invalid database engine. py-auth requires an asynchronous SQLAlchemy "
|
|
23
|
+
"'AsyncEngine' (created via create_async_engine)."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Inspect the driver prefix from the engine's URL object
|
|
27
|
+
supported_drivers = ("asyncpg", "aiomysql", "aiosqlite")
|
|
28
|
+
driver = engine.url.get_driver_name()
|
|
29
|
+
|
|
30
|
+
if driver not in supported_drivers:
|
|
31
|
+
raise AdapterError(
|
|
32
|
+
f"Unsupported database driver '{driver}'. "
|
|
33
|
+
f"py-auth requires one of the following async drivers: {supported_drivers}"
|
|
34
|
+
)
|
|
35
|
+
return engine
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_sqlalchemy_model(
|
|
39
|
+
model: type[Any],
|
|
40
|
+
required_cols: set[str],
|
|
41
|
+
model_name: str | None = None,
|
|
42
|
+
) -> type[Any]:
|
|
43
|
+
"""Validate that the provided class is a valid SQLAlchemy declarative model
|
|
44
|
+
and defines all required py-auth columns.
|
|
45
|
+
"""
|
|
46
|
+
if model is None:
|
|
47
|
+
name = model_name or "Model"
|
|
48
|
+
raise AdapterError(f"'{name}' model is required and cannot be None.")
|
|
49
|
+
|
|
50
|
+
display_name = getattr(model, "__name__", None) or model_name or "Model"
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
mapper = inspect(model)
|
|
54
|
+
col_names = {c.key for c in mapper.columns}
|
|
55
|
+
except Exception as e:
|
|
56
|
+
raise AdapterError(
|
|
57
|
+
f"Provided '{display_name}' must be a valid SQLAlchemy model class: {e}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if not required_cols.issubset(col_names):
|
|
61
|
+
missing = sorted(required_cols - col_names)
|
|
62
|
+
raise AdapterError(
|
|
63
|
+
f"Custom {display_name} model is missing required py-auth columns: {missing}. "
|
|
64
|
+
"Extra custom columns are allowed, but these base columns are mandatory."
|
|
65
|
+
)
|
|
66
|
+
return model
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@asynccontextmanager
|
|
70
|
+
async def handle_db_errors(operation: str):
|
|
71
|
+
"""Context manager for the SQLAlchemy adapter to intercept database-specific
|
|
72
|
+
exceptions and translate them into py-auth core exceptions.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
yield
|
|
76
|
+
except IntegrityError as e:
|
|
77
|
+
error_msg = str(e.orig or e).lower()
|
|
78
|
+
if "foreign key" in error_msg or "violates foreign key" in error_msg:
|
|
79
|
+
raise ForeignKeyViolationError(
|
|
80
|
+
f"Failed to complete operation '{operation}': the referenced record does not exist."
|
|
81
|
+
) from e
|
|
82
|
+
elif "unique" in error_msg or "duplicate" in error_msg:
|
|
83
|
+
raise DuplicateEntryError(
|
|
84
|
+
f"Failed to complete operation '{operation}': a record with this unique value already exists."
|
|
85
|
+
) from e
|
|
86
|
+
raise PyAuthError(
|
|
87
|
+
f"Database integrity error occurred in operation '{operation}': {e}"
|
|
88
|
+
) from e
|
|
89
|
+
|
|
90
|
+
except NoResultFound as e:
|
|
91
|
+
raise RecordNotFoundError(
|
|
92
|
+
f"Failed to complete operation '{operation}': the requested record was not found."
|
|
93
|
+
) from e
|
|
94
|
+
|
|
95
|
+
except PyAuthError:
|
|
96
|
+
raise
|
|
97
|
+
except Exception as e:
|
|
98
|
+
raise PyAuthError(
|
|
99
|
+
f"An unexpected database error occurred in operation '{operation}': {e}"
|
|
100
|
+
) from e
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-auth-sqlalchemy
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: High-performance, async SQLAlchemy adapter for py-auth-core.
|
|
5
|
+
Author-email: Olatunji Jamaldeen Omotoyosi <jamaldeen.o@yahoo.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jamaldeen09/py-auth
|
|
8
|
+
Project-URL: Repository, https://github.com/jamaldeen09/py-auth
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: sqlalchemy>=2.0.0
|
|
21
|
+
Requires-Dist: greenlet>=3.0.0
|
|
22
|
+
Requires-Dist: py-auth-core>=0.0.1
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# py-auth-sqlalchemy
|
|
26
|
+
|
|
27
|
+
**High-performance, async SQLAlchemy 2.0 adapter for [py-auth-core](https://pypi.org/project/py-auth-core/).**
|
|
28
|
+
|
|
29
|
+
Plug this adapter into `PyAuth` to get automatic, async session persistence across PostgreSQL, MySQL, and SQLite — with built-in error translation from SQLAlchemy exceptions into `py-auth-core` exceptions.
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/py-auth-sqlalchemy/)
|
|
32
|
+
[](https://pypi.org/project/py-auth-sqlalchemy/)
|
|
33
|
+
[](https://opensource.org/licenses/MIT)
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Table of Contents
|
|
38
|
+
|
|
39
|
+
- [Features](#features)
|
|
40
|
+
- [Installation](#installation)
|
|
41
|
+
- [Supported Databases](#supported-databases)
|
|
42
|
+
- [Quick Start](#quick-start)
|
|
43
|
+
- [Session Model Requirements](#session-model-requirements)
|
|
44
|
+
- [API Reference](#api-reference)
|
|
45
|
+
- [SqlAlchemyAdapter](#sqlalchemyadapter)
|
|
46
|
+
- [Error Translation](#error-translation)
|
|
47
|
+
- [Full FastAPI Example](#full-fastapi-example)
|
|
48
|
+
- [Security Notes](#security-notes)
|
|
49
|
+
- [License](#license)
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Features
|
|
54
|
+
|
|
55
|
+
- ✅ **Async-native** — built entirely on SQLAlchemy `AsyncEngine` + `AsyncSession`
|
|
56
|
+
- ✅ **BYO model** — bring your own declarative SQLAlchemy model; extra columns are fine
|
|
57
|
+
- ✅ **Strict validation at startup** — engine type and required model columns are checked immediately, before any request is served
|
|
58
|
+
- ✅ **Automatic error translation** — `IntegrityError` → `DuplicateEntryError` / `ForeignKeyViolationError`, `NoResultFound` → `RecordNotFoundError`
|
|
59
|
+
- ✅ **Session-only** — user lookup and creation live in your `authorize()` callback, giving you full control
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install py-auth-sqlalchemy
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Then install the async driver for your database:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# PostgreSQL
|
|
73
|
+
pip install asyncpg
|
|
74
|
+
|
|
75
|
+
# MySQL / MariaDB
|
|
76
|
+
pip install aiomysql
|
|
77
|
+
|
|
78
|
+
# SQLite
|
|
79
|
+
pip install aiosqlite
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Requirements:** Python ≥ 3.9, SQLAlchemy ≥ 2.0, py-auth-core ≥ 0.0.1.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Supported Databases
|
|
87
|
+
|
|
88
|
+
| Database | Async driver | Connection URL prefix |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| PostgreSQL | `asyncpg` | `postgresql+asyncpg://` |
|
|
91
|
+
| MySQL / MariaDB | `aiomysql` | `mysql+aiomysql://` |
|
|
92
|
+
| SQLite | `aiosqlite` | `sqlite+aiosqlite:///` |
|
|
93
|
+
|
|
94
|
+
The adapter validates the driver at startup and raises `AdapterError` for unsupported drivers.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Quick Start
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
102
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
103
|
+
from sqlalchemy import DateTime, String
|
|
104
|
+
import uuid, datetime
|
|
105
|
+
|
|
106
|
+
from py_auth_sqlalchemy import SqlAlchemyAdapter
|
|
107
|
+
from py_auth import PyAuth, CredentialsProvider
|
|
108
|
+
|
|
109
|
+
# 1. Define your SQLAlchemy session model
|
|
110
|
+
class Base(DeclarativeBase):
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
class Session(Base):
|
|
114
|
+
__tablename__ = "sessions"
|
|
115
|
+
|
|
116
|
+
# Required columns — do NOT rename these
|
|
117
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
118
|
+
session_token_hash: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
119
|
+
user_id: Mapped[str] = mapped_column(String, nullable=False)
|
|
120
|
+
csrf_token: Mapped[str] = mapped_column(String, nullable=False)
|
|
121
|
+
expires: Mapped[datetime.datetime] = mapped_column(DateTime, nullable=False)
|
|
122
|
+
|
|
123
|
+
# Any extra columns you want are fine
|
|
124
|
+
created_at: Mapped[datetime.datetime] = mapped_column(
|
|
125
|
+
DateTime, default=datetime.datetime.utcnow
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# 2. Create the async engine
|
|
129
|
+
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/mydb")
|
|
130
|
+
|
|
131
|
+
# 3. Create the adapter
|
|
132
|
+
adapter = SqlAlchemyAdapter(engine=engine, session_model=Session)
|
|
133
|
+
|
|
134
|
+
# 4. Wire up PyAuth
|
|
135
|
+
auth = PyAuth(adapter=adapter, providers=[credentials_provider])
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Session Model Requirements
|
|
141
|
+
|
|
142
|
+
Your SQLAlchemy session model **must** define the following columns with exactly these names:
|
|
143
|
+
|
|
144
|
+
| Column | Recommended type | Description |
|
|
145
|
+
|---|---|---|
|
|
146
|
+
| `id` | `String` / `UUID` (primary key) | Unique session identifier |
|
|
147
|
+
| `session_token_hash` | `String` (unique, non-null) | SHA-256 hash of the raw session token |
|
|
148
|
+
| `user_id` | `String` / `UUID` (non-null) | Reference to the owning user |
|
|
149
|
+
| `csrf_token` | `String` (non-null) | Raw CSRF token for double-submit validation |
|
|
150
|
+
| `expires` | `DateTime` (non-null) | Naive UTC datetime when the session expires |
|
|
151
|
+
|
|
152
|
+
> **Extra columns are allowed.** You can freely add `created_at`, `ip_address`, `user_agent`, or any other columns your application needs.
|
|
153
|
+
|
|
154
|
+
If any required column is missing, `SqlAlchemyAdapter.__init__` raises `AdapterError` with a descriptive message listing the missing columns.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## API Reference
|
|
159
|
+
|
|
160
|
+
### `SqlAlchemyAdapter`
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
SqlAlchemyAdapter(
|
|
164
|
+
engine: AsyncEngine,
|
|
165
|
+
session_model: Type[Any],
|
|
166
|
+
)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
| Parameter | Type | Description |
|
|
170
|
+
|---|---|---|
|
|
171
|
+
| `engine` | `AsyncEngine` | An async SQLAlchemy engine created via `create_async_engine` |
|
|
172
|
+
| `session_model` | `Type[DeclarativeBase]` | Your SQLAlchemy declarative model class for sessions |
|
|
173
|
+
|
|
174
|
+
Both parameters are validated immediately in `__init__`:
|
|
175
|
+
- `engine` must be an `AsyncEngine` using a supported async driver (`asyncpg`, `aiomysql`, `aiosqlite`)
|
|
176
|
+
- `session_model` must be a valid SQLAlchemy declarative class with all required columns present
|
|
177
|
+
|
|
178
|
+
**Methods** (all async)
|
|
179
|
+
|
|
180
|
+
| Method | Signature | Description |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| `create_session` | `(session_data: dict) -> dict` | Inserts a new session row and returns it as a dict |
|
|
183
|
+
| `get_session_by_session_token_hash` | `(token_hash: str) -> dict \| None` | Fetches a session by hashed token, or `None` if not found |
|
|
184
|
+
| `update_session` | `(session_id: str, updates: dict) -> dict \| None` | Updates fields on an existing session row and returns the updated record as a dict, or `None` if not found |
|
|
185
|
+
| `delete_session_by_session_token_hash` | `(token_hash: str) -> None` | Deletes a session by hashed token |
|
|
186
|
+
| `delete_session` | `(session_id: Any) -> None` | Deletes a session by its primary key / ID |
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
### Error Translation
|
|
191
|
+
|
|
192
|
+
The adapter automatically maps SQLAlchemy exceptions to `py-auth-core` exceptions so `PyAuth` can handle them uniformly:
|
|
193
|
+
|
|
194
|
+
| SQLAlchemy exception | py-auth-core exception | Trigger |
|
|
195
|
+
|---|---|---|
|
|
196
|
+
| `IntegrityError` (unique / duplicate) | `DuplicateEntryError` (409) | Duplicate `session_token_hash` |
|
|
197
|
+
| `IntegrityError` (foreign key) | `ForeignKeyViolationError` (400) | `user_id` references a non-existent user |
|
|
198
|
+
| `NoResultFound` | `RecordNotFoundError` (404) | Query returned no rows |
|
|
199
|
+
| Any other `Exception` | `PyAuthError` (500) | Unexpected database error |
|
|
200
|
+
|
|
201
|
+
`PyAuth.signin_with_credentials` automatically retries on `DuplicateEntryError` (up to 3 times with a fresh token) without any extra code on your part.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Full FastAPI Example
|
|
206
|
+
|
|
207
|
+
This example shows the full flow: user lookup, sign-in, and sign-up all handled inside a single `authorize()` callback — no separate signup endpoint needed.
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
import datetime, uuid
|
|
211
|
+
from fastapi import FastAPI, Request, Response
|
|
212
|
+
from pydantic import BaseModel, EmailStr
|
|
213
|
+
from passlib.context import CryptContext
|
|
214
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
|
215
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
216
|
+
from sqlalchemy import DateTime, String, select
|
|
217
|
+
|
|
218
|
+
from py_auth import PyAuth, CredentialsProvider
|
|
219
|
+
from py_auth_sqlalchemy import SqlAlchemyAdapter
|
|
220
|
+
|
|
221
|
+
pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
222
|
+
|
|
223
|
+
# --- SQLAlchemy models ---
|
|
224
|
+
class Base(DeclarativeBase):
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
class User(Base):
|
|
228
|
+
__tablename__ = "users"
|
|
229
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
230
|
+
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
231
|
+
hashed_password: Mapped[str] = mapped_column(String, nullable=False)
|
|
232
|
+
|
|
233
|
+
class Session(Base):
|
|
234
|
+
__tablename__ = "sessions"
|
|
235
|
+
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
236
|
+
session_token_hash: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
237
|
+
user_id: Mapped[str] = mapped_column(String, nullable=False)
|
|
238
|
+
csrf_token: Mapped[str] = mapped_column(String, nullable=False)
|
|
239
|
+
expires: Mapped[datetime.datetime] = mapped_column(DateTime, nullable=False)
|
|
240
|
+
|
|
241
|
+
# --- Engine and adapter ---
|
|
242
|
+
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/mydb")
|
|
243
|
+
adapter = SqlAlchemyAdapter(engine=engine, session_model=Session)
|
|
244
|
+
|
|
245
|
+
# --- Credentials schema ---
|
|
246
|
+
class LoginSchema(BaseModel):
|
|
247
|
+
email: EmailStr
|
|
248
|
+
password: str
|
|
249
|
+
|
|
250
|
+
# --- authorize() handles both sign-in and sign-up ---
|
|
251
|
+
async def authorize(credentials: dict) -> dict | None:
|
|
252
|
+
"""
|
|
253
|
+
Check if the user exists:
|
|
254
|
+
- If yes, verify their password and return their details.
|
|
255
|
+
- If no, create them and return the new user's details.
|
|
256
|
+
- Return None to reject (wrong password).
|
|
257
|
+
"""
|
|
258
|
+
async with AsyncSession(engine) as db:
|
|
259
|
+
result = await db.execute(select(User).where(User.email == credentials["email"]))
|
|
260
|
+
user = result.scalar_one_or_none()
|
|
261
|
+
|
|
262
|
+
if user:
|
|
263
|
+
# Existing user — verify credentials
|
|
264
|
+
if not pwd_ctx.verify(credentials["password"], user.hashed_password):
|
|
265
|
+
return None
|
|
266
|
+
return {"id": user.id, "email": user.email}
|
|
267
|
+
|
|
268
|
+
# New user — create and return
|
|
269
|
+
async with db.begin():
|
|
270
|
+
new_user = User(
|
|
271
|
+
email=credentials["email"],
|
|
272
|
+
hashed_password=pwd_ctx.hash(credentials["password"]),
|
|
273
|
+
)
|
|
274
|
+
db.add(new_user)
|
|
275
|
+
await db.flush()
|
|
276
|
+
await db.refresh(new_user)
|
|
277
|
+
return {"id": new_user.id, "email": new_user.email}
|
|
278
|
+
|
|
279
|
+
# --- Wire up PyAuth ---
|
|
280
|
+
auth = PyAuth(
|
|
281
|
+
adapter=adapter,
|
|
282
|
+
providers=[CredentialsProvider(model=LoginSchema, authorize=authorize)],
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# --- FastAPI routes ---
|
|
286
|
+
app = FastAPI()
|
|
287
|
+
|
|
288
|
+
@app.on_event("startup")
|
|
289
|
+
async def startup():
|
|
290
|
+
async with engine.begin() as conn:
|
|
291
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
292
|
+
|
|
293
|
+
@app.post("/auth/signin")
|
|
294
|
+
async def signin(request: Request, response: Response):
|
|
295
|
+
body = await request.json()
|
|
296
|
+
result = await auth.signin_with_credentials(body)
|
|
297
|
+
if result["error"]:
|
|
298
|
+
return result
|
|
299
|
+
data = result["data"]
|
|
300
|
+
response.set_cookie(auth.cookies["session_token"]["name"], data["session_token"],
|
|
301
|
+
**auth.cookies["session_token"]["options"])
|
|
302
|
+
response.set_cookie(auth.cookies["csrf_token"]["name"], data["csrf_token"],
|
|
303
|
+
**auth.cookies["csrf_token"]["options"])
|
|
304
|
+
return {"user": data["user"]}
|
|
305
|
+
|
|
306
|
+
@app.get("/auth/verify")
|
|
307
|
+
async def verify(request: Request):
|
|
308
|
+
session_token = request.cookies.get(auth.cookies["session_token"]["name"])
|
|
309
|
+
csrf_token = request.cookies.get(auth.cookies["csrf_token"]["name"])
|
|
310
|
+
return await auth.verify_session(session_token, csrf_token)
|
|
311
|
+
|
|
312
|
+
@app.post("/auth/signout")
|
|
313
|
+
async def signout(session_id: str):
|
|
314
|
+
return await auth.signout(session_id)
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
## Security Notes
|
|
320
|
+
|
|
321
|
+
- **Only hashed session tokens are stored in the database.** The raw token is set in a `httpOnly` cookie and never persisted.
|
|
322
|
+
- **Always hash passwords** before persisting — use bcrypt, argon2, or scrypt. Never store plain text.
|
|
323
|
+
- Use `expire_on_commit=False` (set by the adapter automatically) so ORM instances remain accessible after `session.commit()`.
|
|
324
|
+
- For PostgreSQL, ensure your `Session` table has a **unique index** on `session_token_hash` at the database level to guarantee the uniqueness constraint that `DuplicateEntryError` depends on.
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## License
|
|
329
|
+
|
|
330
|
+
MIT — see [LICENSE](./LICENSE) for details.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/py_auth_sqlalchemy/__init__.py
|
|
5
|
+
src/py_auth_sqlalchemy/core.py
|
|
6
|
+
src/py_auth_sqlalchemy/py.typed
|
|
7
|
+
src/py_auth_sqlalchemy/utils.py
|
|
8
|
+
src/py_auth_sqlalchemy.egg-info/PKG-INFO
|
|
9
|
+
src/py_auth_sqlalchemy.egg-info/SOURCES.txt
|
|
10
|
+
src/py_auth_sqlalchemy.egg-info/dependency_links.txt
|
|
11
|
+
src/py_auth_sqlalchemy.egg-info/requires.txt
|
|
12
|
+
src/py_auth_sqlalchemy.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
py_auth_sqlalchemy
|