fastapi-jwt-authkit 0.1.0__py3-none-any.whl
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.
- authkit/__init__.py +5 -0
- authkit/__init__.pyi +3 -0
- authkit/authenticator.py +47 -0
- authkit/authenticator.pyi +11 -0
- authkit/ext/sqlalchemy/__init__.py +6 -0
- authkit/ext/sqlalchemy/sa_async.py +44 -0
- authkit/ext/sqlalchemy/sa_sync.py +60 -0
- authkit/ext/sqlalchmey/__init__.pyi +4 -0
- authkit/ext/sqlalchmey/sa_async.pyi +13 -0
- authkit/ext/sqlalchmey/sa_sync.pyi +13 -0
- authkit/extractors.py +28 -0
- authkit/extractors.pyi +5 -0
- authkit/fastapi/__init__.py +3 -0
- authkit/fastapi/__init__.pyi +2 -0
- authkit/fastapi/models.py +0 -0
- authkit/fastapi/routers.py +204 -0
- authkit/fastapi/routers.pyi +6 -0
- authkit/fastapi/schema.py +13 -0
- authkit/hashing.py +12 -0
- authkit/hashing.pyi +2 -0
- authkit/protocols.py +27 -0
- authkit/protocols.pyi +27 -0
- authkit/py.typed +0 -0
- authkit/service.py +145 -0
- authkit/service.pyi +18 -0
- authkit/settings.py +26 -0
- authkit/settings.pyi +26 -0
- authkit/tokens.py +45 -0
- authkit/tokens.pyi +6 -0
- fastapi_jwt_authkit-0.1.0.dist-info/METADATA +366 -0
- fastapi_jwt_authkit-0.1.0.dist-info/RECORD +33 -0
- fastapi_jwt_authkit-0.1.0.dist-info/WHEEL +4 -0
- fastapi_jwt_authkit-0.1.0.dist-info/licenses/LICENSE +21 -0
authkit/__init__.py
ADDED
authkit/__init__.pyi
ADDED
authkit/authenticator.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from fastapi import HTTPException,status, Request
|
|
2
|
+
from .settings import AuthSettings
|
|
3
|
+
from .extractors import extract_access_token
|
|
4
|
+
from .tokens import decode_access
|
|
5
|
+
from .protocols import AsyncUserProtocol , SyncUserProtocol , UserProtocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AsyncAuthenticator:
|
|
9
|
+
def __init__(self,settings:AuthSettings, repo:AsyncUserProtocol ):
|
|
10
|
+
self.settings = settings
|
|
11
|
+
self.repo = repo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def current_user(self,request:Request) -> UserProtocol:
|
|
15
|
+
token = extract_access_token(request,self.settings)
|
|
16
|
+
if not token:
|
|
17
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,detail="Not authenticated")
|
|
18
|
+
payload = decode_access(self.settings,token)
|
|
19
|
+
sub = payload.get("sub")
|
|
20
|
+
if not sub:
|
|
21
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,detail="Invalid token payload")
|
|
22
|
+
|
|
23
|
+
user = await self.repo.get_by_id(int(sub))
|
|
24
|
+
if not user:
|
|
25
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,detail="User not found")
|
|
26
|
+
return user
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SyncAuthenticator:
|
|
30
|
+
def __init__(self,settings:AuthSettings,repo:SyncUserProtocol):
|
|
31
|
+
self.settings = settings
|
|
32
|
+
self.repo = repo
|
|
33
|
+
|
|
34
|
+
def current_user(self,request:Request) -> UserProtocol:
|
|
35
|
+
token = extract_access_token(request,self.settings)
|
|
36
|
+
if not token:
|
|
37
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,detail="Not authenticated")
|
|
38
|
+
payload = decode_access(self.settings,token)
|
|
39
|
+
sub = payload.get("sub")
|
|
40
|
+
if not sub:
|
|
41
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,detail="Invalid token payload")
|
|
42
|
+
|
|
43
|
+
user = self.repo.get_by_id(int(sub))
|
|
44
|
+
if not user:
|
|
45
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,detail="User not found")
|
|
46
|
+
|
|
47
|
+
return user
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from fastapi import Request
|
|
2
|
+
from .settings import AuthSettings
|
|
3
|
+
from .protocols import AsyncUserProtocol, SyncUserProtocol, UserProtocol
|
|
4
|
+
|
|
5
|
+
class AsyncAuthenticator:
|
|
6
|
+
def __init__(self, settings: AuthSettings, repo: AsyncUserProtocol) -> None: ...
|
|
7
|
+
async def current_user(self, request: Request) -> UserProtocol: ...
|
|
8
|
+
|
|
9
|
+
class SyncAuthenticator:
|
|
10
|
+
def __init__(self, settings: AuthSettings, repo: SyncUserProtocol) -> None: ...
|
|
11
|
+
def current_user(self, request: Request) -> UserProtocol: ...
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
3
|
+
from sqlalchemy import select, or_
|
|
4
|
+
|
|
5
|
+
class SQLAlchemyAsyncUserProtocol:
|
|
6
|
+
"""
|
|
7
|
+
Adapter repo for SQLAlchemy AsyncSession.
|
|
8
|
+
You pass your User model class in user_model.
|
|
9
|
+
"""
|
|
10
|
+
def __init__(self, session: AsyncSession, user_model: type[Any]):
|
|
11
|
+
self.session = session
|
|
12
|
+
self.user_model = user_model
|
|
13
|
+
|
|
14
|
+
async def get_by_id(self, user_id: int):
|
|
15
|
+
return await self.session.get(self.user_model, user_id)
|
|
16
|
+
|
|
17
|
+
async def get_by_email_or_username(self, value: str):
|
|
18
|
+
user_model = self.user_model
|
|
19
|
+
stmt = select(user_model).where(or_(user_model.email == value, user_model.username == value))
|
|
20
|
+
return (await self.session.execute(stmt)).scalar_one_or_none()
|
|
21
|
+
|
|
22
|
+
async def create_user(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
email: str,
|
|
26
|
+
username: str,
|
|
27
|
+
password: str,
|
|
28
|
+
is_active: bool = True,
|
|
29
|
+
is_staff: bool = False,
|
|
30
|
+
is_superuser: bool = False,
|
|
31
|
+
):
|
|
32
|
+
user_model = self.user_model
|
|
33
|
+
user = user_model(
|
|
34
|
+
email=email,
|
|
35
|
+
username=username,
|
|
36
|
+
password_hash=password,
|
|
37
|
+
is_active=is_active,
|
|
38
|
+
is_staff=is_staff,
|
|
39
|
+
is_superuser=is_superuser,
|
|
40
|
+
)
|
|
41
|
+
self.session.add(user)
|
|
42
|
+
await self.session.commit()
|
|
43
|
+
await self.session.refresh(user)
|
|
44
|
+
return user
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from typing import Any, Type
|
|
2
|
+
from sqlalchemy.orm import Session
|
|
3
|
+
from sqlalchemy import select, or_
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SQLAlchemySyncUserProtocol:
|
|
7
|
+
"""
|
|
8
|
+
Adapter repository for SQLAlchemy Session (sync).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, session: Session, user_model: Type[Any]):
|
|
12
|
+
self.session = session
|
|
13
|
+
self.user_model = user_model
|
|
14
|
+
|
|
15
|
+
def get_by_id(self, user_id: int):
|
|
16
|
+
return self.session.get(self.user_model, user_id)
|
|
17
|
+
|
|
18
|
+
def get_by_email_or_username(self, value: str):
|
|
19
|
+
stmt = select(self.user_model).where(
|
|
20
|
+
or_(
|
|
21
|
+
self.user_model.email == value,
|
|
22
|
+
self.user_model.username == value,
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
return self.session.execute(stmt).scalar_one_or_none()
|
|
26
|
+
|
|
27
|
+
def get_by_email(self, email: str):
|
|
28
|
+
stmt = select(self.user_model).where(
|
|
29
|
+
self.user_model.email == email
|
|
30
|
+
)
|
|
31
|
+
return self.session.execute(stmt).scalar_one_or_none()
|
|
32
|
+
|
|
33
|
+
def get_by_username(self, username: str):
|
|
34
|
+
stmt = select(self.user_model).where(
|
|
35
|
+
self.user_model.username == username
|
|
36
|
+
)
|
|
37
|
+
return self.session.execute(stmt).scalar_one_or_none()
|
|
38
|
+
|
|
39
|
+
def create_user(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
email: str,
|
|
43
|
+
username: str,
|
|
44
|
+
password: str,
|
|
45
|
+
is_active: bool = True,
|
|
46
|
+
is_staff: bool = False,
|
|
47
|
+
is_superuser: bool = False,
|
|
48
|
+
):
|
|
49
|
+
user = self.user_model(
|
|
50
|
+
email=email,
|
|
51
|
+
username=username,
|
|
52
|
+
password_hash=password,
|
|
53
|
+
is_active=is_active,
|
|
54
|
+
is_staff=is_staff,
|
|
55
|
+
is_superuser=is_superuser,
|
|
56
|
+
)
|
|
57
|
+
self.session.add(user)
|
|
58
|
+
self.session.commit()
|
|
59
|
+
self.session.refresh(user)
|
|
60
|
+
return user
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
3
|
+
from packages.authkit.src.authkit.protocols import UserProtocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SQLAlchemyAsyncUserRepo:
|
|
7
|
+
def __init__(self, session: AsyncSession, user_model: type[Any]) -> None: ...
|
|
8
|
+
async def get_by_id(self, user_id: int) -> UserProtocol | None: ...
|
|
9
|
+
async def get_by_email_or_username(self, value: str) -> UserProtocol | None: ...
|
|
10
|
+
async def create_user(
|
|
11
|
+
self, *, email: str, username: str, password_hash: str,
|
|
12
|
+
is_active: bool = ..., is_staff: bool = ..., is_superuser: bool = ...
|
|
13
|
+
) -> UserProtocol: ...
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from sqlalchemy.orm import Session
|
|
3
|
+
|
|
4
|
+
from packages.authkit.src.authkit.protocols import UserProtocol
|
|
5
|
+
|
|
6
|
+
class SQLAlchemySyncUserRepo:
|
|
7
|
+
def __init__(self, session: Session, user_model: type[Any]) -> None: ...
|
|
8
|
+
def get_by_id(self, user_id: int) -> UserProtocol | None: ...
|
|
9
|
+
def get_by_email_or_username(self, value: str) -> UserProtocol | None: ...
|
|
10
|
+
def create_user(
|
|
11
|
+
self, *, email: str, username: str, password_hash: str,
|
|
12
|
+
is_active: bool = ..., is_staff: bool = ..., is_superuser: bool = ...
|
|
13
|
+
) -> UserProtocol: ...
|
authkit/extractors.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from fastapi import Request
|
|
2
|
+
from .settings import AuthSettings
|
|
3
|
+
|
|
4
|
+
def extract_access_token(request: Request, settings: AuthSettings) -> str | None:
|
|
5
|
+
if settings.accept_header:
|
|
6
|
+
auth_header = request.headers.get("Authorization")
|
|
7
|
+
if auth_header and auth_header.startswith("Bearer "):
|
|
8
|
+
return auth_header[7:]
|
|
9
|
+
|
|
10
|
+
if settings.accept_cookie:
|
|
11
|
+
cookie_name = settings.cookie_name_access
|
|
12
|
+
return request.cookies.get(cookie_name)
|
|
13
|
+
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
def extract_refresh_token(request: Request, settings: AuthSettings) -> str | None:
|
|
17
|
+
if settings.accept_header:
|
|
18
|
+
auth_header = request.headers.get("Authorization")
|
|
19
|
+
if auth_header and auth_header.startswith("Bearer "):
|
|
20
|
+
return auth_header[7:]
|
|
21
|
+
|
|
22
|
+
if settings.accept_cookie:
|
|
23
|
+
cookie_name = settings.cookie_name_refresh
|
|
24
|
+
return request.cookies.get(cookie_name)
|
|
25
|
+
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
authkit/extractors.pyi
ADDED
|
File without changes
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Callable, Awaitable
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException , Request , Response,status
|
|
4
|
+
|
|
5
|
+
from ..ext.sqlalchemy import SQLAlchemyAsyncUserProtocol, SQLAlchemySyncUserProtocol
|
|
6
|
+
from ..extractors import extract_refresh_token
|
|
7
|
+
from ..settings import AuthSettings
|
|
8
|
+
from ..service import AuthService, AsyncAuthService
|
|
9
|
+
from ..authenticator import AsyncAuthenticator , SyncAuthenticator
|
|
10
|
+
|
|
11
|
+
from .schema import RegisterInSchema, LoginInSchema
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _set_access_cookie(response:Response , settings:AuthSettings, token:str):
|
|
15
|
+
response.set_cookie(
|
|
16
|
+
key=settings.cookie_name_access,
|
|
17
|
+
value=token,
|
|
18
|
+
httponly=True,
|
|
19
|
+
secure=settings.cookie_secure,
|
|
20
|
+
samesite=settings.cookie_samesite,
|
|
21
|
+
max_age=settings.cookie_max_age_access,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _set_refresh_cookie(response:Response,settings:AuthSettings, token:str):
|
|
26
|
+
response.set_cookie(
|
|
27
|
+
key=settings.cookie_name_refresh,
|
|
28
|
+
value=token,
|
|
29
|
+
httponly=True,
|
|
30
|
+
secure=settings.cookie_secure,
|
|
31
|
+
samesite=settings.cookie_samesite,
|
|
32
|
+
max_age=settings.cookie_max_age_refresh,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def _clear_cookie(response:Response,settings:AuthSettings):
|
|
36
|
+
response.delete_cookie(
|
|
37
|
+
key=settings.cookie_name_access,
|
|
38
|
+
)
|
|
39
|
+
response.delete_cookie(
|
|
40
|
+
key=settings.cookie_name_refresh,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def build_auth_router_async(
|
|
44
|
+
*,
|
|
45
|
+
settings:AuthSettings,
|
|
46
|
+
get_session:Callable[...,Any],
|
|
47
|
+
user_model:type[Any]
|
|
48
|
+
)-> APIRouter:
|
|
49
|
+
"""
|
|
50
|
+
Async FastAPI router.
|
|
51
|
+
get_session must provide an AsyncSession (dependency).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
router = APIRouter()
|
|
55
|
+
|
|
56
|
+
def _svc(session=Depends(get_session))->AsyncAuthService:
|
|
57
|
+
repo = SQLAlchemyAsyncUserProtocol(session ,user_model=user_model)
|
|
58
|
+
return AsyncAuthService(settings, repo)
|
|
59
|
+
|
|
60
|
+
def _auth(session=Depends(get_session))->AsyncAuthenticator:
|
|
61
|
+
repo= SQLAlchemyAsyncUserProtocol(session ,user_model=user_model)
|
|
62
|
+
return AsyncAuthenticator(settings , repo)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@router.post("/register")
|
|
66
|
+
async def register(data:RegisterInSchema,svc:AsyncAuthService=Depends(_svc)):
|
|
67
|
+
user = await svc.create_user(data.email , data.username , data.password)
|
|
68
|
+
return {
|
|
69
|
+
"id": user.id,
|
|
70
|
+
"email": user.email,
|
|
71
|
+
"username": user.username,
|
|
72
|
+
"is_staff": user.is_staff,
|
|
73
|
+
"is_active": user.is_active,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.post("/login")
|
|
78
|
+
async def login(data:LoginInSchema, response:Response, svc:AsyncAuthService=Depends(_svc)):
|
|
79
|
+
user = await svc.authenticate(data.username_or_email , data.password)
|
|
80
|
+
access, refresh = await svc.assign_token(user)
|
|
81
|
+
|
|
82
|
+
if settings.set_cookie_on_login:
|
|
83
|
+
_set_access_cookie(response , settings , access)
|
|
84
|
+
_set_refresh_cookie(response , settings , refresh)
|
|
85
|
+
return {
|
|
86
|
+
"access_token": access,
|
|
87
|
+
"refresh_token": refresh,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@router.post("/refresh")
|
|
91
|
+
async def refresh(request:Request, response:Response, svc:AsyncAuthService=Depends(_svc)):
|
|
92
|
+
rt = extract_refresh_token(request,settings)
|
|
93
|
+
if not rt:
|
|
94
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED , detail="Refresh token not found")
|
|
95
|
+
access,refresh = await svc.refresh_pair(rt)
|
|
96
|
+
|
|
97
|
+
if settings.set_cookie_on_login:
|
|
98
|
+
_set_access_cookie(response , settings , access)
|
|
99
|
+
_set_refresh_cookie(response , settings , refresh)
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"access_token": access,
|
|
103
|
+
"refresh_token": refresh,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.post("/logout")
|
|
108
|
+
async def logout(response:Response):
|
|
109
|
+
_clear_cookie(response , settings)
|
|
110
|
+
return {"logout": "success" , "ok":True}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@router.get("/me")
|
|
114
|
+
async def me(request:Request,auth:AsyncAuthenticator=Depends(_auth)):
|
|
115
|
+
user = await auth.current_user(request)
|
|
116
|
+
return {"id": user.id, "email": user.email, "username": user.username, "is_staff": user.is_staff, "is_superuser": user.is_superuser}
|
|
117
|
+
|
|
118
|
+
return router
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ======= Sync Router =======#
|
|
122
|
+
def build_auth_router_sync(
|
|
123
|
+
*,
|
|
124
|
+
settings:AuthSettings,
|
|
125
|
+
get_session:Callable[...,Any],
|
|
126
|
+
user_models:type[Any]
|
|
127
|
+
)-> APIRouter:
|
|
128
|
+
"""
|
|
129
|
+
Sync FastAPI router.
|
|
130
|
+
get_session must provide a Session (dependency).
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
router = APIRouter()
|
|
134
|
+
|
|
135
|
+
def _svc(session=Depends(get_session))->AuthService:
|
|
136
|
+
repo = SQLAlchemySyncUserProtocol(session ,user_model=user_models)
|
|
137
|
+
return AuthService(settings, repo)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _auth(session=Depends(get_session))->SyncAuthenticator:
|
|
141
|
+
repo = SQLAlchemySyncUserProtocol(session ,user_model=user_models)
|
|
142
|
+
return SyncAuthenticator(settings , repo)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.post("/register")
|
|
146
|
+
def register(data:RegisterInSchema,svc:AuthService=Depends(_svc)):
|
|
147
|
+
user = svc.create_user(data.email , data.username , data.password)
|
|
148
|
+
return {
|
|
149
|
+
"id": user.id,
|
|
150
|
+
"email": user.email,
|
|
151
|
+
"username": user.username,
|
|
152
|
+
"is_staff": user.is_staff,
|
|
153
|
+
"is_active": user.is_active,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@router.post("/login")
|
|
157
|
+
def login(data:LoginInSchema, response:Response, svc:AuthService=Depends(_svc)):
|
|
158
|
+
user = svc.authenticate(data.username_or_email , data.password)
|
|
159
|
+
access, refresh = svc.assign_token(user)
|
|
160
|
+
|
|
161
|
+
if settings.set_cookie_on_login:
|
|
162
|
+
_set_access_cookie(response , settings , access)
|
|
163
|
+
_set_refresh_cookie(response , settings , refresh)
|
|
164
|
+
return {
|
|
165
|
+
"access_token": access,
|
|
166
|
+
"refresh_token": refresh,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@router.post("/refresh")
|
|
170
|
+
def refresh(request:Request, response:Response, svc:AuthService=Depends(_svc)):
|
|
171
|
+
rt = extract_refresh_token(request,settings)
|
|
172
|
+
if not rt:
|
|
173
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED , detail="Refresh token not found")
|
|
174
|
+
access,refresh = svc.refresh_pair(rt)
|
|
175
|
+
|
|
176
|
+
if settings.set_cookie_on_login:
|
|
177
|
+
_set_access_cookie(response , settings , access)
|
|
178
|
+
_set_refresh_cookie(response , settings , refresh)
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
"access_token": access,
|
|
182
|
+
"refresh_token": refresh,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@router.post("/logout")
|
|
187
|
+
def logout(response:Response):
|
|
188
|
+
_clear_cookie(response , settings)
|
|
189
|
+
return {"logout": "success" , "ok":True}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@router.get("/me")
|
|
193
|
+
def me(request:Request,auth:SyncAuthenticator=Depends(_auth)):
|
|
194
|
+
user = auth.current_user(request)
|
|
195
|
+
return {"id": user.id, "email": user.email, "username": user.username, "is_staff": user.is_staff, "is_superuser": user.is_superuser}
|
|
196
|
+
|
|
197
|
+
return router
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from typing import Any, Callable
|
|
2
|
+
from fastapi import APIRouter
|
|
3
|
+
from ..settings import AuthSettings
|
|
4
|
+
|
|
5
|
+
def build_auth_router_async(*, settings: AuthSettings, get_session: Callable[..., Any], user_model: type[Any]) -> APIRouter: ...
|
|
6
|
+
def build_auth_router_sync(*, settings: AuthSettings, get_session: Callable[..., Any], user_model: type[Any]) -> APIRouter: ...
|
authkit/hashing.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from passlib.context import CryptContext
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
_PWD = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def hash_password(password: str) -> str:
|
|
9
|
+
return _PWD.hash(password)
|
|
10
|
+
|
|
11
|
+
def verify_password(password: str, hashed_password: str) -> bool:
|
|
12
|
+
return _PWD.verify(password, hashed_password)
|
authkit/hashing.pyi
ADDED
authkit/protocols.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import Protocol , Optional ,runtime_checkable
|
|
2
|
+
|
|
3
|
+
@runtime_checkable
|
|
4
|
+
class UserProtocol(Protocol):
|
|
5
|
+
id: str
|
|
6
|
+
email: str
|
|
7
|
+
username: str
|
|
8
|
+
password_hash: str
|
|
9
|
+
is_active: bool
|
|
10
|
+
is_staff: bool
|
|
11
|
+
is_superuser: bool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AsyncUserProtocol(Protocol):
|
|
16
|
+
async def get_by_id(self,user_id:int) -> Optional[UserProtocol]:...
|
|
17
|
+
async def get_by_email_or_username(self, value:str) -> Optional[UserProtocol]:...
|
|
18
|
+
async def create_user(self,* , email: str, username: str, password: str,is_staff:bool,is_active:bool,is_superuser:bool) -> UserProtocol: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SyncUserProtocol(Protocol):
|
|
23
|
+
def get_by_id(self,user_id:int) -> Optional[UserProtocol]:...
|
|
24
|
+
def get_by_email_or_username(self,value:str) -> Optional[UserProtocol]:...
|
|
25
|
+
|
|
26
|
+
def create_user(self,* , email: str, username: str, password: str,is_staff:bool,is_active:bool,is_superuser:bool) -> UserProtocol:...
|
|
27
|
+
|
authkit/protocols.pyi
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import Protocol, Optional, runtime_checkable
|
|
2
|
+
|
|
3
|
+
@runtime_checkable
|
|
4
|
+
class UserProtocol(Protocol):
|
|
5
|
+
id: int
|
|
6
|
+
email: str
|
|
7
|
+
username: str
|
|
8
|
+
password_hash: str
|
|
9
|
+
is_active: bool
|
|
10
|
+
is_staff: bool
|
|
11
|
+
is_superuser: bool
|
|
12
|
+
|
|
13
|
+
class AsyncUserProtocol(Protocol):
|
|
14
|
+
async def get_by_id(self, user_id: int) -> Optional[UserProtocol]: ...
|
|
15
|
+
async def get_by_email_or_username(self, value: str) -> Optional[UserProtocol]: ...
|
|
16
|
+
async def create_user(
|
|
17
|
+
self, *, email: str, username: str, password_hash: str,
|
|
18
|
+
is_active: bool = ..., is_staff: bool = ..., is_superuser: bool = ...
|
|
19
|
+
) -> UserProtocol: ...
|
|
20
|
+
|
|
21
|
+
class SyncUserProtocol(Protocol):
|
|
22
|
+
def get_by_id(self, user_id: int) -> Optional[UserProtocol]: ...
|
|
23
|
+
def get_by_email_or_username(self, value: str) -> Optional[UserProtocol]: ...
|
|
24
|
+
def create_user(
|
|
25
|
+
self, *, email: str, username: str, password_hash: str,
|
|
26
|
+
is_active: bool = ..., is_staff: bool = ..., is_superuser: bool = ...
|
|
27
|
+
) -> UserProtocol: ...
|
authkit/py.typed
ADDED
|
File without changes
|
authkit/service.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from fastapi import HTTPException , status
|
|
5
|
+
|
|
6
|
+
from .hashing import hash_password , verify_password
|
|
7
|
+
from .settings import AuthSettings
|
|
8
|
+
|
|
9
|
+
from .tokens import (
|
|
10
|
+
create_access_token,
|
|
11
|
+
create_refresh_token,
|
|
12
|
+
decode_refresh
|
|
13
|
+
)
|
|
14
|
+
from .protocols import AsyncUserProtocol , SyncUserProtocol , UserProtocol
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AsyncAuthService:
|
|
18
|
+
def __init__(self,settings:AuthSettings,repo:AsyncUserProtocol):
|
|
19
|
+
self.settings = settings
|
|
20
|
+
self.repo = repo
|
|
21
|
+
|
|
22
|
+
async def create_user(self, email:str, username:str, password:str) -> UserProtocol:
|
|
23
|
+
existing_user = await self.repo.get_by_email_or_username(email) or await self.repo.get_by_email_or_username(username)
|
|
24
|
+
if existing_user:
|
|
25
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User with this email or username already exists")
|
|
26
|
+
return await self.repo.create_user(
|
|
27
|
+
email=email,
|
|
28
|
+
username=username,
|
|
29
|
+
password=hash_password(password),
|
|
30
|
+
is_active=True,
|
|
31
|
+
is_staff=False,
|
|
32
|
+
is_superuser=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
async def create_superuser(self, email:str, username:str, password:str) -> UserProtocol:
|
|
36
|
+
existing_user = await self.repo.get_by_email_or_username(email) or await self.repo.get_by_email_or_username(username)
|
|
37
|
+
if existing_user:
|
|
38
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User with this email or username already exists")
|
|
39
|
+
return await self.repo.create_user(
|
|
40
|
+
email=email,
|
|
41
|
+
username=username,
|
|
42
|
+
password=hash_password(password),
|
|
43
|
+
is_active=True,
|
|
44
|
+
is_staff=True,
|
|
45
|
+
is_superuser=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def authenticate(self , user_name_or_email:str, password:str) -> UserProtocol:
|
|
50
|
+
user = await self.repo.get_by_email_or_username(user_name_or_email)
|
|
51
|
+
if not user or not verify_password(password, user.password_hash):
|
|
52
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
53
|
+
return user
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def assign_token(self , user:UserProtocol) -> tuple[str, str]:
|
|
57
|
+
access_token = create_access_token(self.settings , subject=str(user.id))
|
|
58
|
+
refresh_token = create_refresh_token(self.settings , subject=str(user.id), jti=secrets.token_hex(16))
|
|
59
|
+
return access_token, refresh_token
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def refresh_pair(self , refresh_token:str) ->tuple[str, str|None]:
|
|
63
|
+
payload = decode_refresh(self.settings , refresh_token)
|
|
64
|
+
sub = payload.get("sub")
|
|
65
|
+
new_access_token = create_access_token(self.settings , subject=str(sub))
|
|
66
|
+
if self.settings.refresh_rotation:
|
|
67
|
+
new_refresh_token = create_refresh_token(self.settings , subject=str(sub) , jti=secrets.token_hex(16))
|
|
68
|
+
return new_access_token, new_refresh_token
|
|
69
|
+
return new_access_token, None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AuthService:
|
|
73
|
+
def __init__(self, settings: AuthSettings, repo: SyncUserProtocol):
|
|
74
|
+
self.settings = settings
|
|
75
|
+
self.repo = repo
|
|
76
|
+
|
|
77
|
+
def create_user(self, email: str, username: str, password: str) -> UserProtocol:
|
|
78
|
+
existing_user = (
|
|
79
|
+
self.repo.get_by_email_or_username(email)
|
|
80
|
+
or self.repo.get_by_email_or_username(username)
|
|
81
|
+
)
|
|
82
|
+
if existing_user:
|
|
83
|
+
raise HTTPException(
|
|
84
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
85
|
+
detail="User with this email or username already exists",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return self.repo.create_user(
|
|
89
|
+
email=email,
|
|
90
|
+
username=username,
|
|
91
|
+
password=hash_password(password),
|
|
92
|
+
is_active=True,
|
|
93
|
+
is_staff=False,
|
|
94
|
+
is_superuser=False,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def create_superuser(self, email: str, username: str, password: str) -> UserProtocol:
|
|
98
|
+
existing_user = (
|
|
99
|
+
self.repo.get_by_email_or_username(email)
|
|
100
|
+
or self.repo.get_by_email_or_username(username)
|
|
101
|
+
)
|
|
102
|
+
if existing_user:
|
|
103
|
+
raise HTTPException(
|
|
104
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
105
|
+
detail="User with this email or username already exists",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return self.repo.create_user(
|
|
109
|
+
email=email,
|
|
110
|
+
username=username,
|
|
111
|
+
password=hash_password(password),
|
|
112
|
+
is_active=True,
|
|
113
|
+
is_staff=True,
|
|
114
|
+
is_superuser=True,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def authenticate(self, user_name_or_email: str, password: str) -> UserProtocol:
|
|
118
|
+
user = self.repo.get_by_email_or_username(user_name_or_email)
|
|
119
|
+
if not user or not verify_password(password, user.password_hash):
|
|
120
|
+
raise HTTPException(
|
|
121
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
122
|
+
detail="Invalid credentials",
|
|
123
|
+
)
|
|
124
|
+
return user
|
|
125
|
+
|
|
126
|
+
def assign_token(self, user: UserProtocol) -> tuple[str, str]:
|
|
127
|
+
access_token = create_access_token(self.settings, subject=str(user.id))
|
|
128
|
+
refresh_token = create_refresh_token(self.settings, subject=str(user.id), jti=secrets.token_hex(16))
|
|
129
|
+
return access_token, refresh_token
|
|
130
|
+
|
|
131
|
+
def refresh_pair(self, refresh_token: str) -> tuple[str, str | None]:
|
|
132
|
+
payload = decode_refresh(self.settings, refresh_token)
|
|
133
|
+
sub = payload.get("sub")
|
|
134
|
+
|
|
135
|
+
new_access_token = create_access_token(self.settings, subject=str(sub))
|
|
136
|
+
|
|
137
|
+
if self.settings.refresh_rotation:
|
|
138
|
+
new_refresh_token = create_refresh_token(
|
|
139
|
+
self.settings,
|
|
140
|
+
subject=str(sub),
|
|
141
|
+
jti=secrets.token_hex(16),
|
|
142
|
+
)
|
|
143
|
+
return new_access_token, new_refresh_token
|
|
144
|
+
|
|
145
|
+
return new_access_token, None
|
authkit/service.pyi
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .settings import AuthSettings
|
|
2
|
+
from .protocols import AsyncUserRepo, SyncUserRepo, UserLike
|
|
3
|
+
|
|
4
|
+
class AuthServiceAsync:
|
|
5
|
+
def __init__(self, settings: AuthSettings, repo: AsyncUserRepo) -> None: ...
|
|
6
|
+
async def create_user(self, email: str, username: str, password: str) -> UserLike: ...
|
|
7
|
+
async def create_superuser(self, email: str, username: str, password: str) -> UserLike: ...
|
|
8
|
+
async def authenticate(self, username_or_email: str, password: str) -> UserLike: ...
|
|
9
|
+
async def issue_token_pair(self, user: UserLike) -> tuple[str, str]: ...
|
|
10
|
+
async def refresh_pair(self, refresh_token: str) -> tuple[str, str | None]: ...
|
|
11
|
+
|
|
12
|
+
class AuthServiceSync:
|
|
13
|
+
def __init__(self, settings: AuthSettings, repo: SyncUserRepo) -> None: ...
|
|
14
|
+
def create_user(self, email: str, username: str, password: str) -> UserLike: ...
|
|
15
|
+
def create_superuser(self, email: str, username: str, password: str) -> UserLike: ...
|
|
16
|
+
def authenticate(self, username_or_email: str, password: str) -> UserLike: ...
|
|
17
|
+
def issue_token_pair(self, user: UserLike) -> tuple[str, str]: ...
|
|
18
|
+
def refresh_pair(self, refresh_token: str) -> tuple[str, str | None]: ...
|
authkit/settings.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True)
|
|
5
|
+
class AuthSettings:
|
|
6
|
+
secret_key: str
|
|
7
|
+
algorithm: str = "HS256"
|
|
8
|
+
|
|
9
|
+
access_minutes: int = 15
|
|
10
|
+
refresh_days: int = 7
|
|
11
|
+
|
|
12
|
+
cookie_name_access: str = "access_token"
|
|
13
|
+
cookie_name_refresh: str = "refresh_token"
|
|
14
|
+
|
|
15
|
+
accept_header: bool = True
|
|
16
|
+
accept_cookie: bool = True
|
|
17
|
+
|
|
18
|
+
set_cookie_on_login: bool = True
|
|
19
|
+
cookie_secure: bool = True
|
|
20
|
+
cookie_samesite: Literal["lax", "strict", "none"] = "lax"
|
|
21
|
+
cookie_max_age_access: int = 60 * 15
|
|
22
|
+
cookie_max_age_refresh: int = 60 * 60 * 24 * 7
|
|
23
|
+
|
|
24
|
+
# SimpleJWT-style extras
|
|
25
|
+
refresh_rotation: bool = True
|
|
26
|
+
blacklist_after_rotation: bool = True
|
authkit/settings.pyi
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class AuthSettings:
|
|
7
|
+
secret_key: str
|
|
8
|
+
algorithm: str = ...
|
|
9
|
+
|
|
10
|
+
access_minutes: int = ...
|
|
11
|
+
refresh_days: int = ...
|
|
12
|
+
|
|
13
|
+
accept_header: bool = ...
|
|
14
|
+
accept_cookie: bool = ...
|
|
15
|
+
|
|
16
|
+
cookie_name_access: str = ...
|
|
17
|
+
cookie_name_refresh: str = ...
|
|
18
|
+
|
|
19
|
+
set_cookie_on_login: bool = ...
|
|
20
|
+
cookie_secure: bool = ...
|
|
21
|
+
cookie_samesite: Literal["lax", "strict", "none"] = "lax"
|
|
22
|
+
cookie_max_age_access: int = ...
|
|
23
|
+
cookie_max_age_refresh: int = ...
|
|
24
|
+
|
|
25
|
+
refresh_rotation: bool = ...
|
|
26
|
+
blacklist_after_rotation: bool = ...
|
authkit/tokens.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from datetime import datetime , timedelta , timezone
|
|
2
|
+
from jose import jwt , JWTError
|
|
3
|
+
from fastapi import HTTPException , status
|
|
4
|
+
from .settings import AuthSettings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _encode(settings:AuthSettings , payload:dict) -> str:
|
|
8
|
+
return jwt.encode(payload, settings.secret_key , algorithm=settings.algorithm)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _decode(settings:AuthSettings , token:str) -> dict[str, str]:
|
|
12
|
+
return jwt.decode(token,settings.secret_key,algorithms=[settings.algorithm])
|
|
13
|
+
|
|
14
|
+
def create_access_token(settings: AuthSettings, *, subject: str) -> str:
|
|
15
|
+
now = datetime.now(timezone.utc)
|
|
16
|
+
exp = now + timedelta(minutes=settings.access_minutes)
|
|
17
|
+
payload = {"sub": subject, "type": "access", "iat": int(now.timestamp()), "exp": int(exp.timestamp())}
|
|
18
|
+
return _encode(settings, payload)
|
|
19
|
+
|
|
20
|
+
def create_refresh_token(settings: AuthSettings, *, subject: str, jti: str) -> str:
|
|
21
|
+
now = datetime.now(timezone.utc)
|
|
22
|
+
exp = now + timedelta(days=settings.refresh_days)
|
|
23
|
+
payload = {"sub": subject, "type": "refresh", "jti": jti, "iat": int(now.timestamp()), "exp": int(exp.timestamp())}
|
|
24
|
+
return _encode(settings, payload)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def decode_access(settings:AuthSettings,token:str)->dict[str, str]:
|
|
28
|
+
try:
|
|
29
|
+
payload = _decode(settings, token)
|
|
30
|
+
if payload["type"] != "access":
|
|
31
|
+
raise HTTPException(status_code=401,detail="Invalid access token")
|
|
32
|
+
return payload
|
|
33
|
+
except JWTError:
|
|
34
|
+
raise HTTPException(status_code=401,detail="Invalid access token",headers={"WWW-Authenticate":"Bearer"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def decode_refresh(settings:AuthSettings,token:str)-> dict:
|
|
39
|
+
try:
|
|
40
|
+
payload = _decode(settings, token)
|
|
41
|
+
if payload["type"] != "refresh":
|
|
42
|
+
raise HTTPException(status_code=401,detail="Invalid refresh token")
|
|
43
|
+
return payload
|
|
44
|
+
except JWTError:
|
|
45
|
+
raise HTTPException(status_code=401,detail="Invalid refresh token",headers={"WWW-Authenticate":"Bearer"})
|
authkit/tokens.pyi
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from .settings import AuthSettings
|
|
2
|
+
|
|
3
|
+
def create_access_token(settings: AuthSettings, *, subject: str) -> str: ...
|
|
4
|
+
def create_refresh_token(settings: AuthSettings, *, subject: str, jti: str) -> str: ...
|
|
5
|
+
def decode_access(settings: AuthSettings, token: str) -> dict: ...
|
|
6
|
+
def decode_refresh(settings: AuthSettings, token: str) -> dict: ...
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-jwt-authkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FastAPI authentication library with JWT access/refresh tokens, sync & async SQLAlchemy support, built-in auth routes (register/login/refresh/me), secure password hashing, and cookie/header token support.
|
|
5
|
+
Project-URL: Homepage, https://github.com/azimhossaintuhin/fastapi-jwt-authkit
|
|
6
|
+
Project-URL: Repository, https://github.com/azimhossaintuhin/fastapi-jwt-authkit
|
|
7
|
+
Project-URL: Issues, https://github.com/azimhossaintuhin/fastapi-jwt-authkit/issues
|
|
8
|
+
Author: Azim Hossain Tuhin
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Fast Auth Kit contributors
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: access-token,async,auth,authentication,bcrypt,fastapi,fastapi-auth,fastapi-jwt,json-web-token,jwt,login,password-hashing,refresh-token,register,security,sqlalchemy,sync
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Framework :: FastAPI
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Operating System :: OS Independent
|
|
37
|
+
Classifier: Programming Language :: Python
|
|
38
|
+
Classifier: Programming Language :: Python :: 3
|
|
39
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
44
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
45
|
+
Classifier: Topic :: Security
|
|
46
|
+
Requires-Python: >=3.10
|
|
47
|
+
Requires-Dist: bcrypt<5.0.0,>=4.0.0
|
|
48
|
+
Requires-Dist: fastapi>=0.110
|
|
49
|
+
Requires-Dist: hatch>=1.16.3
|
|
50
|
+
Requires-Dist: passlib[bcrypt]>=1.7.4
|
|
51
|
+
Requires-Dist: pydantic[email]>=2.0.0
|
|
52
|
+
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
53
|
+
Requires-Dist: sqlalchemy>=2.0.0
|
|
54
|
+
Provides-Extra: dev
|
|
55
|
+
Requires-Dist: aiosqlite>=0.20.0; extra == 'dev'
|
|
56
|
+
Requires-Dist: build>=1.4.0; extra == 'dev'
|
|
57
|
+
Requires-Dist: httpx>=0.28.0; extra == 'dev'
|
|
58
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
59
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
60
|
+
Requires-Dist: twine>=6.2.0; extra == 'dev'
|
|
61
|
+
Requires-Dist: uvicorn>=0.40.0; extra == 'dev'
|
|
62
|
+
Description-Content-Type: text/markdown
|
|
63
|
+
|
|
64
|
+
# FastAPI Auth Kit
|
|
65
|
+
|
|
66
|
+
**FastAPI authentication library with JWT access/refresh tokens, sync & async SQLAlchemy support, built-in auth routes (register/login/refresh/me), secure password hashing, and cookie/header token support.**
|
|
67
|
+
|
|
68
|
+
Complete authentication toolkit for FastAPI with JWT, designed for both sync and async SQLAlchemy backends. Provides batteries-included auth services, FastAPI routers, token utilities, and a clean protocol-driven repository interface.
|
|
69
|
+
|
|
70
|
+
## Highlights
|
|
71
|
+
|
|
72
|
+
- JWT access + refresh tokens with rotation
|
|
73
|
+
- Sync and async SQLAlchemy adapters
|
|
74
|
+
- Drop-in FastAPI routers for register, login, refresh, logout, and `/me`
|
|
75
|
+
- Cookie and Authorization header support
|
|
76
|
+
- Strong typing and protocol-based extensibility
|
|
77
|
+
- Works with your own User model
|
|
78
|
+
|
|
79
|
+
## Package Layout
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
packages/authkit/src/authkit/
|
|
83
|
+
fastapi/ # FastAPI router builders + schemas
|
|
84
|
+
ext/ # SQLAlchemy protocol adapters (sync/async)
|
|
85
|
+
authenticator.py
|
|
86
|
+
service.py
|
|
87
|
+
tokens.py
|
|
88
|
+
hashing.py
|
|
89
|
+
settings.py
|
|
90
|
+
extractors.py
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Installation
|
|
94
|
+
|
|
95
|
+
### From this repo
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
uv sync
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### In your project
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install fastapi-jwt-authkit
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
> The PyPI package name is `fastapi-jwt-authkit`, the import name is `authkit`.
|
|
108
|
+
|
|
109
|
+
### Typing support
|
|
110
|
+
|
|
111
|
+
Type information (`.pyi` + `py.typed`) is bundled with the package for IDEs and
|
|
112
|
+
static type checkers.
|
|
113
|
+
|
|
114
|
+
## Quickstart (Async)
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from fastapi import FastAPI
|
|
118
|
+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
|
119
|
+
|
|
120
|
+
from authkit import AuthSettings
|
|
121
|
+
from authkit.fastapi.routers import build_auth_router_async
|
|
122
|
+
|
|
123
|
+
from your_app.models import Base, User
|
|
124
|
+
|
|
125
|
+
DATABASE_URL = "sqlite+aiosqlite:///./app.db"
|
|
126
|
+
engine = create_async_engine(DATABASE_URL, echo=False)
|
|
127
|
+
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
128
|
+
|
|
129
|
+
async def get_session():
|
|
130
|
+
async with SessionLocal() as session:
|
|
131
|
+
try:
|
|
132
|
+
yield session
|
|
133
|
+
await session.commit()
|
|
134
|
+
except Exception:
|
|
135
|
+
await session.rollback()
|
|
136
|
+
raise
|
|
137
|
+
|
|
138
|
+
settings = AuthSettings(secret_key="change-me", cookie_secure=False)
|
|
139
|
+
|
|
140
|
+
app = FastAPI()
|
|
141
|
+
auth_router = build_auth_router_async(
|
|
142
|
+
settings=settings,
|
|
143
|
+
get_session=get_session,
|
|
144
|
+
user_model=User,
|
|
145
|
+
)
|
|
146
|
+
app.include_router(auth_router, prefix="/auth", tags=["auth"])
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Quickstart (Sync)
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from fastapi import FastAPI
|
|
153
|
+
from sqlalchemy import create_engine
|
|
154
|
+
from sqlalchemy.orm import sessionmaker
|
|
155
|
+
|
|
156
|
+
from authkit import AuthSettings
|
|
157
|
+
from authkit.fastapi.routers import build_auth_router_sync
|
|
158
|
+
|
|
159
|
+
from your_app.models import Base, User
|
|
160
|
+
|
|
161
|
+
DATABASE_URL = "sqlite:///./app.db"
|
|
162
|
+
engine = create_engine(DATABASE_URL, echo=False)
|
|
163
|
+
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
|
164
|
+
|
|
165
|
+
def get_session():
|
|
166
|
+
session = SessionLocal()
|
|
167
|
+
try:
|
|
168
|
+
yield session
|
|
169
|
+
session.commit()
|
|
170
|
+
except Exception:
|
|
171
|
+
session.rollback()
|
|
172
|
+
raise
|
|
173
|
+
finally:
|
|
174
|
+
session.close()
|
|
175
|
+
|
|
176
|
+
settings = AuthSettings(secret_key="change-me", cookie_secure=False)
|
|
177
|
+
|
|
178
|
+
app = FastAPI()
|
|
179
|
+
auth_router = build_auth_router_sync(
|
|
180
|
+
settings=settings,
|
|
181
|
+
get_session=get_session,
|
|
182
|
+
user_models=User,
|
|
183
|
+
)
|
|
184
|
+
app.include_router(auth_router, prefix="/auth", tags=["auth"])
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Core Concepts
|
|
188
|
+
|
|
189
|
+
### AuthSettings
|
|
190
|
+
|
|
191
|
+
All auth behavior is configured via `AuthSettings`:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
AuthSettings(
|
|
195
|
+
secret_key="your-secret",
|
|
196
|
+
algorithm="HS256",
|
|
197
|
+
access_minutes=15,
|
|
198
|
+
refresh_days=7,
|
|
199
|
+
accept_header=True,
|
|
200
|
+
accept_cookie=True,
|
|
201
|
+
set_cookie_on_login=True,
|
|
202
|
+
cookie_secure=True,
|
|
203
|
+
cookie_samesite="lax",
|
|
204
|
+
)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### AuthService / AsyncAuthService
|
|
208
|
+
|
|
209
|
+
Business logic for creating users, authenticating, and issuing tokens. Uses a
|
|
210
|
+
repository protocol so you can plug in your own persistence layer.
|
|
211
|
+
|
|
212
|
+
### SQLAlchemy Adapters
|
|
213
|
+
|
|
214
|
+
- `SQLAlchemySyncUserProtocol`
|
|
215
|
+
- `SQLAlchemyAsyncUserProtocol`
|
|
216
|
+
|
|
217
|
+
These adapters expect a SQLAlchemy model with the following fields:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
class User(Base):
|
|
221
|
+
id: int
|
|
222
|
+
email: str
|
|
223
|
+
username: str
|
|
224
|
+
password_hash: str
|
|
225
|
+
is_active: bool
|
|
226
|
+
is_staff: bool
|
|
227
|
+
is_superuser: bool
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## API Endpoints
|
|
231
|
+
|
|
232
|
+
When you include the auth router with prefix `/auth`, the following endpoints
|
|
233
|
+
are available:
|
|
234
|
+
|
|
235
|
+
| Method | Path | Description |
|
|
236
|
+
|--------|----------------|-----------------------------|
|
|
237
|
+
| POST | /auth/register | Create a new user |
|
|
238
|
+
| POST | /auth/login | Authenticate + issue tokens |
|
|
239
|
+
| POST | /auth/refresh | Refresh access token |
|
|
240
|
+
| POST | /auth/logout | Clear auth cookies |
|
|
241
|
+
| GET | /auth/me | Get current user |
|
|
242
|
+
|
|
243
|
+
### Request/Response Schemas
|
|
244
|
+
|
|
245
|
+
**Register**
|
|
246
|
+
```json
|
|
247
|
+
{ "email": "user@example.com", "username": "user", "password": "secret" }
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**Login**
|
|
251
|
+
```json
|
|
252
|
+
{ "username_or_email": "user", "password": "secret" }
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**Token Response**
|
|
256
|
+
```json
|
|
257
|
+
{ "access_token": "jwt", "refresh_token": "jwt" }
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Cookies and Headers
|
|
261
|
+
|
|
262
|
+
`AuthSettings` controls where tokens are accepted and how they are stored:
|
|
263
|
+
|
|
264
|
+
- `accept_header`: allow `Authorization: Bearer <token>`
|
|
265
|
+
- `accept_cookie`: allow cookies
|
|
266
|
+
- `set_cookie_on_login`: set cookies on successful login
|
|
267
|
+
|
|
268
|
+
Cookie names and TTL are customizable with:
|
|
269
|
+
`cookie_name_access`, `cookie_name_refresh`, `cookie_max_age_access`,
|
|
270
|
+
`cookie_max_age_refresh`.
|
|
271
|
+
|
|
272
|
+
## Security Considerations
|
|
273
|
+
|
|
274
|
+
- Use a strong `secret_key` and rotate regularly.
|
|
275
|
+
- Set `cookie_secure=True` in production (HTTPS only).
|
|
276
|
+
- Consider `cookie_samesite="strict"` for web apps with tight CSRF control.
|
|
277
|
+
- Short access token TTLs with longer refresh TTLs are recommended.
|
|
278
|
+
|
|
279
|
+
## Production Checklist
|
|
280
|
+
|
|
281
|
+
- [x] `secret_key` stored in a secure secret manager
|
|
282
|
+
- [x] HTTPS enforced
|
|
283
|
+
- [x] `cookie_secure=True`, `cookie_samesite` set per app policy
|
|
284
|
+
- [x] Rotate JWT secret or use KMS-backed signing
|
|
285
|
+
- [x] Enable logging around login and refresh flows
|
|
286
|
+
- [x] Implement account lockout or rate limiting at the API gateway
|
|
287
|
+
- [x] Configure backups for the user datastore
|
|
288
|
+
|
|
289
|
+
## Compatibility
|
|
290
|
+
|
|
291
|
+
- Python: 3.10+
|
|
292
|
+
- FastAPI: 0.110+
|
|
293
|
+
- SQLAlchemy: 2.x
|
|
294
|
+
|
|
295
|
+
## Observability
|
|
296
|
+
|
|
297
|
+
This library raises standard `HTTPException` errors. For production:
|
|
298
|
+
|
|
299
|
+
- Add structured logging around auth endpoints
|
|
300
|
+
- Add tracing/metrics at the FastAPI middleware layer
|
|
301
|
+
|
|
302
|
+
## Versioning
|
|
303
|
+
|
|
304
|
+
Follows semantic versioning: `MAJOR.MINOR.PATCH`.
|
|
305
|
+
|
|
306
|
+
## Testing
|
|
307
|
+
|
|
308
|
+
Run the full test suite:
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
uv run pytest tests/ -v
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
All tests are under `tests/` and cover tokens, hashing, services, and router
|
|
315
|
+
integration (sync + async).
|
|
316
|
+
|
|
317
|
+
## Examples
|
|
318
|
+
|
|
319
|
+
Working example apps are provided:
|
|
320
|
+
|
|
321
|
+
- `examples/async_app.py`
|
|
322
|
+
- `examples/sync_app.py`
|
|
323
|
+
|
|
324
|
+
Run:
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
uv run python -m examples.async_app
|
|
328
|
+
uv run python -m examples.sync_app
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
## Extending the Repo Layer
|
|
332
|
+
|
|
333
|
+
Implement the repo protocol if you use a different persistence layer:
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
class MyRepo:
|
|
337
|
+
def get_by_id(self, user_id: int): ...
|
|
338
|
+
def get_by_email_or_username(self, value: str): ...
|
|
339
|
+
def create_user(self, *, email: str, username: str, password: str,
|
|
340
|
+
is_staff: bool, is_active: bool, is_superuser: bool): ...
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Async version must expose the same methods as `async def`.
|
|
344
|
+
|
|
345
|
+
## Typing Notes
|
|
346
|
+
|
|
347
|
+
Type information is bundled with the package (`.pyi` + `py.typed`). If your IDE
|
|
348
|
+
or type checker does not pick it up, ensure:
|
|
349
|
+
|
|
350
|
+
- Your tooling supports PEP 561
|
|
351
|
+
|
|
352
|
+
## Troubleshooting
|
|
353
|
+
|
|
354
|
+
**Bcrypt errors on Windows**
|
|
355
|
+
|
|
356
|
+
If you see bcrypt backend errors during hashing, ensure you have `bcrypt<5`
|
|
357
|
+
installed. This repo pins it accordingly in `pyproject.toml`.
|
|
358
|
+
|
|
359
|
+
**SQLite in-memory tests**
|
|
360
|
+
|
|
361
|
+
In-memory SQLite needs a `StaticPool` so all sessions share the same DB
|
|
362
|
+
connection. The tests already handle this.
|
|
363
|
+
|
|
364
|
+
## License
|
|
365
|
+
|
|
366
|
+
MIT (see `LICENSE`).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
authkit/__init__.py,sha256=gsjMS3Rzt9fhv_KE1FQj83mqbifXoOZU7tUMNQHForI,74
|
|
2
|
+
authkit/authenticator.py,sha256=DWxq5xiCQe5_AB6MqjM8RaZRi3fqv8tJMEeHD-o-BjI,1900
|
|
3
|
+
authkit/extractors.py,sha256=bqd-FrF0azwQlbldfPXInHxeDL03c1pv3XEG18nuKfM,931
|
|
4
|
+
authkit/hashing.py,sha256=XrUOLOCJnT3XLZKiQrImrEBnrhBgo1sg3FF_qTok35A,306
|
|
5
|
+
authkit/protocols.py,sha256=E7n1VFVb2cr_vCrICaingEYNQ8Ln0ML-bM-6z7_cYlk,930
|
|
6
|
+
authkit/service.py,sha256=gCY6bZ0CvhI4xnRkF9W4PVvme7EnRO2OBIhgCNdmkOk,5839
|
|
7
|
+
authkit/settings.py,sha256=pNs2H0FVVU3BiyVaGD73E2mtGNXgIOvS2BCxv1IHK28,722
|
|
8
|
+
authkit/tokens.py,sha256=ZGgo-ojF1dR6H_lM1IvRQU42L00nc8vmldjna6-qRjQ,1946
|
|
9
|
+
authkit/ext/sqlalchemy/__init__.py,sha256=E99dNP7QXiWqtotp5zS9BcbN-9yzdLMi_lieYOsuqwI,179
|
|
10
|
+
authkit/ext/sqlalchemy/sa_async.py,sha256=IHqiqGEGJaO8r9C-C2PK8lUol7ebmt1Kg3whdEkEhiY,1438
|
|
11
|
+
authkit/ext/sqlalchemy/sa_sync.py,sha256=ou9wW61Xuxi9rog2kj0WvrfeLH6oyiT75IEJ92z2vjA,1790
|
|
12
|
+
authkit/fastapi/__init__.py,sha256=WLmOv8aCLhA1bRuMAHeQMRtXhGGJ56JSkk_f07UGeqs,138
|
|
13
|
+
authkit/fastapi/models.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
authkit/fastapi/routers.py,sha256=7pnoNhKOtjqHCDB0nGS2O7ay34U1A2clyHK_TtzKqpE,6949
|
|
15
|
+
authkit/fastapi/schema.py,sha256=jgR1HQ_97ktrGuMnUSIYmyvXK0AFmatisrVbQiKH76M,228
|
|
16
|
+
authkit/__init__.pyi,sha256=0AUemrFriJT9zpaU7ykTbU9rnfeZx6wDsH-QR6HpJZ4,56
|
|
17
|
+
authkit/authenticator.pyi,sha256=-nv299UzzB0kp0znvNuEKT-Rmcly0MB0Qu77CgOy6To,507
|
|
18
|
+
authkit/extractors.pyi,sha256=tN7FWcNbohTlBc7Gc2qU-6Z-bRHqFyg6xzvvrG5CXXg,242
|
|
19
|
+
authkit/hashing.pyi,sha256=q5eDOjBfigg1FoXzD3x1vNq_-3U2MptD3EUAumKfHO4,115
|
|
20
|
+
authkit/protocols.pyi,sha256=6ES1giz4-igw8U__i0wnaNDwuibtiPDIsU7RyP5n3xg,1035
|
|
21
|
+
authkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
+
authkit/service.pyi,sha256=VXubFz8NMxxcKX_scsh6S7MTcEqCUhrcCgWkzEaB5Uo,1168
|
|
23
|
+
authkit/settings.pyi,sha256=Q8-yqDh_X_NhdNy2j4TP4A7KgFLo_0-f917ftUQ7MxI,648
|
|
24
|
+
authkit/tokens.pyi,sha256=dhsPGciuQJlOcj0SloMgq9Il5KbeKHG7g4rFwo9pzCw,342
|
|
25
|
+
authkit/ext/sqlalchmey/__init__.pyi,sha256=61eiQrAwk76Vh02EJqlPxilZXteAqWdXGIheAvXXNr0,114
|
|
26
|
+
authkit/ext/sqlalchmey/sa_async.pyi,sha256=Ki5O9RB6LCl48NrRdlUVuf1emwQt-CR4ANcfQGiCEBM,616
|
|
27
|
+
authkit/ext/sqlalchmey/sa_sync.pyi,sha256=C7qe0-EUc-Yt2IgAN4aWPA-a4u6ALdt1saofY2IKTR4,579
|
|
28
|
+
authkit/fastapi/__init__.pyi,sha256=Zh4P3i5_DM5ion1Fm_mD7VRMQYWUMdPnTJW2twFL0f8,89
|
|
29
|
+
authkit/fastapi/routers.pyi,sha256=j0P-H6gxl81SfPvnmNzlgmf1Pi9AneCzkjgmHQsV2Tc,363
|
|
30
|
+
fastapi_jwt_authkit-0.1.0.dist-info/METADATA,sha256=JrEWVwySBhVzBrOEVrdviCPZx7iZGjfIKiWXY49H1zw,10906
|
|
31
|
+
fastapi_jwt_authkit-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
32
|
+
fastapi_jwt_authkit-0.1.0.dist-info/licenses/LICENSE,sha256=lGUjO1MnKSevCg_8dVVqs4q_JmqbMRnEBt7QtwFsLfs,1102
|
|
33
|
+
fastapi_jwt_authkit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fast Auth Kit contributors
|
|
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.
|