py-auth-core 0.0.1__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.
- py_auth/__init__.py +45 -0
- py_auth/base.py +20 -0
- py_auth/core.py +259 -0
- py_auth/exceptions.py +54 -0
- py_auth/providers/__init__.py +7 -0
- py_auth/providers/credentials.py +80 -0
- py_auth/py.typed +1 -0
- py_auth/schemas.py +90 -0
- py_auth/utils.py +105 -0
- py_auth_core-0.0.1.dist-info/METADATA +463 -0
- py_auth_core-0.0.1.dist-info/RECORD +14 -0
- py_auth_core-0.0.1.dist-info/WHEEL +5 -0
- py_auth_core-0.0.1.dist-info/licenses/LICENSE +21 -0
- py_auth_core-0.0.1.dist-info/top_level.txt +1 -0
py_auth/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
py-auth: Modular authentication primitives and provider framework for Python backends.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .base import BaseProvider
|
|
6
|
+
from .core import PyAuth
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
AdapterError,
|
|
9
|
+
ConfigurationError,
|
|
10
|
+
DuplicateEntryError,
|
|
11
|
+
ForeignKeyViolationError,
|
|
12
|
+
PyAuthError,
|
|
13
|
+
RecordNotFoundError,
|
|
14
|
+
)
|
|
15
|
+
from .providers.credentials import CredentialsProvider
|
|
16
|
+
from .schemas import (
|
|
17
|
+
AdapterContainer,
|
|
18
|
+
AuthError,
|
|
19
|
+
AuthResult,
|
|
20
|
+
CookieConfig,
|
|
21
|
+
CookieOptions,
|
|
22
|
+
PyAuthAdapterProtocol,
|
|
23
|
+
PyAuthCookiesInput,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"PyAuth",
|
|
28
|
+
"BaseProvider",
|
|
29
|
+
"CredentialsProvider",
|
|
30
|
+
"PyAuthError",
|
|
31
|
+
"DuplicateEntryError",
|
|
32
|
+
"ForeignKeyViolationError",
|
|
33
|
+
"RecordNotFoundError",
|
|
34
|
+
"AdapterError",
|
|
35
|
+
"ConfigurationError",
|
|
36
|
+
"AuthError",
|
|
37
|
+
"AuthResult",
|
|
38
|
+
"CookieOptions",
|
|
39
|
+
"CookieConfig",
|
|
40
|
+
"PyAuthCookiesInput",
|
|
41
|
+
"PyAuthAdapterProtocol",
|
|
42
|
+
"AdapterContainer",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
__version__ = "0.0.1"
|
py_auth/base.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from .schemas import AuthResult
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BaseProvider(ABC):
|
|
8
|
+
"""Abstract base class for all authentication providers."""
|
|
9
|
+
|
|
10
|
+
id: str
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self.id = self.__class__.__name__.lower().replace("provider", "")
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
async def handle_request(self, *args: Any, **kwargs: Any) -> AuthResult:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ["BaseProvider"]
|
py_auth/core.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import hmac
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
DuplicateEntryError,
|
|
8
|
+
ForeignKeyViolationError,
|
|
9
|
+
RecordNotFoundError,
|
|
10
|
+
)
|
|
11
|
+
from .utils import generate_token, hash_token, merge_cookie_config, get_logger
|
|
12
|
+
from .schemas import (
|
|
13
|
+
AdapterContainer,
|
|
14
|
+
AuthError,
|
|
15
|
+
AuthResult,
|
|
16
|
+
PyAuthCookiesInput,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
class PyAuth:
|
|
20
|
+
"""Core py-auth authentication manager."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
adapter: Any,
|
|
25
|
+
providers: Optional[List[Any]] = None,
|
|
26
|
+
cookies: Optional[PyAuthCookiesInput] = None,
|
|
27
|
+
):
|
|
28
|
+
container = AdapterContainer(adapter=adapter)
|
|
29
|
+
self.adapter = container.adapter
|
|
30
|
+
self.cookies = merge_cookie_config(user_config=cookies)
|
|
31
|
+
self._provider_map = {getattr(p, "id", None): p for p in (providers or [])}
|
|
32
|
+
|
|
33
|
+
def get_auth_result(
|
|
34
|
+
self,
|
|
35
|
+
data: Optional[Any] = None,
|
|
36
|
+
error: Optional[AuthError] = None,
|
|
37
|
+
) -> AuthResult:
|
|
38
|
+
"""Construct a standardized py-auth response."""
|
|
39
|
+
return {"data": data, "error": error}
|
|
40
|
+
|
|
41
|
+
def get_signin_with_credentials_result(
|
|
42
|
+
self,
|
|
43
|
+
session_token: str,
|
|
44
|
+
csrf_token: str,
|
|
45
|
+
user: Dict[str, Any],
|
|
46
|
+
) -> AuthResult:
|
|
47
|
+
"""Construct a standardized credentials sign-in success response."""
|
|
48
|
+
return {
|
|
49
|
+
"data": {
|
|
50
|
+
"session_token": session_token,
|
|
51
|
+
"csrf_token": csrf_token,
|
|
52
|
+
"user": user,
|
|
53
|
+
},
|
|
54
|
+
"error": None,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async def verify_session(self, session_token: str, csrf_token: str) -> AuthResult:
|
|
58
|
+
"""Verify an active session and ensure CSRF token validity."""
|
|
59
|
+
if not session_token:
|
|
60
|
+
return self.get_auth_result(
|
|
61
|
+
error={
|
|
62
|
+
"code": "MissingSessionToken",
|
|
63
|
+
"status_code": 401,
|
|
64
|
+
"message": "Session token was not provided.",
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if not csrf_token:
|
|
69
|
+
return self.get_auth_result(
|
|
70
|
+
error={
|
|
71
|
+
"code": "MissingCsrfToken",
|
|
72
|
+
"status_code": 401,
|
|
73
|
+
"message": "CSRF token was not provided.",
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
session_token_hash = hash_token(session_token)
|
|
78
|
+
session = await self.adapter.get_session_by_session_token_hash(
|
|
79
|
+
session_token_hash
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if not session:
|
|
83
|
+
return self.get_auth_result(
|
|
84
|
+
error={
|
|
85
|
+
"code": "InvalidSessionToken",
|
|
86
|
+
"status_code": 401,
|
|
87
|
+
"message": "Session token not found or invalid.",
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
expires = session.get("expires")
|
|
92
|
+
now = datetime.now(timezone.utc)
|
|
93
|
+
if not isinstance(expires, datetime):
|
|
94
|
+
return self.get_auth_result(
|
|
95
|
+
error={
|
|
96
|
+
"code": "InvalidSessionData",
|
|
97
|
+
"status_code": 401,
|
|
98
|
+
"message": "Session expiration format is invalid.",
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if expires.tzinfo is None:
|
|
103
|
+
expires = expires.replace(tzinfo=timezone.utc)
|
|
104
|
+
|
|
105
|
+
if expires <= now:
|
|
106
|
+
await self.adapter.delete_session_by_session_token_hash(session_token_hash)
|
|
107
|
+
return self.get_auth_result(
|
|
108
|
+
error={
|
|
109
|
+
"code": "SessionExpired",
|
|
110
|
+
"status_code": 401,
|
|
111
|
+
"message": "Session has expired. Sign in again to continue.",
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
db_csrf_token = session.get("csrf_token")
|
|
116
|
+
if not db_csrf_token or not hmac.compare_digest(db_csrf_token, csrf_token):
|
|
117
|
+
return self.get_auth_result(
|
|
118
|
+
error={
|
|
119
|
+
"code": "InvalidCsrfToken",
|
|
120
|
+
"status_code": 403,
|
|
121
|
+
"message": "CSRF token validation failed.",
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return self.get_auth_result(data={"session": session})
|
|
126
|
+
|
|
127
|
+
async def signout(self, session_id: str) -> AuthResult:
|
|
128
|
+
"""Invalidate and delete the session identified by session_id with error handling."""
|
|
129
|
+
try:
|
|
130
|
+
await self.adapter.delete_session(session_id)
|
|
131
|
+
return self.get_auth_result(data={"signed_out": True})
|
|
132
|
+
except RecordNotFoundError:
|
|
133
|
+
return self.get_auth_result(data={"signed_out": True})
|
|
134
|
+
except Exception as e:
|
|
135
|
+
get_logger().exception("Unexpected error during signout:", e)
|
|
136
|
+
return self.get_auth_result(
|
|
137
|
+
error={
|
|
138
|
+
"code": "InternalServerError",
|
|
139
|
+
"status_code": 500,
|
|
140
|
+
"message": "An internal error occurred.",
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
async def update_session_csrf_token(self, session_id: str) -> AuthResult:
|
|
145
|
+
"""Updates the CSRF token for a given session with error handling."""
|
|
146
|
+
new_csrf_token = generate_token(num_bytes=32)
|
|
147
|
+
try:
|
|
148
|
+
updated = await self.adapter.update_session(
|
|
149
|
+
session_id, {"csrf_token": new_csrf_token}
|
|
150
|
+
)
|
|
151
|
+
if not updated:
|
|
152
|
+
return self.get_auth_result(
|
|
153
|
+
error={
|
|
154
|
+
"code": "RecordNotFound",
|
|
155
|
+
"status_code": 404,
|
|
156
|
+
"message": "Session not found for CSRF rotation.",
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
return self.get_auth_result(data={"csrf_token": new_csrf_token})
|
|
160
|
+
|
|
161
|
+
except RecordNotFoundError:
|
|
162
|
+
return self.get_auth_result(
|
|
163
|
+
error={
|
|
164
|
+
"code": "RecordNotFound",
|
|
165
|
+
"status_code": 404,
|
|
166
|
+
"message": "Session not found.",
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
get_logger().exception("Unexpected error during CSRF token update:", e)
|
|
171
|
+
return self.get_auth_result(
|
|
172
|
+
error={
|
|
173
|
+
"code": "InternalServerError",
|
|
174
|
+
"status_code": 500,
|
|
175
|
+
"message": "An internal error occurred.",
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
async def signin_with_credentials(self, request_body: Dict[str, Any]) -> AuthResult:
|
|
180
|
+
"""Authenticate user credentials, create a session, and return session tokens."""
|
|
181
|
+
credentials_provider = self._provider_map.get("credentials")
|
|
182
|
+
|
|
183
|
+
if not credentials_provider:
|
|
184
|
+
return self.get_auth_result(
|
|
185
|
+
error={
|
|
186
|
+
"code": "ConfigurationError",
|
|
187
|
+
"status_code": 500,
|
|
188
|
+
"message": "Credentials provider is not configured.",
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
result = await credentials_provider.handle_request(request_body)
|
|
193
|
+
error = result["error"]
|
|
194
|
+
user_data = result["data"]
|
|
195
|
+
|
|
196
|
+
if error:
|
|
197
|
+
return self.get_auth_result(error=error)
|
|
198
|
+
|
|
199
|
+
expires = (datetime.now(timezone.utc) + timedelta(days=30)).replace(tzinfo=None)
|
|
200
|
+
session_token = generate_token(num_bytes=48)
|
|
201
|
+
csrf_token = generate_token(num_bytes=32)
|
|
202
|
+
|
|
203
|
+
max_retries = 3
|
|
204
|
+
created_session = None
|
|
205
|
+
for _ in range(max_retries):
|
|
206
|
+
session_token_hash = hash_token(session_token)
|
|
207
|
+
try:
|
|
208
|
+
created_session = await self.adapter.create_session(
|
|
209
|
+
{
|
|
210
|
+
"session_token_hash": session_token_hash,
|
|
211
|
+
"user_id": user_data.get("id", None),
|
|
212
|
+
"csrf_token": csrf_token,
|
|
213
|
+
"expires": expires,
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
except DuplicateEntryError:
|
|
219
|
+
session_token = generate_token(num_bytes=48)
|
|
220
|
+
|
|
221
|
+
except ForeignKeyViolationError as e:
|
|
222
|
+
get_logger().exception(
|
|
223
|
+
"Foreign key violation occurred while creating session: referenced user record not found.",
|
|
224
|
+
e,
|
|
225
|
+
)
|
|
226
|
+
status_code = getattr(e, "status_code", 400)
|
|
227
|
+
return self.get_auth_result(
|
|
228
|
+
error={
|
|
229
|
+
"code": "ForeignKeyViolation",
|
|
230
|
+
"status_code": status_code,
|
|
231
|
+
"message": "The user account associated with this login no longer exists.",
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
except Exception as e:
|
|
236
|
+
get_logger().exception(
|
|
237
|
+
"Unexpected error occurred during session creation:", e
|
|
238
|
+
)
|
|
239
|
+
status_code = getattr(e, "status_code", 500)
|
|
240
|
+
return self.get_auth_result(
|
|
241
|
+
error={
|
|
242
|
+
"code": "InternalServerError",
|
|
243
|
+
"status_code": status_code,
|
|
244
|
+
"message": "An internal error occurred.",
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if not created_session:
|
|
249
|
+
return self.get_auth_result(
|
|
250
|
+
error={
|
|
251
|
+
"code": "SessionCreationFailed",
|
|
252
|
+
"status_code": 500,
|
|
253
|
+
"message": "Failed to create session after multiple attempts. Please try again.",
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return self.get_signin_with_credentials_result(
|
|
258
|
+
session_token=session_token, csrf_token=csrf_token, user=user_data
|
|
259
|
+
)
|
py_auth/exceptions.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core exception classes for py-auth.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PyAuthError(Exception):
|
|
7
|
+
"""Base exception for all py-auth errors."""
|
|
8
|
+
|
|
9
|
+
status_code: int = 500
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str = "", status_code: int | None = None) -> None:
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
if status_code is not None:
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DuplicateEntryError(PyAuthError):
|
|
18
|
+
"""Raised when a unique constraint or duplicate entry violation occurs (HTTP 409)."""
|
|
19
|
+
|
|
20
|
+
status_code: int = 409
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ForeignKeyViolationError(PyAuthError):
|
|
24
|
+
"""Raised when a foreign key constraint violation occurs (HTTP 400)."""
|
|
25
|
+
|
|
26
|
+
status_code: int = 400
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RecordNotFoundError(PyAuthError):
|
|
30
|
+
"""Raised when a requested database record cannot be found (HTTP 404)."""
|
|
31
|
+
|
|
32
|
+
status_code: int = 404
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AdapterError(PyAuthError):
|
|
36
|
+
"""Raised when an adapter or database engine is misconfigured or fails setup (HTTP 500)."""
|
|
37
|
+
|
|
38
|
+
status_code: int = 500
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConfigurationError(PyAuthError):
|
|
42
|
+
"""Raised when the core library is misconfigured during setup (HTTP 500)."""
|
|
43
|
+
|
|
44
|
+
status_code: int = 500
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"PyAuthError",
|
|
49
|
+
"DuplicateEntryError",
|
|
50
|
+
"ForeignKeyViolationError",
|
|
51
|
+
"RecordNotFoundError",
|
|
52
|
+
"AdapterError",
|
|
53
|
+
"ConfigurationError",
|
|
54
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ValidationError as PydanticValidationError
|
|
4
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, TypedDict, Union, Type
|
|
5
|
+
|
|
6
|
+
from ..base import BaseProvider
|
|
7
|
+
from ..schemas import AuthResult
|
|
8
|
+
from ..utils import get_logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ValidationError(TypedDict):
|
|
12
|
+
"""Represents structured field validation errors for request bodies."""
|
|
13
|
+
|
|
14
|
+
field: str
|
|
15
|
+
errors: List[str]
|
|
16
|
+
|
|
17
|
+
class CredentialsProvider(BaseProvider):
|
|
18
|
+
"""Provider for credential-based authentication.
|
|
19
|
+
|
|
20
|
+
Handles credential validation, sanitization, and user-defined authorization callbacks.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
model: Type[BaseModel],
|
|
26
|
+
authorize: Union[Callable[[Dict[str, Any]], Any], Callable[[Dict[str, Any]], Awaitable[Any]]],
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__()
|
|
29
|
+
self.model = model
|
|
30
|
+
self.authorize = authorize
|
|
31
|
+
|
|
32
|
+
async def handle_request(self, request_body: Dict[str, Any]) -> AuthResult:
|
|
33
|
+
"""Validate request data using the Pydantic model and execute the authorization callback."""
|
|
34
|
+
try:
|
|
35
|
+
validated_data = self.model.model_validate(request_body)
|
|
36
|
+
payload = validated_data.model_dump()
|
|
37
|
+
except PydanticValidationError as e:
|
|
38
|
+
formatted_errors: list[ValidationError] = [
|
|
39
|
+
{"field": ".".join(str(loc) for loc in err["loc"]), "errors": [err["msg"]]}
|
|
40
|
+
for err in e.errors()
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"data": None,
|
|
45
|
+
"error": {
|
|
46
|
+
"code": "ValidationError",
|
|
47
|
+
"status_code": 422,
|
|
48
|
+
"message": "Validation failed.",
|
|
49
|
+
"details":{"validation_errors": formatted_errors}
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
if inspect.iscoroutinefunction(self.authorize):
|
|
55
|
+
result = await self.authorize(payload)
|
|
56
|
+
else:
|
|
57
|
+
result = self.authorize(payload)
|
|
58
|
+
|
|
59
|
+
if not result:
|
|
60
|
+
return {
|
|
61
|
+
"data": None,
|
|
62
|
+
"error": {
|
|
63
|
+
"code": "CredentialsSignIn",
|
|
64
|
+
"status_code": 401,
|
|
65
|
+
"message": "Invalid email or password."
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
return {"data": result, "error": None}
|
|
69
|
+
except Exception as e:
|
|
70
|
+
get_logger().exception("Unhandled exception during credentials authorization callback: %s", e)
|
|
71
|
+
return {
|
|
72
|
+
"data": None,
|
|
73
|
+
"error": {
|
|
74
|
+
"code": "ServerError",
|
|
75
|
+
"status_code": 500,
|
|
76
|
+
"message": "An internal server error occurred.",
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
__all__ = ["ValidationError","CredentialsProvider"]
|
py_auth/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561.
|
py_auth/schemas.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
from typing import (
|
|
4
|
+
Any,
|
|
5
|
+
Dict,
|
|
6
|
+
Literal,
|
|
7
|
+
Optional,
|
|
8
|
+
Protocol,
|
|
9
|
+
TypedDict,
|
|
10
|
+
runtime_checkable,
|
|
11
|
+
)
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, field_validator
|
|
13
|
+
from .exceptions import ConfigurationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthError(TypedDict, total=False):
|
|
17
|
+
code: str
|
|
18
|
+
status_code: int
|
|
19
|
+
message: str
|
|
20
|
+
details: Optional[Dict[str, Any]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthResult(TypedDict):
|
|
24
|
+
data: Optional[Any]
|
|
25
|
+
error: Optional[AuthError]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CookieOptions(BaseModel):
|
|
29
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
30
|
+
|
|
31
|
+
http_only: Optional[bool] = None
|
|
32
|
+
secure: Optional[bool] = None
|
|
33
|
+
same_site: Optional[Literal["lax", "strict", "none"]] = None
|
|
34
|
+
path: Optional[str] = None
|
|
35
|
+
domain: Optional[str] = None
|
|
36
|
+
max_age: Optional[int] = None
|
|
37
|
+
expires: Optional[datetime.datetime] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CookieConfig(BaseModel):
|
|
41
|
+
name: Optional[str] = None
|
|
42
|
+
options: Optional[CookieOptions] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PyAuthCookiesInput(BaseModel):
|
|
46
|
+
session_token: Optional[CookieConfig] = None
|
|
47
|
+
csrf_token: Optional[CookieConfig] = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@runtime_checkable
|
|
51
|
+
class PyAuthAdapterProtocol(Protocol):
|
|
52
|
+
"""Defines the strict structural contract that any py-auth adapter must implement."""
|
|
53
|
+
|
|
54
|
+
async def create_session(self, session_data: Dict[str, Any]) -> Dict[str, Any]: ...
|
|
55
|
+
async def get_session_by_session_token_hash(
|
|
56
|
+
self, session_token_hash: str
|
|
57
|
+
) -> Optional[Dict[str, Any]]: ...
|
|
58
|
+
async def delete_session_by_session_token_hash(
|
|
59
|
+
self, session_token_hash: str
|
|
60
|
+
) -> None: ...
|
|
61
|
+
async def delete_session(self, session_id: str) -> None: ...
|
|
62
|
+
async def update_session(
|
|
63
|
+
self, session_id: str, updates: Dict
|
|
64
|
+
) -> Optional[Dict]: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AdapterContainer(BaseModel):
|
|
68
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
69
|
+
adapter: Any
|
|
70
|
+
|
|
71
|
+
@field_validator("adapter", mode="before")
|
|
72
|
+
@classmethod
|
|
73
|
+
def validate_adapter_interface(cls, v: Any) -> Any:
|
|
74
|
+
if not isinstance(v, PyAuthAdapterProtocol):
|
|
75
|
+
raise ConfigurationError(
|
|
76
|
+
"The provided adapter is missing required methods or attributes defined in 'PyAuthAdapterProtocol'."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return v
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
"AuthError",
|
|
84
|
+
"AuthResult",
|
|
85
|
+
"CookieOptions",
|
|
86
|
+
"CookieConfig",
|
|
87
|
+
"PyAuthCookiesInput",
|
|
88
|
+
"PyAuthAdapterProtocol",
|
|
89
|
+
"AdapterContainer",
|
|
90
|
+
]
|
py_auth/utils.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import os, hashlib, secrets, logging
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Mapping, Optional, Union
|
|
4
|
+
|
|
5
|
+
from .schemas import CookieConfig, PyAuthCookiesInput
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_token(num_bytes: int = 32) -> str:
|
|
9
|
+
"""Generate a cryptographically secure URL-safe token."""
|
|
10
|
+
return secrets.token_urlsafe(num_bytes)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def hash_token(token: str) -> str:
|
|
14
|
+
"""Produce a SHA-256 hash digest of a given token string."""
|
|
15
|
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_logger():
|
|
19
|
+
"""Retrieve the centralized namespaced logger for the py-auth library.
|
|
20
|
+
|
|
21
|
+
Using a dedicated namespace ('py_auth') allows consuming applications
|
|
22
|
+
to configure logging levels, formats, or handlers specifically for this
|
|
23
|
+
package without polluting or altering the main application's logs.
|
|
24
|
+
"""
|
|
25
|
+
return logging.getLogger("py_auth")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_DEFAULTS = {
|
|
29
|
+
"session_token": {
|
|
30
|
+
"name": "__Host-py_auth_session",
|
|
31
|
+
"options": {
|
|
32
|
+
"http_only": True,
|
|
33
|
+
"secure": True,
|
|
34
|
+
"same_site": "lax",
|
|
35
|
+
"path": "/",
|
|
36
|
+
"max_age": 30 * 24 * 60 * 60,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
"csrf_token": {
|
|
40
|
+
"name": "py_auth_csrf",
|
|
41
|
+
"options": {
|
|
42
|
+
"http_only": False,
|
|
43
|
+
"secure": True,
|
|
44
|
+
"same_site": "lax",
|
|
45
|
+
"path": "/",
|
|
46
|
+
"max_age": 60 * 60,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def merge_cookie_config(
|
|
53
|
+
user_config: Optional[Union[PyAuthCookiesInput, Dict[str, Any]]] = None,
|
|
54
|
+
*,
|
|
55
|
+
is_production: bool = os.environ.get("ENVIRONMENT", "development") == "production",
|
|
56
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
57
|
+
"""Merge user cookie options with safe defaults based on environment."""
|
|
58
|
+
|
|
59
|
+
if isinstance(user_config, PyAuthCookiesInput):
|
|
60
|
+
user_config_dict = user_config.model_dump(exclude_unset=True)
|
|
61
|
+
elif isinstance(user_config, dict):
|
|
62
|
+
user_config_dict = user_config
|
|
63
|
+
else:
|
|
64
|
+
user_config_dict = {}
|
|
65
|
+
|
|
66
|
+
# Avoid slow copy.deepcopy by constructing a shallow/dict copy inline
|
|
67
|
+
defaults = {
|
|
68
|
+
k: {"name": v["name"], "options": v["options"].copy()}
|
|
69
|
+
for k, v in _DEFAULTS.items()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
keys = defaults.keys() | user_config_dict.keys()
|
|
73
|
+
result: Dict[str, Dict[str, Any]] = {}
|
|
74
|
+
|
|
75
|
+
for key in keys:
|
|
76
|
+
default_cfg = defaults.get(key)
|
|
77
|
+
if default_cfg:
|
|
78
|
+
merged_name = default_cfg["name"]
|
|
79
|
+
merged_opts = default_cfg["options"].copy()
|
|
80
|
+
else:
|
|
81
|
+
merged_name = key
|
|
82
|
+
merged_opts = {"path": "/", "secure": is_production}
|
|
83
|
+
|
|
84
|
+
user_val = user_config_dict.get(key)
|
|
85
|
+
|
|
86
|
+
if isinstance(user_val, str):
|
|
87
|
+
merged_name = user_val
|
|
88
|
+
elif user_val is not None:
|
|
89
|
+
if isinstance(user_val, CookieConfig):
|
|
90
|
+
parsed = user_val
|
|
91
|
+
elif isinstance(user_val, Mapping):
|
|
92
|
+
parsed = CookieConfig.model_validate(user_val)
|
|
93
|
+
else:
|
|
94
|
+
parsed = None
|
|
95
|
+
|
|
96
|
+
if parsed:
|
|
97
|
+
if parsed.name is not None:
|
|
98
|
+
merged_name = parsed.name
|
|
99
|
+
if parsed.options is not None:
|
|
100
|
+
merged_opts.update(parsed.options.model_dump(exclude_none=True))
|
|
101
|
+
|
|
102
|
+
merged_opts.setdefault("secure", is_production)
|
|
103
|
+
result[key] = {"name": merged_name, "options": merged_opts}
|
|
104
|
+
|
|
105
|
+
return result
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-auth-core
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Core auth primitives and provider framework for py-auth-core.
|
|
5
|
+
Author-email: Olatunji Jamaldeen Omotoyosi <jamaldeen.o@yahoo.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jamaldeen09/py-auth
|
|
8
|
+
Project-URL: Repository, https://github.com/jamaldeen09/py-auth
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: pydantic<3.0.0,>=2.0.0
|
|
21
|
+
Provides-Extra: postgres
|
|
22
|
+
Requires-Dist: asyncpg>=0.28.0; extra == "postgres"
|
|
23
|
+
Provides-Extra: mysql
|
|
24
|
+
Requires-Dist: aiomysql>=0.2.0; extra == "mysql"
|
|
25
|
+
Provides-Extra: sqlite
|
|
26
|
+
Requires-Dist: aiosqlite>=0.19.0; extra == "sqlite"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# py-auth-core
|
|
30
|
+
|
|
31
|
+
**Modular, framework-agnostic authentication primitives for Python backends.**
|
|
32
|
+
|
|
33
|
+
`py-auth-core` gives you secure session management, credential-based sign-in, CSRF protection, and a clean provider/adapter architecture — without forcing a specific ORM, web framework, or database on you.
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/py-auth-core/)
|
|
36
|
+
[](https://pypi.org/project/py-auth-core/)
|
|
37
|
+
[](https://opensource.org/licenses/MIT)
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Table of Contents
|
|
42
|
+
|
|
43
|
+
- [Features](#features)
|
|
44
|
+
- [Installation](#installation)
|
|
45
|
+
- [Quick Start](#quick-start)
|
|
46
|
+
- [Core Concepts](#core-concepts)
|
|
47
|
+
- [PyAuth](#pyauth-1)
|
|
48
|
+
- [Providers](#providers)
|
|
49
|
+
- [Adapters](#adapters)
|
|
50
|
+
- [Cookies](#cookies)
|
|
51
|
+
- [API Reference](#api-reference)
|
|
52
|
+
- [PyAuth class](#pyauth-class)
|
|
53
|
+
- [CredentialsProvider](#credentialsprovider)
|
|
54
|
+
- [BaseProvider](#baseprovider)
|
|
55
|
+
- [Schemas & TypedDicts](#schemas--typeddicts)
|
|
56
|
+
- [Exceptions](#exceptions)
|
|
57
|
+
- [Integrations](#integrations)
|
|
58
|
+
- [Available Adapters](#available-adapters)
|
|
59
|
+
- [Roadmap](#roadmap)
|
|
60
|
+
- [Security Notes](#security-notes)
|
|
61
|
+
- [Contributing](#contributing)
|
|
62
|
+
- [License](#license)
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Features
|
|
67
|
+
|
|
68
|
+
- ✅ **Async-first** — every auth operation is a coroutine
|
|
69
|
+
- ✅ **Provider pattern** — plug in `CredentialsProvider` or use an upcoming provider
|
|
70
|
+
- ✅ **Adapter pattern** — swap the database layer without touching auth logic
|
|
71
|
+
- ✅ **Secure by default** — SHA-256 session-token hashing, `httpOnly` + `Secure` cookies, CSRF protection via `hmac.compare_digest`
|
|
72
|
+
- ✅ **Pydantic v2** request validation built-in
|
|
73
|
+
- ✅ **Framework-agnostic** — works with FastAPI, Starlette, Django, Flask, or any async Python backend
|
|
74
|
+
- ✅ **Typed throughout** — ships a `py.typed` marker; full TypedDict / Protocol coverage
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Installation
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install py-auth-core
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`py-auth-core` requires **Python ≥ 3.9** and **Pydantic ≥ 2.0**.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Quick Start
|
|
89
|
+
|
|
90
|
+
Below is a minimal example using `py-auth-core` directly. If you're on **FastAPI**, see [Integrations](#integrations) — the official integration reduces this to a single line.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from pydantic import BaseModel, EmailStr
|
|
94
|
+
from py_auth import PyAuth, CredentialsProvider
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# 1. Define your credentials schema (Pydantic v2)
|
|
98
|
+
class LoginSchema(BaseModel):
|
|
99
|
+
email: EmailStr
|
|
100
|
+
password: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# 2. Implement your authorization callback
|
|
104
|
+
async def authorize(credentials: dict) -> dict | None:
|
|
105
|
+
"""Check if the user exists — create them if not. Return None to reject."""
|
|
106
|
+
user = await db.find_user_by_email(credentials["email"])
|
|
107
|
+
|
|
108
|
+
if user:
|
|
109
|
+
# Existing user — verify their password
|
|
110
|
+
if not verify_password(credentials["password"], user.hashed_password):
|
|
111
|
+
return None
|
|
112
|
+
return {"id": str(user.id), "email": user.email, "name": user.name}
|
|
113
|
+
|
|
114
|
+
# New user — create them and return their details
|
|
115
|
+
new_user = await db.create_user(
|
|
116
|
+
email=credentials["email"],
|
|
117
|
+
hashed_password=hash_password(credentials["password"]),
|
|
118
|
+
)
|
|
119
|
+
return {"id": str(new_user.id), "email": new_user.email, "name": new_user.name}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# 3. Wire everything together
|
|
123
|
+
credentials_provider = CredentialsProvider(model=LoginSchema, authorize=authorize)
|
|
124
|
+
|
|
125
|
+
auth = PyAuth(
|
|
126
|
+
adapter=my_adapter, # any PyAuthAdapterProtocol-compliant adapter
|
|
127
|
+
providers=[credentials_provider],
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Once `auth` is set up, use it in your route handlers:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
# Sign in
|
|
135
|
+
result = await auth.signin_with_credentials(request_body)
|
|
136
|
+
|
|
137
|
+
# Verify an active session
|
|
138
|
+
result = await auth.verify_session(session_token, csrf_token)
|
|
139
|
+
|
|
140
|
+
# Sign out
|
|
141
|
+
result = await auth.signout(session_id)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Every method returns an `AuthResult` — a plain dict with `data` and `error` keys. Check `result["error"]` first; if it's `None` the operation succeeded.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Core Concepts
|
|
149
|
+
|
|
150
|
+
### PyAuth
|
|
151
|
+
|
|
152
|
+
`PyAuth` is the central manager. It holds your adapter and providers and exposes async methods for every auth flow.
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
PyAuth
|
|
156
|
+
├── adapter ← talks to your database
|
|
157
|
+
├── providers ← one or more auth strategies
|
|
158
|
+
└── cookies ← merged cookie configuration
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Providers
|
|
162
|
+
|
|
163
|
+
A **provider** encapsulates a single authentication strategy. `py-auth-core` ships with one built-in provider today, with more on the way:
|
|
164
|
+
|
|
165
|
+
| Provider | Status | Description |
|
|
166
|
+
|---|---|---|
|
|
167
|
+
| `CredentialsProvider` | ✅ Available | Field-based sign-in (email/password, etc.) via a Pydantic model + async callback |
|
|
168
|
+
| `GoogleProvider` | 🔜 Coming soon | Google OAuth 2.0 |
|
|
169
|
+
| `GithubProvider` | 🔜 Coming soon | GitHub OAuth |
|
|
170
|
+
| `EmailProvider` | 🔜 Coming soon | Passwordless magic-link sign-in |
|
|
171
|
+
|
|
172
|
+
### Adapters
|
|
173
|
+
|
|
174
|
+
An **adapter** is any object that satisfies `PyAuthAdapterProtocol`. It handles all database I/O: creating sessions, updating sessions and looking up / deleting sessions.
|
|
175
|
+
|
|
176
|
+
`py-auth-core` validates your adapter at startup using a structural `Protocol` check — you'll get a clear `ConfigurationError` immediately if a required method is missing, rather than a cryptic failure later.
|
|
177
|
+
|
|
178
|
+
See [Available Adapters](#available-adapters) for ready-made options.
|
|
179
|
+
|
|
180
|
+
### Cookies
|
|
181
|
+
|
|
182
|
+
`py-auth-core` manages two cookies:
|
|
183
|
+
|
|
184
|
+
| Cookie | Default name | Purpose |
|
|
185
|
+
|---|---|---|
|
|
186
|
+
| Session token | `__Host-py_auth_session` | Authenticates the session — `httpOnly`, `Secure`, `SameSite=lax` |
|
|
187
|
+
| CSRF token | `py_auth_csrf` | Double-submit CSRF protection — JavaScript-readable (no `httpOnly`) |
|
|
188
|
+
|
|
189
|
+
Defaults are environment-aware: `secure=True` is always enforced when `ENVIRONMENT=production`. Override any value via `PyAuthCookiesInput`:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from py_auth import PyAuth, PyAuthCookiesInput, CookieConfig, CookieOptions
|
|
193
|
+
|
|
194
|
+
auth = PyAuth(
|
|
195
|
+
adapter=my_adapter,
|
|
196
|
+
providers=[credentials_provider],
|
|
197
|
+
cookies=PyAuthCookiesInput(
|
|
198
|
+
session_token=CookieConfig(
|
|
199
|
+
name="my_session",
|
|
200
|
+
options=CookieOptions(max_age=7 * 24 * 60 * 60), # 7 days
|
|
201
|
+
)
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## API Reference
|
|
209
|
+
|
|
210
|
+
### `PyAuth` class
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
PyAuth(
|
|
214
|
+
adapter: PyAuthAdapterProtocol,
|
|
215
|
+
providers: list[BaseProvider] | None = None,
|
|
216
|
+
cookies: PyAuthCookiesInput | None = None,
|
|
217
|
+
)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**Attributes**
|
|
221
|
+
|
|
222
|
+
| Attribute | Type | Description |
|
|
223
|
+
|---|---|---|
|
|
224
|
+
| `adapter` | `PyAuthAdapterProtocol` | The validated adapter instance |
|
|
225
|
+
| `cookies` | `dict[str, dict]` | Merged cookie config (name + options per token) |
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
#### `await auth.signin_with_credentials(request_body: dict) -> AuthResult`
|
|
230
|
+
|
|
231
|
+
Validates `request_body` with the `CredentialsProvider`'s Pydantic model, calls your `authorize` callback, creates a session, and returns tokens.
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
result = await auth.signin_with_credentials({"email": "...", "password": "..."})
|
|
235
|
+
# Success:
|
|
236
|
+
# result["data"] = {"session_token": "...", "csrf_token": "...", "user": {...}}
|
|
237
|
+
# result["error"] = None
|
|
238
|
+
#
|
|
239
|
+
# Failure:
|
|
240
|
+
# result["data"] = None
|
|
241
|
+
# result["error"] = {"code": "CredentialsSignIn", "status_code": 401, "message": "..."}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
#### `await auth.verify_session(session_token: str, csrf_token: str) -> AuthResult`
|
|
247
|
+
|
|
248
|
+
Hashes the session token, fetches the session from the adapter, checks expiry, and validates the CSRF token with `hmac.compare_digest`.
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
result = await auth.verify_session(session_token, csrf_token)
|
|
252
|
+
# Success: result["data"] = {"session": {...}}
|
|
253
|
+
# Failure: result["error"] = {"code": "SessionExpired" | "InvalidCsrfToken" | ..., ...}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
#### `await auth.signout(session_id: str) -> AuthResult`
|
|
259
|
+
|
|
260
|
+
Deletes the session identified by `session_id`.
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
result = await auth.signout(session_id)
|
|
264
|
+
# result["data"] = {"signed_out": True}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
#### `auth.get_auth_result(data=None, error=None) -> AuthResult`
|
|
270
|
+
|
|
271
|
+
Utility to build a standardised `AuthResult`. Useful in custom middleware or route guards.
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
### `CredentialsProvider`
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
CredentialsProvider(
|
|
279
|
+
model: Type[BaseModel],
|
|
280
|
+
authorize: Callable[[dict], Any] | Callable[[dict], Awaitable[Any]],
|
|
281
|
+
)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
| Parameter | Type | Description |
|
|
285
|
+
|---|---|---|
|
|
286
|
+
| `model` | `Type[BaseModel]` | Pydantic v2 model — the request body is validated against this before `authorize` is called |
|
|
287
|
+
| `authorize` | sync or async callable | Receives the validated payload as a plain `dict`. Return a truthy user dict on success, or `None` / falsy to trigger a `401` |
|
|
288
|
+
|
|
289
|
+
**Validation errors** are automatically serialised into a structured `422` response:
|
|
290
|
+
|
|
291
|
+
```json
|
|
292
|
+
{
|
|
293
|
+
"error": {
|
|
294
|
+
"code": "ValidationError",
|
|
295
|
+
"status_code": 422,
|
|
296
|
+
"message": "Validation failed.",
|
|
297
|
+
"details": {
|
|
298
|
+
"validation_errors": [
|
|
299
|
+
{"field": "email", "errors": ["value is not a valid email address"]}
|
|
300
|
+
]
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
### `BaseProvider`
|
|
309
|
+
|
|
310
|
+
Abstract base class for all providers. Every provider that ships with `py-auth` extends this class. The `id` attribute is automatically derived from the class name (lowercased, with `"provider"` stripped) — e.g. `CredentialsProvider` → `"credentials"`.
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
from py_auth import BaseProvider, AuthResult
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class MyProvider(BaseProvider):
|
|
317
|
+
async def handle_request(self, *args, **kwargs) -> AuthResult: ...
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
### Schemas & TypedDicts
|
|
323
|
+
|
|
324
|
+
#### `AuthResult`
|
|
325
|
+
```python
|
|
326
|
+
class AuthResult(TypedDict):
|
|
327
|
+
data: Any | None
|
|
328
|
+
error: AuthError | None
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
#### `AuthError`
|
|
332
|
+
```python
|
|
333
|
+
class AuthError(TypedDict, total=False):
|
|
334
|
+
code: str # machine-readable, e.g. "InvalidSessionToken"
|
|
335
|
+
status_code: int # HTTP status to send to the client
|
|
336
|
+
message: str # human-readable description
|
|
337
|
+
details: dict # optional structured detail (e.g. validation errors)
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
#### `PyAuthAdapterProtocol`
|
|
341
|
+
```python
|
|
342
|
+
class PyAuthAdapterProtocol(Protocol):
|
|
343
|
+
async def create_session(self, session_data: dict) -> dict: ...
|
|
344
|
+
async def get_session_by_session_token_hash(
|
|
345
|
+
self, token_hash: str
|
|
346
|
+
) -> dict | None: ...
|
|
347
|
+
async def delete_session_by_session_token_hash(self, token_hash: str) -> None: ...
|
|
348
|
+
async def delete_session(self, session_id: str) -> None: ...
|
|
349
|
+
async def update_session(
|
|
350
|
+
self, session_id: str, updates: Dict
|
|
351
|
+
) -> dict | None: ...
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
> The adapter only manages sessions. User lookup and creation live entirely inside your
|
|
355
|
+
> `authorize()` callback — giving you full control over hashing, validation, and
|
|
356
|
+
> any other user-creation logic your app needs.
|
|
357
|
+
|
|
358
|
+
#### `CookieOptions`
|
|
359
|
+
```python
|
|
360
|
+
class CookieOptions(BaseModel):
|
|
361
|
+
http_only: bool | None = None
|
|
362
|
+
secure: bool | None = None
|
|
363
|
+
same_site: Literal["lax", "strict", "none"] | None = None
|
|
364
|
+
path: str | None = None
|
|
365
|
+
domain: str | None = None
|
|
366
|
+
max_age: int | None = None # seconds
|
|
367
|
+
expires: datetime | None = None
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
### Exceptions
|
|
373
|
+
|
|
374
|
+
All exceptions inherit from `PyAuthError` and carry a `status_code` attribute for easy HTTP mapping.
|
|
375
|
+
|
|
376
|
+
| Exception | Default `status_code` | When raised |
|
|
377
|
+
|---|---|---|
|
|
378
|
+
| `PyAuthError` | `500` | Base class; general catch-all |
|
|
379
|
+
| `ConfigurationError` | `500` | Adapter missing required methods, or provider not configured |
|
|
380
|
+
| `AdapterError` | `500` | Database engine setup failure |
|
|
381
|
+
| `DuplicateEntryError` | `409` | Unique constraint violation (e.g. duplicate session token) |
|
|
382
|
+
| `ForeignKeyViolationError` | `400` | Foreign key violation (e.g. referenced user no longer exists) |
|
|
383
|
+
| `RecordNotFoundError` | `404` | Requested record not found |
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Integrations
|
|
388
|
+
|
|
389
|
+
### FastAPI — `py-auth-fastapi`
|
|
390
|
+
|
|
391
|
+
The official FastAPI integration is a separate package that removes all the boilerplate of wiring `py-auth-core` into a FastAPI app. **It's a single line.**
|
|
392
|
+
|
|
393
|
+
You still configure the pieces you own — your providers and your `PyAuth` instance — and the integration handles everything else internally: mounting the auth routes, setting and reading cookies, and returning the right HTTP responses.
|
|
394
|
+
|
|
395
|
+
```python
|
|
396
|
+
# You set up your PyAuth instance as normal...
|
|
397
|
+
auth = PyAuth(adapter=my_adapter, providers=[credentials_provider])
|
|
398
|
+
|
|
399
|
+
# ...then hand it to the integration. That's it.
|
|
400
|
+
app.include_router(PyAuthFastAPI(auth), prefix="/auth", tags=["Authentication"])
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
The integration exposes ready-made routes for sign-in, session verification, and sign-out — no manual cookie handling, no manual response construction.
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
pip install py-auth-fastapi
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
## Available Adapters
|
|
412
|
+
|
|
413
|
+
| Package | Supported Databases | Install |
|
|
414
|
+
|---|---|---|
|
|
415
|
+
| [`py-auth-sqlalchemy`](https://pypi.org/project/py-auth-sqlalchemy/) | PostgreSQL (`asyncpg`), MySQL (`aiomysql`), SQLite (`aiosqlite`) | `pip install py-auth-sqlalchemy` |
|
|
416
|
+
|
|
417
|
+
> More adapters (Tortoise ORM, Motor/MongoDB, Beanie, etc.) are on the roadmap. Community contributions are welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md).
|
|
418
|
+
|
|
419
|
+
---
|
|
420
|
+
|
|
421
|
+
## Roadmap
|
|
422
|
+
|
|
423
|
+
`py-auth-core` is in early release (`0.0.1`). Here's what's planned:
|
|
424
|
+
|
|
425
|
+
**Providers**
|
|
426
|
+
- [ ] `GoogleProvider` — Google OAuth 2.0
|
|
427
|
+
- [ ] `GithubProvider` — GitHub OAuth
|
|
428
|
+
- [ ] `EmailProvider` — passwordless magic-link sign-in
|
|
429
|
+
|
|
430
|
+
**Integrations**
|
|
431
|
+
- [x] `py-auth-fastapi` — FastAPI integration
|
|
432
|
+
- [ ] `py-auth-django` — Django integration
|
|
433
|
+
- [ ] `py-auth-flask` — Flask / Quart integration
|
|
434
|
+
- [ ] `py-auth-litestar` — Litestar integration
|
|
435
|
+
|
|
436
|
+
**Adapters**
|
|
437
|
+
- [ ] Tortoise ORM adapter
|
|
438
|
+
- [ ] Motor (async MongoDB) adapter
|
|
439
|
+
- [ ] Beanie adapter
|
|
440
|
+
|
|
441
|
+
These will land as the project gains traction. If you'd like to see something added sooner, open an issue or a PR on [GitHub](https://github.com/jamaldeen09/py-auth).
|
|
442
|
+
|
|
443
|
+
---
|
|
444
|
+
|
|
445
|
+
## Security Notes
|
|
446
|
+
|
|
447
|
+
- **Session tokens are never stored in plain text.** Only a SHA-256 hex digest is persisted; the raw token lives only in the client cookie.
|
|
448
|
+
- **CSRF validation uses `hmac.compare_digest`** — immune to timing attacks.
|
|
449
|
+
- **Cookie defaults follow the `__Host-` prefix convention** for session cookies: `Secure`, `httpOnly`, `Path=/`, no explicit `Domain`. This provides the strongest possible same-origin binding.
|
|
450
|
+
- The **CSRF cookie intentionally omits `httpOnly`** so your frontend can read it and attach it as a request header for server-side comparison.
|
|
451
|
+
- In `ENVIRONMENT=production`, the `secure` flag is always forced to `True` on every cookie regardless of user configuration.
|
|
452
|
+
|
|
453
|
+
---
|
|
454
|
+
|
|
455
|
+
## Contributing
|
|
456
|
+
|
|
457
|
+
Want to build a new provider, adapter, or integration? See [CONTRIBUTING.md](./CONTRIBUTING.md) for architecture guidelines, how the adapter protocol works, and how to get started.
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
## License
|
|
462
|
+
|
|
463
|
+
MIT — see [LICENSE](./LICENSE) for details.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
py_auth/__init__.py,sha256=W6UQ3bM3k6D_6UT0K6E4UdTEyaHTMLFlbI2Es8N38EU,945
|
|
2
|
+
py_auth/base.py,sha256=aYh_9MYUONzd6K169ESy-u680EtAxSo9zMJjU_RnOfk,443
|
|
3
|
+
py_auth/core.py,sha256=DbR9hDHSEVqdQ6BU-Qm4gQPOYtxO6V710X-ecz7CX0g,9388
|
|
4
|
+
py_auth/exceptions.py,sha256=xOqeqYz0x_jYR2iuozzGh9G0nOM7kZWFlCP_ZsaIBYg,1285
|
|
5
|
+
py_auth/py.typed,sha256=bWew9mHgMy8LqMu7RuqQXFXLBxh2CRx0dUbSx-3wE48,27
|
|
6
|
+
py_auth/schemas.py,sha256=c1hqN9GbCnVTe9jC_W6hFE_U-zZMn8oGwN-Nns3-Foo,2368
|
|
7
|
+
py_auth/utils.py,sha256=uJow2o_fA99tlYQkG2DOewdI1_KQCJt_ttYRofAE80k,3284
|
|
8
|
+
py_auth/providers/__init__.py,sha256=nU_2dYT9DqSpW9z2wR3zkwvxz1Y5nYtEFDMNmXGc9z0,164
|
|
9
|
+
py_auth/providers/credentials.py,sha256=JI2KyeKGSYhzeDiKNqjYoGE4pjwMiTkPUKGIiEDErG0,2792
|
|
10
|
+
py_auth_core-0.0.1.dist-info/licenses/LICENSE,sha256=I-lV8EG8M1xQi7woapfebjyhp5CiY-QESJVVf4yur24,1078
|
|
11
|
+
py_auth_core-0.0.1.dist-info/METADATA,sha256=U10iL6GUhKn6UVwfXzurqg_lO1x38oyhoNqDxyiW5c0,15560
|
|
12
|
+
py_auth_core-0.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
py_auth_core-0.0.1.dist-info/top_level.txt,sha256=7eMpqeN-eJJPiFjT3w9KcqO90D_RLQ7p1BN50041Jko,8
|
|
14
|
+
py_auth_core-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) [2026] [Olatunji Jamaldeen]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
py_auth
|