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 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,7 @@
1
+ """
2
+ Authentication providers for py-auth.
3
+ """
4
+
5
+ from .credentials import (CredentialsProvider,ValidationError)
6
+
7
+ __all__ = ["CredentialsProvider","ValidationError",]
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/py-auth-core.svg)](https://pypi.org/project/py-auth-core/)
36
+ [![Python versions](https://img.shields.io/pypi/pyversions/py-auth-core.svg)](https://pypi.org/project/py-auth-core/)
37
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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