humanoid-login 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ .ruff_cache/
4
+ __pycache__/
5
+ *.py[cod]
6
+ build/
7
+ dist/
8
+ *.egg-info/
9
+ .coverage
10
+ htmlcov/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fardin Ibrahimi
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,5 @@
1
+ include LICENSE
2
+ include README.md
3
+ include humanoid_login/py.typed
4
+ recursive-include humanoid_login *.py
5
+ recursive-include tests *.py
@@ -0,0 +1,218 @@
1
+ Metadata-Version: 2.4
2
+ Name: humanoid-login
3
+ Version: 0.1.0
4
+ Summary: Django REST Framework JWT authentication with HttpOnly cookies.
5
+ Project-URL: Homepage, https://github.com/humanoid-ai/humanoid-login
6
+ Project-URL: Documentation, https://github.com/humanoid-ai/humanoid-login#readme
7
+ Project-URL: Repository, https://github.com/humanoid-ai/humanoid-login
8
+ Project-URL: Issues, https://github.com/humanoid-ai/humanoid-login/issues
9
+ Author: Fardin Ibrahimi
10
+ Maintainer: Fardin Ibrahimi
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: authentication,cookies,django,django-rest-framework,httponly,jwt
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Environment :: Web Environment
16
+ Classifier: Framework :: Django
17
+ Classifier: Framework :: Django :: 5
18
+ Classifier: Framework :: Django :: 5.0
19
+ Classifier: Framework :: Django :: 5.1
20
+ Classifier: Framework :: Django :: 5.2
21
+ Classifier: Intended Audience :: Developers
22
+ Classifier: License :: OSI Approved :: MIT License
23
+ Classifier: Operating System :: OS Independent
24
+ Classifier: Programming Language :: Python
25
+ Classifier: Programming Language :: Python :: 3
26
+ Classifier: Programming Language :: Python :: 3.11
27
+ Classifier: Programming Language :: Python :: 3.12
28
+ Classifier: Programming Language :: Python :: 3.13
29
+ Classifier: Typing :: Typed
30
+ Requires-Python: >=3.11
31
+ Requires-Dist: django>=5.0
32
+ Requires-Dist: djangorestframework-simplejwt>=5.3
33
+ Requires-Dist: djangorestframework>=3.15
34
+ Provides-Extra: dev
35
+ Requires-Dist: build>=1.2; extra == 'dev'
36
+ Requires-Dist: pytest-django>=4.8; extra == 'dev'
37
+ Requires-Dist: pytest>=8.0; extra == 'dev'
38
+ Requires-Dist: ruff>=0.6; extra == 'dev'
39
+ Requires-Dist: twine>=5.0; extra == 'dev'
40
+ Description-Content-Type: text/markdown
41
+
42
+ # humanoid_login
43
+
44
+ **humanoid_login** provides production-ready JWT authentication for Django REST Framework using secure HttpOnly cookies. It wraps `djangorestframework-simplejwt` with ready-made login, logout, refresh, and current-user endpoints so applications can ship cookie-based authentication without duplicating boilerplate.
45
+
46
+ Developed and maintained by **Fardin Ibrahimi**, CEO of **Humanoid**.
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ pip install humanoid-login
52
+ ```
53
+
54
+ ## Quick Start
55
+
56
+ Add the app and authentication class:
57
+
58
+ ```python
59
+ INSTALLED_APPS = [
60
+ "django.contrib.auth",
61
+ "django.contrib.contenttypes",
62
+ "rest_framework",
63
+ "humanoid_login",
64
+ ]
65
+
66
+ REST_FRAMEWORK = {
67
+ "DEFAULT_AUTHENTICATION_CLASSES": [
68
+ "humanoid_login.authentication.CookieJWTAuthentication",
69
+ ],
70
+ }
71
+ ```
72
+
73
+ Include the URLs:
74
+
75
+ ```python
76
+ from django.urls import include, path
77
+
78
+ urlpatterns = [
79
+ path("", include("humanoid_login.urls")),
80
+ ]
81
+ ```
82
+
83
+ Or import views directly:
84
+
85
+ ```python
86
+ from humanoid_login.views import LoginView
87
+ ```
88
+
89
+ ## API Endpoints
90
+
91
+ | Method | Path | Description |
92
+ | --- | --- | --- |
93
+ | `POST` | `/login/` | Authenticates credentials, returns user data, and sets access and refresh cookies. |
94
+ | `POST` | `/logout/` | Deletes cookies and blacklists the refresh token when SimpleJWT blacklist support is enabled. |
95
+ | `POST` | `/refresh/` | Reads the refresh cookie and issues a new access cookie. |
96
+ | `GET` | `/me/` | Returns the authenticated user's compact profile. |
97
+
98
+ ## Login Example
99
+
100
+ ```http
101
+ POST /login/
102
+ Content-Type: application/json
103
+
104
+ {
105
+ "email": "user@example.com",
106
+ "password": "correct-password"
107
+ }
108
+ ```
109
+
110
+ Successful responses set HttpOnly cookies and return:
111
+
112
+ ```json
113
+ {
114
+ "detail": "Login successful.",
115
+ "user": {
116
+ "id": 1,
117
+ "email": "user@example.com",
118
+ "name": "John Doe",
119
+ "role": "admin"
120
+ }
121
+ }
122
+ ```
123
+
124
+ ## Configuration
125
+
126
+ Override defaults in Django settings:
127
+
128
+ ```python
129
+ from datetime import timedelta
130
+
131
+ HUMANOID_LOGIN = {
132
+ "ACCESS_COOKIE": "access_token",
133
+ "REFRESH_COOKIE": "refresh_token",
134
+ "COOKIE_HTTPONLY": True,
135
+ "COOKIE_SECURE": True,
136
+ "COOKIE_SAMESITE": "Lax",
137
+ "COOKIE_PATH": "/",
138
+ "COOKIE_DOMAIN": None,
139
+ "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5),
140
+ "REFRESH_TOKEN_LIFETIME": timedelta(days=1),
141
+ "ROTATE_REFRESH_TOKENS": False,
142
+ "BLACKLIST_AFTER_ROTATION": True,
143
+ "USER_ROLE_ATTRIBUTE": "role",
144
+ }
145
+ ```
146
+
147
+ ### Recommended Production Settings
148
+
149
+ Use HTTPS and secure cookies in production:
150
+
151
+ ```python
152
+ HUMANOID_LOGIN = {
153
+ "COOKIE_SECURE": True,
154
+ "COOKIE_SAMESITE": "Lax",
155
+ "COOKIE_HTTPONLY": True,
156
+ }
157
+ ```
158
+
159
+ If your frontend and API are on different sites, configure CORS and CSRF deliberately and choose `COOKIE_SAMESITE="None"` only with `COOKIE_SECURE=True`.
160
+
161
+ ## Authentication Flow
162
+
163
+ 1. `LoginView` validates email and password through `LoginSerializer`.
164
+ 2. `AuthService` authenticates with Django's configured authentication backends.
165
+ 3. SimpleJWT access and refresh tokens are generated.
166
+ 4. Tokens are stored in configured HttpOnly cookies.
167
+ 5. `CookieJWTAuthentication` reads the access cookie and validates it for DRF permissions.
168
+ 6. `RefreshView` reads the refresh cookie and issues a new access cookie.
169
+ 7. `LogoutView` removes cookies and blacklists refresh tokens when the blacklist app is installed.
170
+
171
+ ## Security Notes
172
+
173
+ - Access and refresh tokens are never returned in response bodies.
174
+ - Cookies default to `HttpOnly=True`.
175
+ - Set `COOKIE_SECURE=True` in production.
176
+ - Use short access-token lifetimes and rotate refresh tokens where appropriate.
177
+ - Consider enabling SimpleJWT's blacklist app:
178
+
179
+ ```python
180
+ INSTALLED_APPS = [
181
+ "rest_framework_simplejwt.token_blacklist",
182
+ ]
183
+ ```
184
+
185
+ Run migrations after enabling blacklist support:
186
+
187
+ ```bash
188
+ python manage.py migrate
189
+ ```
190
+
191
+ ## Testing
192
+
193
+ ```bash
194
+ pip install -e ".[dev]"
195
+ pytest
196
+ python -m build
197
+ ```
198
+
199
+ ## Contributing
200
+
201
+ Contributions are welcome. Please open an issue for larger changes, include tests for new behavior, and keep authentication changes small, explicit, and documented.
202
+
203
+ ## Changelog
204
+
205
+ ### 0.1.0
206
+
207
+ - Initial public release.
208
+ - Cookie-backed JWT login, logout, refresh, and current-user endpoints.
209
+ - Typed package marker and pytest coverage.
210
+
211
+ ## About the Author
212
+
213
+ **humanoid_login** is an open-source package developed and maintained by **Fardin Ibrahimi**, CEO of **Humanoid**, with the goal of making secure JWT cookie authentication effortless for Django REST Framework developers.
214
+
215
+ ---
216
+
217
+ Documentation maintained by **Fardin Ibrahimi**, CEO of **Humanoid**.
218
+ # humanoid-login
@@ -0,0 +1,177 @@
1
+ # humanoid_login
2
+
3
+ **humanoid_login** provides production-ready JWT authentication for Django REST Framework using secure HttpOnly cookies. It wraps `djangorestframework-simplejwt` with ready-made login, logout, refresh, and current-user endpoints so applications can ship cookie-based authentication without duplicating boilerplate.
4
+
5
+ Developed and maintained by **Fardin Ibrahimi**, CEO of **Humanoid**.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install humanoid-login
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ Add the app and authentication class:
16
+
17
+ ```python
18
+ INSTALLED_APPS = [
19
+ "django.contrib.auth",
20
+ "django.contrib.contenttypes",
21
+ "rest_framework",
22
+ "humanoid_login",
23
+ ]
24
+
25
+ REST_FRAMEWORK = {
26
+ "DEFAULT_AUTHENTICATION_CLASSES": [
27
+ "humanoid_login.authentication.CookieJWTAuthentication",
28
+ ],
29
+ }
30
+ ```
31
+
32
+ Include the URLs:
33
+
34
+ ```python
35
+ from django.urls import include, path
36
+
37
+ urlpatterns = [
38
+ path("", include("humanoid_login.urls")),
39
+ ]
40
+ ```
41
+
42
+ Or import views directly:
43
+
44
+ ```python
45
+ from humanoid_login.views import LoginView
46
+ ```
47
+
48
+ ## API Endpoints
49
+
50
+ | Method | Path | Description |
51
+ | --- | --- | --- |
52
+ | `POST` | `/login/` | Authenticates credentials, returns user data, and sets access and refresh cookies. |
53
+ | `POST` | `/logout/` | Deletes cookies and blacklists the refresh token when SimpleJWT blacklist support is enabled. |
54
+ | `POST` | `/refresh/` | Reads the refresh cookie and issues a new access cookie. |
55
+ | `GET` | `/me/` | Returns the authenticated user's compact profile. |
56
+
57
+ ## Login Example
58
+
59
+ ```http
60
+ POST /login/
61
+ Content-Type: application/json
62
+
63
+ {
64
+ "email": "user@example.com",
65
+ "password": "correct-password"
66
+ }
67
+ ```
68
+
69
+ Successful responses set HttpOnly cookies and return:
70
+
71
+ ```json
72
+ {
73
+ "detail": "Login successful.",
74
+ "user": {
75
+ "id": 1,
76
+ "email": "user@example.com",
77
+ "name": "John Doe",
78
+ "role": "admin"
79
+ }
80
+ }
81
+ ```
82
+
83
+ ## Configuration
84
+
85
+ Override defaults in Django settings:
86
+
87
+ ```python
88
+ from datetime import timedelta
89
+
90
+ HUMANOID_LOGIN = {
91
+ "ACCESS_COOKIE": "access_token",
92
+ "REFRESH_COOKIE": "refresh_token",
93
+ "COOKIE_HTTPONLY": True,
94
+ "COOKIE_SECURE": True,
95
+ "COOKIE_SAMESITE": "Lax",
96
+ "COOKIE_PATH": "/",
97
+ "COOKIE_DOMAIN": None,
98
+ "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5),
99
+ "REFRESH_TOKEN_LIFETIME": timedelta(days=1),
100
+ "ROTATE_REFRESH_TOKENS": False,
101
+ "BLACKLIST_AFTER_ROTATION": True,
102
+ "USER_ROLE_ATTRIBUTE": "role",
103
+ }
104
+ ```
105
+
106
+ ### Recommended Production Settings
107
+
108
+ Use HTTPS and secure cookies in production:
109
+
110
+ ```python
111
+ HUMANOID_LOGIN = {
112
+ "COOKIE_SECURE": True,
113
+ "COOKIE_SAMESITE": "Lax",
114
+ "COOKIE_HTTPONLY": True,
115
+ }
116
+ ```
117
+
118
+ If your frontend and API are on different sites, configure CORS and CSRF deliberately and choose `COOKIE_SAMESITE="None"` only with `COOKIE_SECURE=True`.
119
+
120
+ ## Authentication Flow
121
+
122
+ 1. `LoginView` validates email and password through `LoginSerializer`.
123
+ 2. `AuthService` authenticates with Django's configured authentication backends.
124
+ 3. SimpleJWT access and refresh tokens are generated.
125
+ 4. Tokens are stored in configured HttpOnly cookies.
126
+ 5. `CookieJWTAuthentication` reads the access cookie and validates it for DRF permissions.
127
+ 6. `RefreshView` reads the refresh cookie and issues a new access cookie.
128
+ 7. `LogoutView` removes cookies and blacklists refresh tokens when the blacklist app is installed.
129
+
130
+ ## Security Notes
131
+
132
+ - Access and refresh tokens are never returned in response bodies.
133
+ - Cookies default to `HttpOnly=True`.
134
+ - Set `COOKIE_SECURE=True` in production.
135
+ - Use short access-token lifetimes and rotate refresh tokens where appropriate.
136
+ - Consider enabling SimpleJWT's blacklist app:
137
+
138
+ ```python
139
+ INSTALLED_APPS = [
140
+ "rest_framework_simplejwt.token_blacklist",
141
+ ]
142
+ ```
143
+
144
+ Run migrations after enabling blacklist support:
145
+
146
+ ```bash
147
+ python manage.py migrate
148
+ ```
149
+
150
+ ## Testing
151
+
152
+ ```bash
153
+ pip install -e ".[dev]"
154
+ pytest
155
+ python -m build
156
+ ```
157
+
158
+ ## Contributing
159
+
160
+ Contributions are welcome. Please open an issue for larger changes, include tests for new behavior, and keep authentication changes small, explicit, and documented.
161
+
162
+ ## Changelog
163
+
164
+ ### 0.1.0
165
+
166
+ - Initial public release.
167
+ - Cookie-backed JWT login, logout, refresh, and current-user endpoints.
168
+ - Typed package marker and pytest coverage.
169
+
170
+ ## About the Author
171
+
172
+ **humanoid_login** is an open-source package developed and maintained by **Fardin Ibrahimi**, CEO of **Humanoid**, with the goal of making secure JWT cookie authentication effortless for Django REST Framework developers.
173
+
174
+ ---
175
+
176
+ Documentation maintained by **Fardin Ibrahimi**, CEO of **Humanoid**.
177
+ # humanoid-login
@@ -0,0 +1,10 @@
1
+ """JWT authentication for Django REST Framework using HttpOnly cookies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+ __author__ = "Fardin Ibrahimi"
7
+ __maintainer__ = "Fardin Ibrahimi"
8
+ __company__ = "Humanoid"
9
+
10
+ default_app_config = "humanoid_login.apps.HumanoidLoginConfig"
@@ -0,0 +1,13 @@
1
+ """Django application configuration for humanoid_login."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django.apps import AppConfig
6
+
7
+
8
+ class HumanoidLoginConfig(AppConfig):
9
+ """Application metadata used by Django."""
10
+
11
+ default_auto_field = "django.db.models.BigAutoField"
12
+ name = "humanoid_login"
13
+ verbose_name = "Humanoid Login"
@@ -0,0 +1,25 @@
1
+ """Cookie-backed JWT authentication for Django REST Framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rest_framework.request import Request
8
+ from rest_framework_simplejwt.authentication import JWTAuthentication
9
+
10
+ from .settings import api_settings
11
+ from .utils import get_request_cookie
12
+
13
+
14
+ class CookieJWTAuthentication(JWTAuthentication):
15
+ """Authenticate requests using the configured access-token cookie."""
16
+
17
+ def authenticate(self, request: Request) -> tuple[Any, Any] | None:
18
+ """Return the authenticated user and validated token, if a cookie exists."""
19
+
20
+ raw_token = get_request_cookie(request, api_settings.ACCESS_COOKIE)
21
+ if raw_token is None:
22
+ return None
23
+
24
+ validated_token = self.get_validated_token(raw_token)
25
+ return self.get_user(validated_token), validated_token
@@ -0,0 +1,21 @@
1
+ """Package-specific exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rest_framework.exceptions import APIException
6
+
7
+
8
+ class HumanoidLoginError(APIException):
9
+ """Base exception for package-specific API failures."""
10
+
11
+ status_code = 400
12
+ default_detail = "Authentication request could not be completed."
13
+ default_code = "humanoid_login_error"
14
+
15
+
16
+ class MissingRefreshToken(HumanoidLoginError):
17
+ """Raised when a refresh cookie is required but missing."""
18
+
19
+ status_code = 401
20
+ default_detail = "Refresh token cookie is missing."
21
+ default_code = "missing_refresh_token"
@@ -0,0 +1,7 @@
1
+ """Permission aliases for projects that want package-local imports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rest_framework.permissions import IsAuthenticated
6
+
7
+ __all__ = ["IsAuthenticated"]
@@ -0,0 +1,27 @@
1
+ """Serializers used by the public authentication views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rest_framework import serializers
8
+
9
+
10
+ class LoginSerializer(serializers.Serializer[dict[str, str]]):
11
+ """Validate and normalize login credentials."""
12
+
13
+ email = serializers.EmailField(required=True, trim_whitespace=True)
14
+ password = serializers.CharField(
15
+ required=True,
16
+ trim_whitespace=False,
17
+ write_only=True,
18
+ style={"input_type": "password"},
19
+ )
20
+
21
+ def validate(self, attrs: dict[str, Any]) -> dict[str, str]:
22
+ """Return credentials in a predictable shape for the service layer."""
23
+
24
+ return {
25
+ "email": str(attrs["email"]).strip().lower(),
26
+ "password": str(attrs["password"]),
27
+ }
@@ -0,0 +1,152 @@
1
+ """Service layer for cookie-based JWT authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from django.contrib.auth import authenticate, get_user_model
8
+ from django.contrib.auth.models import AbstractBaseUser
9
+ from django.utils.translation import gettext_lazy as _
10
+ from rest_framework import status
11
+ from rest_framework.exceptions import AuthenticationFailed
12
+ from rest_framework.request import Request
13
+ from rest_framework.response import Response
14
+ from rest_framework_simplejwt.exceptions import TokenError
15
+ from rest_framework_simplejwt.tokens import RefreshToken
16
+
17
+ from .exceptions import MissingRefreshToken
18
+ from .serializers import LoginSerializer
19
+ from .settings import api_settings
20
+ from .utils import delete_auth_cookies, get_request_cookie, serialize_user, set_auth_cookies
21
+
22
+
23
+ class AuthService:
24
+ """Business logic for login, logout, refresh, and user responses."""
25
+
26
+ @classmethod
27
+ def login(cls, request: Request) -> Response:
28
+ """Validate credentials, authenticate the user, and set auth cookies."""
29
+
30
+ serializer = LoginSerializer(data=request.data)
31
+ serializer.is_valid(raise_exception=True)
32
+ user = cls.authenticate(request, serializer.validated_data)
33
+ access, refresh = cls.generate_tokens(user)
34
+
35
+ response = Response(
36
+ {
37
+ "detail": "Login successful.",
38
+ "user": serialize_user(user),
39
+ },
40
+ status=status.HTTP_200_OK,
41
+ )
42
+ set_auth_cookies(response, access, refresh)
43
+ return response
44
+
45
+ @staticmethod
46
+ def authenticate(request: Request, credentials: dict[str, str]) -> AbstractBaseUser:
47
+ """Authenticate credentials against Django's configured auth backends."""
48
+
49
+ email = credentials["email"]
50
+ password = credentials["password"]
51
+ UserModel = get_user_model()
52
+ username_field = UserModel.USERNAME_FIELD
53
+
54
+ user = authenticate(
55
+ request=request,
56
+ **{username_field: email, "password": password},
57
+ )
58
+ if user is None and username_field == "username":
59
+ username = AuthService.get_username_for_email(email)
60
+ if username:
61
+ user = authenticate(request=request, username=username, password=password)
62
+ elif user is None:
63
+ user = authenticate(request=request, username=email, password=password)
64
+ if user is None:
65
+ raise AuthenticationFailed(
66
+ _("Unable to log in with the provided credentials."),
67
+ code="invalid_credentials",
68
+ )
69
+ if not user.is_active:
70
+ raise AuthenticationFailed(_("User account is disabled."), code="inactive_user")
71
+ return user
72
+
73
+ @staticmethod
74
+ def get_username_for_email(email: str) -> str | None:
75
+ """Resolve Django's default username field from a unique email address."""
76
+
77
+ UserModel = get_user_model()
78
+ try:
79
+ user = UserModel._default_manager.get(email__iexact=email)
80
+ except (UserModel.DoesNotExist, UserModel.MultipleObjectsReturned):
81
+ return None
82
+ return str(user.get_username())
83
+
84
+ @staticmethod
85
+ def generate_tokens(user: AbstractBaseUser) -> tuple[Any, RefreshToken]:
86
+ """Create access and refresh tokens for a user."""
87
+
88
+ refresh = RefreshToken.for_user(user)
89
+ refresh.set_exp(lifetime=api_settings.REFRESH_TOKEN_LIFETIME)
90
+ access = refresh.access_token
91
+ access.set_exp(lifetime=api_settings.ACCESS_TOKEN_LIFETIME)
92
+ return access, refresh
93
+
94
+ @classmethod
95
+ def logout(cls, request: Request) -> Response:
96
+ """Delete authentication cookies and blacklist refresh token when possible."""
97
+
98
+ refresh_token = get_request_cookie(request, api_settings.REFRESH_COOKIE)
99
+ if refresh_token:
100
+ cls.blacklist_refresh_token(refresh_token)
101
+
102
+ response = Response({"detail": "Logout successful."}, status=status.HTTP_200_OK)
103
+ delete_auth_cookies(response)
104
+ return response
105
+
106
+ @staticmethod
107
+ def blacklist_refresh_token(refresh_token: str) -> None:
108
+ """Blacklist a refresh token when SimpleJWT's blacklist app is installed."""
109
+
110
+ try:
111
+ token = RefreshToken(refresh_token)
112
+ blacklist = getattr(token, "blacklist", None)
113
+ if callable(blacklist):
114
+ blacklist()
115
+ except TokenError:
116
+ return
117
+
118
+ @classmethod
119
+ def refresh(cls, request: Request) -> Response:
120
+ """Issue a new access cookie from the configured refresh-token cookie."""
121
+
122
+ refresh_token = get_request_cookie(request, api_settings.REFRESH_COOKIE)
123
+ if not refresh_token:
124
+ raise MissingRefreshToken()
125
+
126
+ try:
127
+ refresh = RefreshToken(refresh_token)
128
+ except TokenError as exc:
129
+ raise AuthenticationFailed(_("Refresh token is invalid or expired.")) from exc
130
+
131
+ access = refresh.access_token
132
+ access.set_exp(lifetime=api_settings.ACCESS_TOKEN_LIFETIME)
133
+ rotated_refresh = (
134
+ cls.rotate_refresh_token(refresh) if api_settings.ROTATE_REFRESH_TOKENS else None
135
+ )
136
+
137
+ response = Response({"detail": "Token refreshed."}, status=status.HTTP_200_OK)
138
+ set_auth_cookies(response, access, rotated_refresh)
139
+ return response
140
+
141
+ @staticmethod
142
+ def rotate_refresh_token(refresh: RefreshToken) -> RefreshToken:
143
+ """Rotate a refresh token using the same behavior as SimpleJWT serializers."""
144
+
145
+ if api_settings.BLACKLIST_AFTER_ROTATION:
146
+ blacklist = getattr(refresh, "blacklist", None)
147
+ if callable(blacklist):
148
+ blacklist()
149
+ refresh.set_jti()
150
+ refresh.set_iat()
151
+ refresh.set_exp(lifetime=api_settings.REFRESH_TOKEN_LIFETIME)
152
+ return refresh
@@ -0,0 +1,61 @@
1
+ """Runtime settings for humanoid_login.
2
+
3
+ Configuration is read from ``settings.HUMANOID_LOGIN`` and merged with secure,
4
+ documented defaults. Values are resolved lazily so Django tests and projects can
5
+ override settings at runtime.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import timedelta
11
+ from typing import Any, Final
12
+
13
+ from django.conf import settings as django_settings
14
+
15
+ DEFAULTS: Final[dict[str, Any]] = {
16
+ "ACCESS_COOKIE": "access_token",
17
+ "REFRESH_COOKIE": "refresh_token",
18
+ "COOKIE_HTTPONLY": True,
19
+ "COOKIE_SECURE": False,
20
+ "COOKIE_SAMESITE": "Lax",
21
+ "COOKIE_PATH": "/",
22
+ "COOKIE_DOMAIN": None,
23
+ "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5),
24
+ "REFRESH_TOKEN_LIFETIME": timedelta(days=1),
25
+ "ROTATE_REFRESH_TOKENS": False,
26
+ "BLACKLIST_AFTER_ROTATION": True,
27
+ "USER_ROLE_ATTRIBUTE": "role",
28
+ "USER_NAME_ATTRIBUTES": ("get_full_name", "name", "first_name", "username"),
29
+ }
30
+
31
+
32
+ class HumanoidLoginSettings:
33
+ """Typed accessor for package settings."""
34
+
35
+ def __init__(self, user_settings: dict[str, Any] | None = None) -> None:
36
+ self._user_settings = user_settings
37
+
38
+ @property
39
+ def user_settings(self) -> dict[str, Any]:
40
+ """Return the current ``HUMANOID_LOGIN`` Django setting."""
41
+
42
+ if self._user_settings is not None:
43
+ return self._user_settings
44
+ value = getattr(django_settings, "HUMANOID_LOGIN", {})
45
+ if value is None:
46
+ return {}
47
+ if not isinstance(value, dict):
48
+ msg = "HUMANOID_LOGIN must be a dictionary."
49
+ raise TypeError(msg)
50
+ return value
51
+
52
+ def __getattr__(self, attr: str) -> Any:
53
+ """Resolve known settings with defaults."""
54
+
55
+ if attr not in DEFAULTS:
56
+ msg = f"Invalid humanoid_login setting: {attr}"
57
+ raise AttributeError(msg)
58
+ return self.user_settings.get(attr, DEFAULTS[attr])
59
+
60
+
61
+ api_settings = HumanoidLoginSettings()
@@ -0,0 +1,16 @@
1
+ """URL routes provided by humanoid_login."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django.urls import path
6
+
7
+ from .views import LoginView, LogoutView, MeView, RefreshView
8
+
9
+ app_name = "humanoid_login"
10
+
11
+ urlpatterns = [
12
+ path("login/", LoginView.as_view(), name="login"),
13
+ path("logout/", LogoutView.as_view(), name="logout"),
14
+ path("refresh/", RefreshView.as_view(), name="refresh"),
15
+ path("me/", MeView.as_view(), name="me"),
16
+ ]
@@ -0,0 +1,110 @@
1
+ """Utility helpers for cookies, tokens, and user serialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import timedelta
6
+ from typing import Any
7
+
8
+ from django.contrib.auth import get_user_model
9
+ from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
10
+ from django.http import HttpRequest
11
+ from django.utils.encoding import force_str
12
+ from rest_framework.response import Response
13
+ from rest_framework_simplejwt.tokens import Token
14
+
15
+ from .settings import api_settings
16
+
17
+
18
+ def lifetime_to_max_age(lifetime: timedelta | None) -> int | None:
19
+ """Convert a token lifetime to a cookie ``max_age`` value."""
20
+
21
+ if lifetime is None:
22
+ return None
23
+ return max(0, int(lifetime.total_seconds()))
24
+
25
+
26
+ def set_cookie(
27
+ response: Response,
28
+ name: str,
29
+ value: str,
30
+ max_age: int | None,
31
+ ) -> None:
32
+ """Set a package-authentication cookie on a response."""
33
+
34
+ response.set_cookie(
35
+ key=name,
36
+ value=value,
37
+ max_age=max_age,
38
+ httponly=api_settings.COOKIE_HTTPONLY,
39
+ secure=api_settings.COOKIE_SECURE,
40
+ samesite=api_settings.COOKIE_SAMESITE,
41
+ path=api_settings.COOKIE_PATH,
42
+ domain=api_settings.COOKIE_DOMAIN,
43
+ )
44
+
45
+
46
+ def set_auth_cookies(response: Response, access: Token, refresh: Token | None) -> None:
47
+ """Attach access and optional refresh cookies to a response."""
48
+
49
+ set_cookie(
50
+ response,
51
+ api_settings.ACCESS_COOKIE,
52
+ str(access),
53
+ lifetime_to_max_age(api_settings.ACCESS_TOKEN_LIFETIME),
54
+ )
55
+ if refresh is not None:
56
+ set_cookie(
57
+ response,
58
+ api_settings.REFRESH_COOKIE,
59
+ str(refresh),
60
+ lifetime_to_max_age(api_settings.REFRESH_TOKEN_LIFETIME),
61
+ )
62
+
63
+
64
+ def delete_auth_cookies(response: Response) -> None:
65
+ """Delete access and refresh cookies using the configured cookie scope."""
66
+
67
+ for cookie_name in (api_settings.ACCESS_COOKIE, api_settings.REFRESH_COOKIE):
68
+ response.delete_cookie(
69
+ key=cookie_name,
70
+ path=api_settings.COOKIE_PATH,
71
+ domain=api_settings.COOKIE_DOMAIN,
72
+ samesite=api_settings.COOKIE_SAMESITE,
73
+ )
74
+
75
+
76
+ def get_request_cookie(request: HttpRequest, cookie_name: str) -> str | None:
77
+ """Read a cookie value from a Django or DRF request."""
78
+
79
+ value = request.COOKIES.get(cookie_name)
80
+ return force_str(value) if value else None
81
+
82
+
83
+ def get_user_display_name(user: AbstractBaseUser) -> str:
84
+ """Return a friendly display name for a user."""
85
+
86
+ for attribute in api_settings.USER_NAME_ATTRIBUTES:
87
+ value = getattr(user, attribute, None)
88
+ if callable(value):
89
+ value = value()
90
+ if value:
91
+ return str(value)
92
+ return ""
93
+
94
+
95
+ def serialize_user(user: AbstractBaseUser | AnonymousUser) -> dict[str, Any]:
96
+ """Serialize a user object for authentication responses."""
97
+
98
+ if not user or isinstance(user, AnonymousUser):
99
+ return {}
100
+
101
+ email = getattr(user, "email", "") or ""
102
+ role = getattr(user, api_settings.USER_ROLE_ATTRIBUTE, None)
103
+ user_id = getattr(user, get_user_model()._meta.pk.attname)
104
+
105
+ return {
106
+ "id": user_id,
107
+ "email": str(email),
108
+ "name": get_user_display_name(user),
109
+ "role": role,
110
+ }
@@ -0,0 +1,60 @@
1
+ """Public Django REST Framework views exposed by humanoid_login."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ from rest_framework.permissions import IsAuthenticated
8
+ from rest_framework.request import Request
9
+ from rest_framework.response import Response
10
+ from rest_framework.views import APIView
11
+
12
+ from .authentication import CookieJWTAuthentication
13
+ from .services import AuthService
14
+ from .utils import serialize_user
15
+
16
+
17
+ class LoginView(APIView):
18
+ """Authenticate a user and set HttpOnly JWT cookies."""
19
+
20
+ permission_classes: ClassVar[list[type]] = []
21
+
22
+ def post(self, request: Request) -> Response:
23
+ """Handle login requests."""
24
+
25
+ return AuthService.login(request)
26
+
27
+
28
+ class LogoutView(APIView):
29
+ """Clear JWT cookies and blacklist refresh tokens when available."""
30
+
31
+ authentication_classes: ClassVar[list[type]] = [CookieJWTAuthentication]
32
+ permission_classes: ClassVar[list[type]] = []
33
+
34
+ def post(self, request: Request) -> Response:
35
+ """Handle logout requests."""
36
+
37
+ return AuthService.logout(request)
38
+
39
+
40
+ class RefreshView(APIView):
41
+ """Refresh access cookies using the refresh-token cookie."""
42
+
43
+ permission_classes: ClassVar[list[type]] = []
44
+
45
+ def post(self, request: Request) -> Response:
46
+ """Handle token refresh requests."""
47
+
48
+ return AuthService.refresh(request)
49
+
50
+
51
+ class MeView(APIView):
52
+ """Return the currently authenticated user."""
53
+
54
+ authentication_classes: ClassVar[list[type]] = [CookieJWTAuthentication]
55
+ permission_classes: ClassVar[list[type]] = [IsAuthenticated]
56
+
57
+ def get(self, request: Request) -> Response:
58
+ """Return a compact user profile."""
59
+
60
+ return Response(serialize_user(request.user))
@@ -0,0 +1,89 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "humanoid-login"
7
+ version = "0.1.0"
8
+ description = "Django REST Framework JWT authentication with HttpOnly cookies."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Fardin Ibrahimi" }
14
+ ]
15
+ maintainers = [
16
+ { name = "Fardin Ibrahimi" }
17
+ ]
18
+ keywords = [
19
+ "django",
20
+ "django-rest-framework",
21
+ "jwt",
22
+ "httponly",
23
+ "cookies",
24
+ "authentication",
25
+ ]
26
+ classifiers = [
27
+ "Development Status :: 4 - Beta",
28
+ "Environment :: Web Environment",
29
+ "Framework :: Django",
30
+ "Framework :: Django :: 5",
31
+ "Framework :: Django :: 5.0",
32
+ "Framework :: Django :: 5.1",
33
+ "Framework :: Django :: 5.2",
34
+ "Intended Audience :: Developers",
35
+ "License :: OSI Approved :: MIT License",
36
+ "Operating System :: OS Independent",
37
+ "Programming Language :: Python",
38
+ "Programming Language :: Python :: 3",
39
+ "Programming Language :: Python :: 3.11",
40
+ "Programming Language :: Python :: 3.12",
41
+ "Programming Language :: Python :: 3.13",
42
+ "Typing :: Typed",
43
+ ]
44
+ dependencies = [
45
+ "Django>=5.0",
46
+ "djangorestframework>=3.15",
47
+ "djangorestframework-simplejwt>=5.3",
48
+ ]
49
+
50
+ [project.optional-dependencies]
51
+ dev = [
52
+ "build>=1.2",
53
+ "pytest>=8.0",
54
+ "pytest-django>=4.8",
55
+ "ruff>=0.6",
56
+ "twine>=5.0",
57
+ ]
58
+
59
+ [project.urls]
60
+ Homepage = "https://github.com/humanoid-ai/humanoid-login"
61
+ Documentation = "https://github.com/humanoid-ai/humanoid-login#readme"
62
+ Repository = "https://github.com/humanoid-ai/humanoid-login"
63
+ Issues = "https://github.com/humanoid-ai/humanoid-login/issues"
64
+
65
+ [tool.hatch.build.targets.sdist]
66
+ include = [
67
+ "/humanoid_login",
68
+ "/tests",
69
+ "/README.md",
70
+ "/LICENSE",
71
+ "/MANIFEST.in",
72
+ "/pyproject.toml",
73
+ ]
74
+
75
+ [tool.hatch.build.targets.wheel]
76
+ packages = ["humanoid_login"]
77
+
78
+ [tool.pytest.ini_options]
79
+ DJANGO_SETTINGS_MODULE = "tests.settings"
80
+ python_files = ["test_*.py"]
81
+ testpaths = ["tests"]
82
+ addopts = "-ra"
83
+
84
+ [tool.ruff]
85
+ line-length = 100
86
+ target-version = "py311"
87
+
88
+ [tool.ruff.lint]
89
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,28 @@
1
+ """Pytest fixtures for humanoid_login."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from django.contrib.auth import get_user_model
7
+ from rest_framework.test import APIClient
8
+
9
+
10
+ @pytest.fixture
11
+ def api_client() -> APIClient:
12
+ """Return a DRF API client."""
13
+
14
+ return APIClient()
15
+
16
+
17
+ @pytest.fixture
18
+ def user(db):
19
+ """Create a reusable active user."""
20
+
21
+ UserModel = get_user_model()
22
+ return UserModel.objects.create_user(
23
+ username="user@example.com",
24
+ email="user@example.com",
25
+ password="correct-password",
26
+ first_name="John",
27
+ last_name="Doe",
28
+ )
@@ -0,0 +1,44 @@
1
+ """Django settings used by the humanoid_login test suite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ SECRET_KEY = "humanoid-login-tests-secret-key-with-enough-entropy"
6
+ DEBUG = True
7
+ ROOT_URLCONF = "tests.urls"
8
+ USE_TZ = True
9
+ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
10
+
11
+ INSTALLED_APPS = [
12
+ "django.contrib.auth",
13
+ "django.contrib.contenttypes",
14
+ "rest_framework",
15
+ "rest_framework_simplejwt.token_blacklist",
16
+ "humanoid_login",
17
+ ]
18
+
19
+ DATABASES = {
20
+ "default": {
21
+ "ENGINE": "django.db.backends.sqlite3",
22
+ "NAME": ":memory:",
23
+ }
24
+ }
25
+
26
+ MIDDLEWARE: list[str] = []
27
+
28
+ PASSWORD_HASHERS = [
29
+ "django.contrib.auth.hashers.MD5PasswordHasher",
30
+ ]
31
+
32
+ AUTHENTICATION_BACKENDS = [
33
+ "django.contrib.auth.backends.ModelBackend",
34
+ ]
35
+
36
+ REST_FRAMEWORK = {
37
+ "DEFAULT_AUTHENTICATION_CLASSES": [
38
+ "humanoid_login.authentication.CookieJWTAuthentication",
39
+ ],
40
+ }
41
+
42
+ HUMANOID_LOGIN = {
43
+ "COOKIE_SECURE": False,
44
+ }
@@ -0,0 +1,208 @@
1
+ """Integration tests for cookie-based JWT authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import timedelta
6
+
7
+ import pytest
8
+ from django.test import override_settings
9
+ from rest_framework import status
10
+ from rest_framework_simplejwt.tokens import RefreshToken
11
+
12
+ from humanoid_login.authentication import CookieJWTAuthentication
13
+
14
+ pytestmark = pytest.mark.django_db
15
+
16
+
17
+ def test_login_success_returns_user_and_sets_http_only_cookies(api_client, user) -> None:
18
+ """A valid login returns user data and creates both auth cookies."""
19
+
20
+ response = api_client.post(
21
+ "/login/",
22
+ {"email": user.email, "password": "correct-password"},
23
+ format="json",
24
+ )
25
+
26
+ assert response.status_code == status.HTTP_200_OK
27
+ assert response.data["detail"] == "Login successful."
28
+ assert response.data["user"] == {
29
+ "id": user.id,
30
+ "email": "user@example.com",
31
+ "name": "John Doe",
32
+ "role": None,
33
+ }
34
+ assert "access_token" in response.cookies
35
+ assert "refresh_token" in response.cookies
36
+ assert response.cookies["access_token"]["httponly"] is True
37
+ assert response.cookies["refresh_token"]["httponly"] is True
38
+
39
+
40
+ def test_login_failure_rejects_invalid_credentials(api_client, user) -> None:
41
+ """Invalid credentials return an authentication failure."""
42
+
43
+ response = api_client.post(
44
+ "/login/",
45
+ {"email": user.email, "password": "wrong-password"},
46
+ format="json",
47
+ )
48
+
49
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
50
+ assert "access_token" not in response.cookies
51
+ assert "refresh_token" not in response.cookies
52
+
53
+
54
+ def test_login_supports_email_when_username_differs(api_client, django_user_model) -> None:
55
+ """Projects using Django's default username field can still log in by email."""
56
+
57
+ user = django_user_model.objects.create_user(
58
+ username="john",
59
+ email="john@example.com",
60
+ password="correct-password",
61
+ )
62
+
63
+ response = api_client.post(
64
+ "/login/",
65
+ {"email": user.email, "password": "correct-password"},
66
+ format="json",
67
+ )
68
+
69
+ assert response.status_code == status.HTTP_200_OK
70
+ assert response.data["user"]["email"] == "john@example.com"
71
+ assert "access_token" in response.cookies
72
+
73
+
74
+ def test_cookie_jwt_authentication_allows_drf_permissions(api_client, user) -> None:
75
+ """The /me/ endpoint authenticates with only the access-token cookie."""
76
+
77
+ login_response = api_client.post(
78
+ "/login/",
79
+ {"email": user.email, "password": "correct-password"},
80
+ format="json",
81
+ )
82
+ api_client.cookies["access_token"] = login_response.cookies["access_token"].value
83
+
84
+ response = api_client.get("/me/")
85
+
86
+ assert response.status_code == status.HTTP_200_OK
87
+ assert response.data["email"] == user.email
88
+
89
+
90
+ def test_authentication_class_returns_none_when_cookie_missing(api_client) -> None:
91
+ """Requests without access cookies are ignored by the package authenticator."""
92
+
93
+ request = api_client.get("/me/").wsgi_request
94
+
95
+ assert CookieJWTAuthentication().authenticate(request) is None
96
+
97
+
98
+ def test_logout_deletes_cookies(api_client, user) -> None:
99
+ """Logout clears both configured authentication cookies."""
100
+
101
+ login_response = api_client.post(
102
+ "/login/",
103
+ {"email": user.email, "password": "correct-password"},
104
+ format="json",
105
+ )
106
+ api_client.cookies["access_token"] = login_response.cookies["access_token"].value
107
+ api_client.cookies["refresh_token"] = login_response.cookies["refresh_token"].value
108
+
109
+ response = api_client.post("/logout/")
110
+
111
+ assert response.status_code == status.HTTP_200_OK
112
+ assert response.cookies["access_token"]["max-age"] == 0
113
+ assert response.cookies["refresh_token"]["max-age"] == 0
114
+
115
+
116
+ def test_refresh_issues_new_access_cookie(api_client, user) -> None:
117
+ """Refresh reads the refresh cookie and sets a new access cookie."""
118
+
119
+ login_response = api_client.post(
120
+ "/login/",
121
+ {"email": user.email, "password": "correct-password"},
122
+ format="json",
123
+ )
124
+ api_client.cookies["refresh_token"] = login_response.cookies["refresh_token"].value
125
+ api_client.cookies.pop("access_token", None)
126
+
127
+ response = api_client.post("/refresh/")
128
+
129
+ assert response.status_code == status.HTTP_200_OK
130
+ assert "access_token" in response.cookies
131
+ assert "refresh_token" not in response.cookies
132
+
133
+
134
+ def test_refresh_rotates_refresh_cookie_when_enabled(api_client, user) -> None:
135
+ """Refresh rotation updates the refresh cookie when configured."""
136
+
137
+ login_response = api_client.post(
138
+ "/login/",
139
+ {"email": user.email, "password": "correct-password"},
140
+ format="json",
141
+ )
142
+ api_client.cookies["refresh_token"] = login_response.cookies["refresh_token"].value
143
+
144
+ with override_settings(HUMANOID_LOGIN={"ROTATE_REFRESH_TOKENS": True}):
145
+ response = api_client.post("/refresh/")
146
+
147
+ assert response.status_code == status.HTTP_200_OK
148
+ assert "access_token" in response.cookies
149
+ assert "refresh_token" in response.cookies
150
+
151
+
152
+ def test_expired_access_token_is_rejected(api_client, user) -> None:
153
+ """Expired access tokens fail authentication."""
154
+
155
+ refresh = RefreshToken.for_user(user)
156
+ access = refresh.access_token
157
+ access.set_exp(lifetime=timedelta(seconds=-1))
158
+ api_client.cookies["access_token"] = str(access)
159
+
160
+ response = api_client.get("/me/")
161
+
162
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
163
+
164
+
165
+ def test_invalid_access_token_is_rejected(api_client) -> None:
166
+ """Malformed access tokens fail authentication."""
167
+
168
+ api_client.cookies["access_token"] = "not-a-token"
169
+
170
+ response = api_client.get("/me/")
171
+
172
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
173
+
174
+
175
+ def test_missing_refresh_cookie_is_rejected(api_client) -> None:
176
+ """Refresh requires a configured refresh cookie."""
177
+
178
+ response = api_client.post("/refresh/")
179
+
180
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
181
+
182
+
183
+ def test_configuration_overrides_cookie_names_and_attributes(api_client, user) -> None:
184
+ """Django settings override cookie names, attributes, and lifetimes."""
185
+
186
+ with override_settings(
187
+ HUMANOID_LOGIN={
188
+ "ACCESS_COOKIE": "humanoid_access",
189
+ "REFRESH_COOKIE": "humanoid_refresh",
190
+ "COOKIE_SECURE": True,
191
+ "COOKIE_SAMESITE": "Strict",
192
+ "ACCESS_TOKEN_LIFETIME": timedelta(seconds=60),
193
+ "REFRESH_TOKEN_LIFETIME": timedelta(seconds=120),
194
+ }
195
+ ):
196
+ response = api_client.post(
197
+ "/login/",
198
+ {"email": user.email, "password": "correct-password"},
199
+ format="json",
200
+ )
201
+
202
+ assert response.status_code == status.HTTP_200_OK
203
+ assert "humanoid_access" in response.cookies
204
+ assert "humanoid_refresh" in response.cookies
205
+ assert response.cookies["humanoid_access"]["secure"] is True
206
+ assert response.cookies["humanoid_access"]["samesite"] == "Strict"
207
+ assert response.cookies["humanoid_access"]["max-age"] == 60
208
+ assert response.cookies["humanoid_refresh"]["max-age"] == 120
@@ -0,0 +1,9 @@
1
+ """URL configuration for package tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django.urls import include, path
6
+
7
+ urlpatterns = [
8
+ path("", include("humanoid_login.urls")),
9
+ ]