fastapi-fullauth 0.3.0__tar.gz → 0.4.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.
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/CHANGELOG.md +22 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/PKG-INFO +45 -1
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/README.md +41 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/__init__.py +1 -1
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/base.py +20 -1
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/memory.py +28 -1
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/sqlalchemy/adapter.py +76 -1
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/sqlalchemy/models.py +19 -1
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/sqlmodel/adapter.py +81 -2
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/sqlmodel/models.py +19 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/config.py +5 -1
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/dependencies/__init__.py +3 -4
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/dependencies/current_user.py +0 -1
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/exceptions.py +17 -0
- fastapi_fullauth-0.4.0/fastapi_fullauth/flows/oauth.py +123 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/fullauth.py +67 -10
- fastapi_fullauth-0.4.0/fastapi_fullauth/oauth/__init__.py +9 -0
- fastapi_fullauth-0.4.0/fastapi_fullauth/oauth/base.py +55 -0
- fastapi_fullauth-0.4.0/fastapi_fullauth/oauth/github.py +79 -0
- fastapi_fullauth-0.4.0/fastapi_fullauth/oauth/google.py +65 -0
- fastapi_fullauth-0.4.0/fastapi_fullauth/protection/ratelimit.py +166 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/router/auth.py +3 -3
- fastapi_fullauth-0.4.0/fastapi_fullauth/router/oauth.py +173 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/types.py +22 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/pyproject.toml +3 -1
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_dx_improvements.py +5 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_middleware.py +83 -0
- fastapi_fullauth-0.4.0/tests/test_oauth.py +270 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/uv.lock +8 -2
- fastapi_fullauth-0.3.0/fastapi_fullauth/protection/ratelimit.py +0 -90
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/.github/workflows/ci.yml +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/.github/workflows/publish.yml +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/.gitignore +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/.python-version +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/LICENSE +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/memory_app/__init__.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/memory_app/auth.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/memory_app/main.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/memory_app/routes.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/sqlmodel_app/__init__.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/sqlmodel_app/auth.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/sqlmodel_app/config.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/sqlmodel_app/main.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/sqlmodel_app/models.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/sqlmodel_app/routes.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/__init__.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/sqlalchemy/__init__.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/sqlmodel/__init__.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/backends/__init__.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/backends/base.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/backends/bearer.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/backends/cookie.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/core/__init__.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/core/crypto.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/core/redis_blacklist.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/core/tokens.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/dependencies/require_role.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/flows/__init__.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/flows/email_verify.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/flows/login.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/flows/logout.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/flows/password_reset.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/flows/register.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/hooks.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/middleware/__init__.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/middleware/csrf.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/middleware/ratelimit.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/middleware/security_headers.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/migrations/__init__.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/migrations/helpers.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/protection/__init__.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/protection/lockout.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/rbac/__init__.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/router/__init__.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/utils.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/validators.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/__init__.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/conftest.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_auth_flows.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_crypto.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_customization.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_email_verify.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_lockout.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_new_endpoints.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_refresh_tokens.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_roles.py +0 -0
- {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_tokens.py +0 -0
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **OAuth2 social login** — Google and GitHub out of the box, extensible for custom providers
|
|
8
|
+
- `GET /oauth/{provider}/authorize` — get authorization URL
|
|
9
|
+
- `POST /oauth/{provider}/callback` — exchange code for JWT tokens
|
|
10
|
+
- `GET /oauth/providers` — list configured providers
|
|
11
|
+
- `GET /oauth/accounts` — list linked OAuth accounts
|
|
12
|
+
- `DELETE /oauth/accounts/{provider}` — unlink a provider (with lockout prevention)
|
|
13
|
+
- `OAuthProvider` abstract base class for implementing custom providers
|
|
14
|
+
- `OAuthAccount` and `OAuthUserInfo` types
|
|
15
|
+
- `OAuthAccountRecord` / `OAuthAccountModel` for SQLModel and SQLAlchemy adapters
|
|
16
|
+
- OAuth adapter methods on all adapters (memory, SQLModel, SQLAlchemy)
|
|
17
|
+
- `OAUTH_PROVIDERS`, `OAUTH_STATE_EXPIRE_SECONDS`, `OAUTH_AUTO_LINK_BY_EMAIL` config fields
|
|
18
|
+
- `after_oauth_login` hook event
|
|
19
|
+
- `oauth` optional dependency group (`pip install fastapi-fullauth[oauth]`)
|
|
20
|
+
- Auto-link OAuth to existing user by email (configurable)
|
|
21
|
+
- Auto-verify email when provider confirms it
|
|
22
|
+
- Lockout prevention — can't unlink last login method
|
|
23
|
+
- Multiple `redirect_uris` per OAuth provider — supports web, mobile, and production frontends from one config. Client passes `?redirect_uri=` on authorize, validated against allowed list.
|
|
24
|
+
|
|
3
25
|
## 0.3.0
|
|
4
26
|
|
|
5
27
|
### Breaking changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-fullauth
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Production-grade, async-native authentication and authorization library for FastAPI
|
|
5
5
|
Project-URL: Homepage, https://github.com/mdfarhankc/fastapi-fullauth
|
|
6
6
|
Project-URL: Documentation, https://github.com/mdfarhankc/fastapi-fullauth
|
|
@@ -28,9 +28,12 @@ Requires-Dist: pyjwt>=2.8
|
|
|
28
28
|
Requires-Dist: uuid-utils>=0.14.1
|
|
29
29
|
Provides-Extra: all
|
|
30
30
|
Requires-Dist: alembic>=1.13; extra == 'all'
|
|
31
|
+
Requires-Dist: httpx>=0.25; extra == 'all'
|
|
31
32
|
Requires-Dist: redis>=5.0; extra == 'all'
|
|
32
33
|
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'all'
|
|
33
34
|
Requires-Dist: sqlmodel>=0.0.16; extra == 'all'
|
|
35
|
+
Provides-Extra: oauth
|
|
36
|
+
Requires-Dist: httpx>=0.25; extra == 'oauth'
|
|
34
37
|
Provides-Extra: redis
|
|
35
38
|
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
36
39
|
Provides-Extra: sqlalchemy
|
|
@@ -59,6 +62,8 @@ pip install fastapi-fullauth[sqlmodel]
|
|
|
59
62
|
pip install fastapi-fullauth[sqlalchemy]
|
|
60
63
|
# with redis for token blacklisting:
|
|
61
64
|
pip install fastapi-fullauth[sqlmodel,redis]
|
|
65
|
+
# with OAuth2 social login:
|
|
66
|
+
pip install fastapi-fullauth[sqlmodel,oauth]
|
|
62
67
|
```
|
|
63
68
|
|
|
64
69
|
## Quick start
|
|
@@ -213,6 +218,45 @@ fullauth = FullAuth(
|
|
|
213
218
|
|
|
214
219
|
When switching algorithms, existing users are transparently rehashed on their next login.
|
|
215
220
|
|
|
221
|
+
## OAuth2 social login
|
|
222
|
+
|
|
223
|
+
Add Google and/or GitHub login with a few config lines:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
fullauth = FullAuth(
|
|
227
|
+
secret_key="...",
|
|
228
|
+
adapter=adapter,
|
|
229
|
+
oauth_providers={
|
|
230
|
+
"google": {
|
|
231
|
+
"client_id": "your-google-client-id",
|
|
232
|
+
"client_secret": "your-google-secret",
|
|
233
|
+
"redirect_uris": [
|
|
234
|
+
"http://localhost:3000/auth/callback",
|
|
235
|
+
"https://myapp.com/auth/callback",
|
|
236
|
+
"myapp://auth/callback", # Flutter deep link
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
"github": {
|
|
240
|
+
"client_id": "your-github-client-id",
|
|
241
|
+
"client_secret": "your-github-secret",
|
|
242
|
+
"redirect_uri": "http://localhost:3000/auth/callback", # single URI also works
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
This registers these routes automatically:
|
|
249
|
+
|
|
250
|
+
- `GET /auth/oauth/providers` — list configured providers
|
|
251
|
+
- `GET /auth/oauth/{provider}/authorize?redirect_uri=...` — get the authorization URL (optional `redirect_uri` param, validated against allowed list, defaults to first)
|
|
252
|
+
- `POST /auth/oauth/{provider}/callback` — exchange code for JWT tokens
|
|
253
|
+
- `GET /auth/oauth/accounts` — list linked OAuth accounts
|
|
254
|
+
- `DELETE /auth/oauth/accounts/{provider}` — unlink a provider
|
|
255
|
+
|
|
256
|
+
Users can link multiple providers and keep email/password login alongside OAuth. New users are auto-created on first OAuth login, and existing users are auto-linked by email.
|
|
257
|
+
|
|
258
|
+
Requires `httpx`: `pip install fastapi-fullauth[oauth]`
|
|
259
|
+
|
|
216
260
|
## Route control
|
|
217
261
|
|
|
218
262
|
```python
|
|
@@ -16,6 +16,8 @@ pip install fastapi-fullauth[sqlmodel]
|
|
|
16
16
|
pip install fastapi-fullauth[sqlalchemy]
|
|
17
17
|
# with redis for token blacklisting:
|
|
18
18
|
pip install fastapi-fullauth[sqlmodel,redis]
|
|
19
|
+
# with OAuth2 social login:
|
|
20
|
+
pip install fastapi-fullauth[sqlmodel,oauth]
|
|
19
21
|
```
|
|
20
22
|
|
|
21
23
|
## Quick start
|
|
@@ -170,6 +172,45 @@ fullauth = FullAuth(
|
|
|
170
172
|
|
|
171
173
|
When switching algorithms, existing users are transparently rehashed on their next login.
|
|
172
174
|
|
|
175
|
+
## OAuth2 social login
|
|
176
|
+
|
|
177
|
+
Add Google and/or GitHub login with a few config lines:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
fullauth = FullAuth(
|
|
181
|
+
secret_key="...",
|
|
182
|
+
adapter=adapter,
|
|
183
|
+
oauth_providers={
|
|
184
|
+
"google": {
|
|
185
|
+
"client_id": "your-google-client-id",
|
|
186
|
+
"client_secret": "your-google-secret",
|
|
187
|
+
"redirect_uris": [
|
|
188
|
+
"http://localhost:3000/auth/callback",
|
|
189
|
+
"https://myapp.com/auth/callback",
|
|
190
|
+
"myapp://auth/callback", # Flutter deep link
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
"github": {
|
|
194
|
+
"client_id": "your-github-client-id",
|
|
195
|
+
"client_secret": "your-github-secret",
|
|
196
|
+
"redirect_uri": "http://localhost:3000/auth/callback", # single URI also works
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
This registers these routes automatically:
|
|
203
|
+
|
|
204
|
+
- `GET /auth/oauth/providers` — list configured providers
|
|
205
|
+
- `GET /auth/oauth/{provider}/authorize?redirect_uri=...` — get the authorization URL (optional `redirect_uri` param, validated against allowed list, defaults to first)
|
|
206
|
+
- `POST /auth/oauth/{provider}/callback` — exchange code for JWT tokens
|
|
207
|
+
- `GET /auth/oauth/accounts` — list linked OAuth accounts
|
|
208
|
+
- `DELETE /auth/oauth/accounts/{provider}` — unlink a provider
|
|
209
|
+
|
|
210
|
+
Users can link multiple providers and keep email/password login alongside OAuth. New users are auto-created on first OAuth login, and existing users are auto-linked by email.
|
|
211
|
+
|
|
212
|
+
Requires `httpx`: `pip install fastapi-fullauth[oauth]`
|
|
213
|
+
|
|
173
214
|
## Route control
|
|
174
215
|
|
|
175
216
|
```python
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from abc import ABC, abstractmethod
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
|
-
from fastapi_fullauth.types import CreateUserSchema, RefreshToken, UserSchema
|
|
4
|
+
from fastapi_fullauth.types import CreateUserSchema, OAuthAccount, RefreshToken, UserSchema
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class AbstractUserAdapter(ABC):
|
|
@@ -66,3 +66,22 @@ class AbstractUserAdapter(ABC):
|
|
|
66
66
|
|
|
67
67
|
@abstractmethod
|
|
68
68
|
async def remove_role(self, user_id: str, role_name: str) -> None: ...
|
|
69
|
+
|
|
70
|
+
# ── OAuth (optional — override when using OAuth) ─────────────────
|
|
71
|
+
|
|
72
|
+
async def get_oauth_account(self, provider: str, provider_user_id: str) -> OAuthAccount | None:
|
|
73
|
+
raise NotImplementedError("Implement OAuth adapter methods to use OAuth")
|
|
74
|
+
|
|
75
|
+
async def get_user_oauth_accounts(self, user_id: str) -> list[OAuthAccount]:
|
|
76
|
+
raise NotImplementedError("Implement OAuth adapter methods to use OAuth")
|
|
77
|
+
|
|
78
|
+
async def create_oauth_account(self, data: OAuthAccount) -> OAuthAccount:
|
|
79
|
+
raise NotImplementedError("Implement OAuth adapter methods to use OAuth")
|
|
80
|
+
|
|
81
|
+
async def update_oauth_account(
|
|
82
|
+
self, provider: str, provider_user_id: str, data: dict[str, Any]
|
|
83
|
+
) -> OAuthAccount | None:
|
|
84
|
+
raise NotImplementedError("Implement OAuth adapter methods to use OAuth")
|
|
85
|
+
|
|
86
|
+
async def delete_oauth_account(self, provider: str, provider_user_id: str) -> None:
|
|
87
|
+
raise NotImplementedError("Implement OAuth adapter methods to use OAuth")
|
|
@@ -3,7 +3,7 @@ from typing import Any
|
|
|
3
3
|
from uuid_utils import uuid7
|
|
4
4
|
|
|
5
5
|
from fastapi_fullauth.adapters.base import AbstractUserAdapter
|
|
6
|
-
from fastapi_fullauth.types import CreateUserSchema, RefreshToken, UserSchema
|
|
6
|
+
from fastapi_fullauth.types import CreateUserSchema, OAuthAccount, RefreshToken, UserSchema
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class InMemoryAdapter(AbstractUserAdapter):
|
|
@@ -13,6 +13,7 @@ class InMemoryAdapter(AbstractUserAdapter):
|
|
|
13
13
|
self._passwords: dict[str, str] = {}
|
|
14
14
|
self._refresh_tokens: dict[str, RefreshToken] = {}
|
|
15
15
|
self._roles: dict[str, list[str]] = {}
|
|
16
|
+
self._oauth_accounts: dict[tuple[str, str], OAuthAccount] = {}
|
|
16
17
|
|
|
17
18
|
async def get_user_by_id(self, user_id: str) -> UserSchema | None:
|
|
18
19
|
data = self._users.get(user_id)
|
|
@@ -104,3 +105,29 @@ class InMemoryAdapter(AbstractUserAdapter):
|
|
|
104
105
|
roles.remove(role_name)
|
|
105
106
|
if user_id in self._users:
|
|
106
107
|
self._users[user_id]["roles"] = roles
|
|
108
|
+
|
|
109
|
+
# ── OAuth ────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
async def get_oauth_account(self, provider: str, provider_user_id: str) -> OAuthAccount | None:
|
|
112
|
+
return self._oauth_accounts.get((provider, provider_user_id))
|
|
113
|
+
|
|
114
|
+
async def get_user_oauth_accounts(self, user_id: str) -> list[OAuthAccount]:
|
|
115
|
+
return [a for a in self._oauth_accounts.values() if a.user_id == user_id]
|
|
116
|
+
|
|
117
|
+
async def create_oauth_account(self, data: OAuthAccount) -> OAuthAccount:
|
|
118
|
+
self._oauth_accounts[(data.provider, data.provider_user_id)] = data
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
async def update_oauth_account(
|
|
122
|
+
self, provider: str, provider_user_id: str, data: dict[str, Any]
|
|
123
|
+
) -> OAuthAccount | None:
|
|
124
|
+
key = (provider, provider_user_id)
|
|
125
|
+
account = self._oauth_accounts.get(key)
|
|
126
|
+
if account is None:
|
|
127
|
+
return None
|
|
128
|
+
updated = account.model_copy(update=data)
|
|
129
|
+
self._oauth_accounts[key] = updated
|
|
130
|
+
return updated
|
|
131
|
+
|
|
132
|
+
async def delete_oauth_account(self, provider: str, provider_user_id: str) -> None:
|
|
133
|
+
self._oauth_accounts.pop((provider, provider_user_id), None)
|
{fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/sqlalchemy/adapter.py
RENAMED
|
@@ -6,11 +6,12 @@ from sqlalchemy.orm import selectinload
|
|
|
6
6
|
|
|
7
7
|
from fastapi_fullauth.adapters.base import AbstractUserAdapter
|
|
8
8
|
from fastapi_fullauth.adapters.sqlalchemy.models import (
|
|
9
|
+
OAuthAccountModel,
|
|
9
10
|
RefreshTokenModel,
|
|
10
11
|
RoleModel,
|
|
11
12
|
UserBase,
|
|
12
13
|
)
|
|
13
|
-
from fastapi_fullauth.types import CreateUserSchema, RefreshToken, UserSchema
|
|
14
|
+
from fastapi_fullauth.types import CreateUserSchema, OAuthAccount, RefreshToken, UserSchema
|
|
14
15
|
|
|
15
16
|
# Map SQLAlchemy column types to Python types for schema auto-derivation
|
|
16
17
|
_SA_TYPE_MAP: dict[type, type] = {}
|
|
@@ -253,3 +254,77 @@ class SQLAlchemyAdapter(AbstractUserAdapter):
|
|
|
253
254
|
if user:
|
|
254
255
|
user.roles = [r for r in user.roles if r.name != role_name]
|
|
255
256
|
await session.commit()
|
|
257
|
+
|
|
258
|
+
# ── OAuth ────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
def _to_oauth_account(self, row: OAuthAccountModel) -> OAuthAccount:
|
|
261
|
+
return OAuthAccount(
|
|
262
|
+
provider=row.provider,
|
|
263
|
+
provider_user_id=row.provider_user_id,
|
|
264
|
+
user_id=str(row.user_id),
|
|
265
|
+
provider_email=row.provider_email,
|
|
266
|
+
access_token=row.access_token,
|
|
267
|
+
refresh_token=row.refresh_token,
|
|
268
|
+
expires_at=row.expires_at,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
async def get_oauth_account(self, provider: str, provider_user_id: str) -> OAuthAccount | None:
|
|
272
|
+
async with self._session_maker() as session:
|
|
273
|
+
result = await session.execute(
|
|
274
|
+
select(OAuthAccountModel).where(
|
|
275
|
+
OAuthAccountModel.provider == provider,
|
|
276
|
+
OAuthAccountModel.provider_user_id == provider_user_id,
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
row = result.scalars().first()
|
|
280
|
+
return self._to_oauth_account(row) if row else None
|
|
281
|
+
|
|
282
|
+
async def get_user_oauth_accounts(self, user_id: str) -> list[OAuthAccount]:
|
|
283
|
+
async with self._session_maker() as session:
|
|
284
|
+
result = await session.execute(
|
|
285
|
+
select(OAuthAccountModel).where(OAuthAccountModel.user_id == user_id)
|
|
286
|
+
)
|
|
287
|
+
return [self._to_oauth_account(row) for row in result.scalars().all()]
|
|
288
|
+
|
|
289
|
+
async def create_oauth_account(self, data: OAuthAccount) -> OAuthAccount:
|
|
290
|
+
async with self._session_maker() as session:
|
|
291
|
+
record = OAuthAccountModel(
|
|
292
|
+
provider=data.provider,
|
|
293
|
+
provider_user_id=data.provider_user_id,
|
|
294
|
+
user_id=data.user_id,
|
|
295
|
+
provider_email=data.provider_email,
|
|
296
|
+
access_token=data.access_token,
|
|
297
|
+
refresh_token=data.refresh_token,
|
|
298
|
+
expires_at=data.expires_at,
|
|
299
|
+
)
|
|
300
|
+
session.add(record)
|
|
301
|
+
await session.commit()
|
|
302
|
+
return data
|
|
303
|
+
|
|
304
|
+
async def update_oauth_account(
|
|
305
|
+
self, provider: str, provider_user_id: str, data: dict[str, Any]
|
|
306
|
+
) -> OAuthAccount | None:
|
|
307
|
+
async with self._session_maker() as session:
|
|
308
|
+
await session.execute(
|
|
309
|
+
update(OAuthAccountModel)
|
|
310
|
+
.where(
|
|
311
|
+
OAuthAccountModel.provider == provider,
|
|
312
|
+
OAuthAccountModel.provider_user_id == provider_user_id,
|
|
313
|
+
)
|
|
314
|
+
.values(**data)
|
|
315
|
+
)
|
|
316
|
+
await session.commit()
|
|
317
|
+
return await self.get_oauth_account(provider, provider_user_id)
|
|
318
|
+
|
|
319
|
+
async def delete_oauth_account(self, provider: str, provider_user_id: str) -> None:
|
|
320
|
+
async with self._session_maker() as session:
|
|
321
|
+
result = await session.execute(
|
|
322
|
+
select(OAuthAccountModel).where(
|
|
323
|
+
OAuthAccountModel.provider == provider,
|
|
324
|
+
OAuthAccountModel.provider_user_id == provider_user_id,
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
row = result.scalars().first()
|
|
328
|
+
if row:
|
|
329
|
+
await session.delete(row)
|
|
330
|
+
await session.commit()
|
{fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/sqlalchemy/models.py
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from datetime import datetime, timezone
|
|
2
2
|
from uuid import UUID
|
|
3
3
|
|
|
4
|
-
from sqlalchemy import Boolean, DateTime, ForeignKey, Text, Uuid
|
|
4
|
+
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, Uuid
|
|
5
5
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
6
6
|
from uuid_utils import uuid7
|
|
7
7
|
|
|
@@ -52,6 +52,24 @@ class UserRoleModel(FullAuthBase):
|
|
|
52
52
|
)
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
class OAuthAccountModel(FullAuthBase):
|
|
56
|
+
__tablename__ = "fullauth_oauth_accounts"
|
|
57
|
+
|
|
58
|
+
id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid7)
|
|
59
|
+
provider: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
|
|
60
|
+
provider_user_id: Mapped[str] = mapped_column(String(320), index=True, nullable=False)
|
|
61
|
+
user_id: Mapped[UUID] = mapped_column(
|
|
62
|
+
Uuid, ForeignKey("fullauth_users.id", ondelete="CASCADE"), nullable=False
|
|
63
|
+
)
|
|
64
|
+
provider_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
|
65
|
+
access_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
66
|
+
refresh_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
67
|
+
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
68
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
69
|
+
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
55
73
|
class RefreshTokenModel(FullAuthBase):
|
|
56
74
|
__tablename__ = "fullauth_refresh_tokens"
|
|
57
75
|
|
{fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/sqlmodel/adapter.py
RENAMED
|
@@ -5,8 +5,13 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
5
5
|
from sqlalchemy.orm import selectinload
|
|
6
6
|
|
|
7
7
|
from fastapi_fullauth.adapters.base import AbstractUserAdapter
|
|
8
|
-
from fastapi_fullauth.adapters.sqlmodel.models import
|
|
9
|
-
|
|
8
|
+
from fastapi_fullauth.adapters.sqlmodel.models import (
|
|
9
|
+
OAuthAccountRecord,
|
|
10
|
+
RefreshTokenRecord,
|
|
11
|
+
Role,
|
|
12
|
+
UserBase,
|
|
13
|
+
)
|
|
14
|
+
from fastapi_fullauth.types import CreateUserSchema, OAuthAccount, RefreshToken, UserSchema
|
|
10
15
|
|
|
11
16
|
|
|
12
17
|
class SQLModelAdapter(AbstractUserAdapter):
|
|
@@ -222,3 +227,77 @@ class SQLModelAdapter(AbstractUserAdapter):
|
|
|
222
227
|
user.roles = [r for r in user.roles if r.name != role_name]
|
|
223
228
|
session.add(user)
|
|
224
229
|
await session.commit()
|
|
230
|
+
|
|
231
|
+
# ── OAuth ────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
def _to_oauth_account(self, row: OAuthAccountRecord) -> OAuthAccount:
|
|
234
|
+
return OAuthAccount(
|
|
235
|
+
provider=row.provider,
|
|
236
|
+
provider_user_id=row.provider_user_id,
|
|
237
|
+
user_id=str(row.user_id),
|
|
238
|
+
provider_email=row.provider_email,
|
|
239
|
+
access_token=row.access_token,
|
|
240
|
+
refresh_token=row.refresh_token,
|
|
241
|
+
expires_at=row.expires_at,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
async def get_oauth_account(self, provider: str, provider_user_id: str) -> OAuthAccount | None:
|
|
245
|
+
async with self._session_maker() as session:
|
|
246
|
+
result = await session.execute(
|
|
247
|
+
select(OAuthAccountRecord).where(
|
|
248
|
+
OAuthAccountRecord.provider == provider,
|
|
249
|
+
OAuthAccountRecord.provider_user_id == provider_user_id,
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
row = result.scalars().first()
|
|
253
|
+
return self._to_oauth_account(row) if row else None
|
|
254
|
+
|
|
255
|
+
async def get_user_oauth_accounts(self, user_id: str) -> list[OAuthAccount]:
|
|
256
|
+
async with self._session_maker() as session:
|
|
257
|
+
result = await session.execute(
|
|
258
|
+
select(OAuthAccountRecord).where(OAuthAccountRecord.user_id == user_id)
|
|
259
|
+
)
|
|
260
|
+
return [self._to_oauth_account(row) for row in result.scalars().all()]
|
|
261
|
+
|
|
262
|
+
async def create_oauth_account(self, data: OAuthAccount) -> OAuthAccount:
|
|
263
|
+
async with self._session_maker() as session:
|
|
264
|
+
record = OAuthAccountRecord(
|
|
265
|
+
provider=data.provider,
|
|
266
|
+
provider_user_id=data.provider_user_id,
|
|
267
|
+
user_id=data.user_id,
|
|
268
|
+
provider_email=data.provider_email,
|
|
269
|
+
access_token=data.access_token,
|
|
270
|
+
refresh_token=data.refresh_token,
|
|
271
|
+
expires_at=data.expires_at,
|
|
272
|
+
)
|
|
273
|
+
session.add(record)
|
|
274
|
+
await session.commit()
|
|
275
|
+
return data
|
|
276
|
+
|
|
277
|
+
async def update_oauth_account(
|
|
278
|
+
self, provider: str, provider_user_id: str, data: dict[str, Any]
|
|
279
|
+
) -> OAuthAccount | None:
|
|
280
|
+
async with self._session_maker() as session:
|
|
281
|
+
await session.execute(
|
|
282
|
+
update(OAuthAccountRecord)
|
|
283
|
+
.where(
|
|
284
|
+
OAuthAccountRecord.provider == provider,
|
|
285
|
+
OAuthAccountRecord.provider_user_id == provider_user_id,
|
|
286
|
+
)
|
|
287
|
+
.values(**data)
|
|
288
|
+
)
|
|
289
|
+
await session.commit()
|
|
290
|
+
return await self.get_oauth_account(provider, provider_user_id)
|
|
291
|
+
|
|
292
|
+
async def delete_oauth_account(self, provider: str, provider_user_id: str) -> None:
|
|
293
|
+
async with self._session_maker() as session:
|
|
294
|
+
result = await session.execute(
|
|
295
|
+
select(OAuthAccountRecord).where(
|
|
296
|
+
OAuthAccountRecord.provider == provider,
|
|
297
|
+
OAuthAccountRecord.provider_user_id == provider_user_id,
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
row = result.scalars().first()
|
|
301
|
+
if row:
|
|
302
|
+
await session.delete(row)
|
|
303
|
+
await session.commit()
|
{fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/sqlmodel/models.py
RENAMED
|
@@ -43,6 +43,25 @@ class UserBase(SQLModel):
|
|
|
43
43
|
)
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
class OAuthAccountRecord(SQLModel, table=True):
|
|
47
|
+
__tablename__ = "fullauth_oauth_accounts"
|
|
48
|
+
|
|
49
|
+
id: UUID = Field(default_factory=uuid7, primary_key=True)
|
|
50
|
+
provider: str = Field(index=True, max_length=50)
|
|
51
|
+
provider_user_id: str = Field(index=True, max_length=320)
|
|
52
|
+
user_id: UUID = Field(foreign_key="fullauth_users.id")
|
|
53
|
+
provider_email: str | None = Field(default=None, max_length=320)
|
|
54
|
+
access_token: str | None = Field(default=None)
|
|
55
|
+
refresh_token: str | None = Field(default=None)
|
|
56
|
+
expires_at: datetime | None = Field(
|
|
57
|
+
default=None, sa_column=Column(DateTime(timezone=True), nullable=True)
|
|
58
|
+
)
|
|
59
|
+
created_at: datetime = Field(
|
|
60
|
+
default_factory=lambda: datetime.now(timezone.utc),
|
|
61
|
+
sa_column=Column(DateTime(timezone=True), nullable=False),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
46
65
|
class RefreshTokenRecord(SQLModel, table=True):
|
|
47
66
|
__tablename__ = "fullauth_refresh_tokens"
|
|
48
67
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import warnings
|
|
2
|
-
from typing import Literal
|
|
2
|
+
from typing import Any, Literal
|
|
3
3
|
|
|
4
4
|
from pydantic import model_validator
|
|
5
5
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
@@ -48,6 +48,10 @@ class FullAuthConfig(BaseSettings):
|
|
|
48
48
|
COOKIE_SAMESITE: Literal["lax", "strict", "none"] = "lax"
|
|
49
49
|
COOKIE_DOMAIN: str | None = None
|
|
50
50
|
|
|
51
|
+
OAUTH_PROVIDERS: dict[str, dict[str, Any]] = {}
|
|
52
|
+
OAUTH_STATE_EXPIRE_SECONDS: int = 300
|
|
53
|
+
OAUTH_AUTO_LINK_BY_EMAIL: bool = True
|
|
54
|
+
|
|
51
55
|
API_PREFIX: str = "/api/v1"
|
|
52
56
|
AUTH_ROUTER_PREFIX: str = "/auth"
|
|
53
57
|
ROUTER_TAGS: list[str] = ["Auth"]
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
from fastapi_fullauth.dependencies.current_user import (
|
|
2
|
+
CurrentUser,
|
|
3
|
+
SuperUser,
|
|
4
|
+
VerifiedUser,
|
|
2
5
|
current_active_verified_user,
|
|
3
6
|
current_superuser,
|
|
4
7
|
current_user,
|
|
5
|
-
CurrentUser,
|
|
6
|
-
VerifiedUser,
|
|
7
|
-
SuperUser,
|
|
8
8
|
)
|
|
9
9
|
from fastapi_fullauth.dependencies.require_role import require_permission, require_role
|
|
10
10
|
|
|
11
|
-
|
|
12
11
|
__all__ = [
|
|
13
12
|
"CurrentUser",
|
|
14
13
|
"VerifiedUser",
|
|
@@ -45,6 +45,18 @@ class RefreshTokenReuseError(TokenError):
|
|
|
45
45
|
pass
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
class OAuthError(FullAuthError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class OAuthProviderError(OAuthError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class OAuthAccountAlreadyLinkedError(OAuthError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
48
60
|
CREDENTIALS_EXCEPTION = HTTPException(
|
|
49
61
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
50
62
|
detail="Could not validate credentials",
|
|
@@ -65,3 +77,8 @@ ACCOUNT_LOCKED_EXCEPTION = HTTPException(
|
|
|
65
77
|
status_code=status.HTTP_423_LOCKED,
|
|
66
78
|
detail="Account is temporarily locked due to too many failed login attempts",
|
|
67
79
|
)
|
|
80
|
+
|
|
81
|
+
OAUTH_ERROR_EXCEPTION = HTTPException(
|
|
82
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
83
|
+
detail="OAuth authentication failed",
|
|
84
|
+
)
|