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.
Files changed (87) hide show
  1. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/CHANGELOG.md +22 -0
  2. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/PKG-INFO +45 -1
  3. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/README.md +41 -0
  4. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/__init__.py +1 -1
  5. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/base.py +20 -1
  6. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/memory.py +28 -1
  7. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/sqlalchemy/adapter.py +76 -1
  8. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/sqlalchemy/models.py +19 -1
  9. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/sqlmodel/adapter.py +81 -2
  10. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/sqlmodel/models.py +19 -0
  11. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/config.py +5 -1
  12. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/dependencies/__init__.py +3 -4
  13. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/dependencies/current_user.py +0 -1
  14. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/exceptions.py +17 -0
  15. fastapi_fullauth-0.4.0/fastapi_fullauth/flows/oauth.py +123 -0
  16. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/fullauth.py +67 -10
  17. fastapi_fullauth-0.4.0/fastapi_fullauth/oauth/__init__.py +9 -0
  18. fastapi_fullauth-0.4.0/fastapi_fullauth/oauth/base.py +55 -0
  19. fastapi_fullauth-0.4.0/fastapi_fullauth/oauth/github.py +79 -0
  20. fastapi_fullauth-0.4.0/fastapi_fullauth/oauth/google.py +65 -0
  21. fastapi_fullauth-0.4.0/fastapi_fullauth/protection/ratelimit.py +166 -0
  22. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/router/auth.py +3 -3
  23. fastapi_fullauth-0.4.0/fastapi_fullauth/router/oauth.py +173 -0
  24. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/types.py +22 -0
  25. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/pyproject.toml +3 -1
  26. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_dx_improvements.py +5 -0
  27. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_middleware.py +83 -0
  28. fastapi_fullauth-0.4.0/tests/test_oauth.py +270 -0
  29. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/uv.lock +8 -2
  30. fastapi_fullauth-0.3.0/fastapi_fullauth/protection/ratelimit.py +0 -90
  31. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/.github/workflows/ci.yml +0 -0
  32. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/.github/workflows/publish.yml +0 -0
  33. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/.gitignore +0 -0
  34. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/.python-version +0 -0
  35. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/LICENSE +0 -0
  36. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/memory_app/__init__.py +0 -0
  37. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/memory_app/auth.py +0 -0
  38. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/memory_app/main.py +0 -0
  39. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/memory_app/routes.py +0 -0
  40. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/sqlmodel_app/__init__.py +0 -0
  41. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/sqlmodel_app/auth.py +0 -0
  42. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/sqlmodel_app/config.py +0 -0
  43. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/sqlmodel_app/main.py +0 -0
  44. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/sqlmodel_app/models.py +0 -0
  45. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/examples/sqlmodel_app/routes.py +0 -0
  46. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/__init__.py +0 -0
  47. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/sqlalchemy/__init__.py +0 -0
  48. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/adapters/sqlmodel/__init__.py +0 -0
  49. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/backends/__init__.py +0 -0
  50. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/backends/base.py +0 -0
  51. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/backends/bearer.py +0 -0
  52. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/backends/cookie.py +0 -0
  53. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/core/__init__.py +0 -0
  54. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/core/crypto.py +0 -0
  55. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/core/redis_blacklist.py +0 -0
  56. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/core/tokens.py +0 -0
  57. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/dependencies/require_role.py +0 -0
  58. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/flows/__init__.py +0 -0
  59. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/flows/email_verify.py +0 -0
  60. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/flows/login.py +0 -0
  61. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/flows/logout.py +0 -0
  62. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/flows/password_reset.py +0 -0
  63. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/flows/register.py +0 -0
  64. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/hooks.py +0 -0
  65. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/middleware/__init__.py +0 -0
  66. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/middleware/csrf.py +0 -0
  67. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/middleware/ratelimit.py +0 -0
  68. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/middleware/security_headers.py +0 -0
  69. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/migrations/__init__.py +0 -0
  70. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/migrations/helpers.py +0 -0
  71. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/protection/__init__.py +0 -0
  72. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/protection/lockout.py +0 -0
  73. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/rbac/__init__.py +0 -0
  74. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/router/__init__.py +0 -0
  75. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/utils.py +0 -0
  76. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/fastapi_fullauth/validators.py +0 -0
  77. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/__init__.py +0 -0
  78. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/conftest.py +0 -0
  79. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_auth_flows.py +0 -0
  80. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_crypto.py +0 -0
  81. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_customization.py +0 -0
  82. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_email_verify.py +0 -0
  83. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_lockout.py +0 -0
  84. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_new_endpoints.py +0 -0
  85. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_refresh_tokens.py +0 -0
  86. {fastapi_fullauth-0.3.0 → fastapi_fullauth-0.4.0}/tests/test_roles.py +0 -0
  87. {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.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,4 +1,4 @@
1
- __version__ = "0.3.0"
1
+ __version__ = "0.4.0"
2
2
 
3
3
  from fastapi_fullauth.config import FullAuthConfig
4
4
  from fastapi_fullauth.fullauth import FullAuth
@@ -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)
@@ -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()
@@ -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
 
@@ -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 RefreshTokenRecord, Role, UserBase
9
- from fastapi_fullauth.types import CreateUserSchema, RefreshToken, UserSchema
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()
@@ -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",
@@ -6,7 +6,6 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
6
6
  from fastapi_fullauth.exceptions import CREDENTIALS_EXCEPTION
7
7
  from fastapi_fullauth.types import UserSchema
8
8
 
9
-
10
9
  if TYPE_CHECKING:
11
10
  from fastapi_fullauth.fullauth import FullAuth
12
11
 
@@ -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
+ )