karrio-server 2026.1.3__py3-none-any.whl → 2026.1.4__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.
karrio/server/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.1.3
1
+ 2026.1.4
@@ -513,6 +513,15 @@ SIMPLE_JWT = {
513
513
  "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
514
514
  }
515
515
 
516
+ # JWT Cookie settings for HTTP-only cookie authentication
517
+ JWT_AUTH_COOKIE = config("JWT_AUTH_COOKIE", default="karrio_access_token")
518
+ JWT_REFRESH_COOKIE = config("JWT_REFRESH_COOKIE", default="karrio_refresh_token")
519
+ JWT_AUTH_COOKIE_SECURE = config(
520
+ "JWT_AUTH_COOKIE_SECURE", default=USE_HTTPS, cast=bool
521
+ )
522
+ JWT_AUTH_COOKIE_SAMESITE = config("JWT_AUTH_COOKIE_SAMESITE", default="Lax")
523
+ JWT_AUTH_COOKIE_PATH = config("JWT_AUTH_COOKIE_PATH", default="/")
524
+
516
525
  # OAuth2 config
517
526
  OIDC_RSA_PRIVATE_KEY = config("OIDC_RSA_PRIVATE_KEY", default="").replace("\\n", "\n")
518
527
  OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application"
karrio/server/urls/jwt.py CHANGED
@@ -1,7 +1,10 @@
1
1
  from django.urls import path
2
2
  from django.contrib.auth import get_user_model
3
3
  from django.utils.translation import gettext_lazy as _
4
- from rest_framework import serializers, exceptions
4
+ from django.conf import settings
5
+ from rest_framework import serializers, exceptions, status
6
+ from rest_framework.response import Response
7
+ from rest_framework.permissions import AllowAny
5
8
  from rest_framework_simplejwt import views as jwt_views, serializers as jwt
6
9
  from two_factor.utils import default_device
7
10
 
@@ -11,6 +14,99 @@ ENDPOINT_ID = "&&" # This endpoint id is used to make operation ids unique make
11
14
  User = get_user_model()
12
15
 
13
16
 
17
+ # --- Cookie helpers (shared by all JWT views) ---
18
+
19
+ def get_cookie_config(include_max_age=True):
20
+ """Build cookie configuration from Django settings."""
21
+ config = dict(
22
+ access_cookie_name=getattr(settings, "JWT_AUTH_COOKIE", "karrio_access_token"),
23
+ refresh_cookie_name=getattr(settings, "JWT_REFRESH_COOKIE", "karrio_refresh_token"),
24
+ secure=getattr(settings, "JWT_AUTH_COOKIE_SECURE", getattr(settings, "USE_HTTPS", False)),
25
+ samesite=getattr(settings, "JWT_AUTH_COOKIE_SAMESITE", "Lax"),
26
+ path=getattr(settings, "JWT_AUTH_COOKIE_PATH", "/"),
27
+ )
28
+
29
+ if include_max_age:
30
+ jwt_config = getattr(settings, "SIMPLE_JWT", {})
31
+ access_lifetime = jwt_config.get("ACCESS_TOKEN_LIFETIME")
32
+ refresh_lifetime = jwt_config.get("REFRESH_TOKEN_LIFETIME")
33
+ config.update(
34
+ access_max_age=int(access_lifetime.total_seconds()) if access_lifetime else 1800,
35
+ refresh_max_age=int(refresh_lifetime.total_seconds()) if refresh_lifetime else 259200,
36
+ )
37
+
38
+ return config
39
+
40
+
41
+ def set_auth_cookies(response, access_token, refresh_token):
42
+ """Set HTTP-only cookies for access and refresh tokens on the response."""
43
+ config = get_cookie_config()
44
+
45
+ response.set_cookie(
46
+ config["access_cookie_name"],
47
+ access_token,
48
+ max_age=config["access_max_age"],
49
+ httponly=True,
50
+ secure=config["secure"],
51
+ samesite=config["samesite"],
52
+ path=config["path"],
53
+ )
54
+ response.set_cookie(
55
+ config["refresh_cookie_name"],
56
+ refresh_token,
57
+ max_age=config["refresh_max_age"],
58
+ httponly=True,
59
+ secure=config["secure"],
60
+ samesite=config["samesite"],
61
+ path=config["path"],
62
+ )
63
+
64
+
65
+ def clear_auth_cookies(response):
66
+ """Clear HTTP-only auth cookies by expiring them immediately."""
67
+ config = get_cookie_config(include_max_age=False)
68
+
69
+ for cookie_name in [config["access_cookie_name"], config["refresh_cookie_name"]]:
70
+ response.set_cookie(
71
+ cookie_name,
72
+ "",
73
+ max_age=0,
74
+ httponly=True,
75
+ secure=config["secure"],
76
+ samesite=config["samesite"],
77
+ path=config["path"],
78
+ )
79
+
80
+
81
+ def get_refresh_token(request):
82
+ """Get refresh token from cookie, falling back to request body."""
83
+ cookie_name = getattr(settings, "JWT_REFRESH_COOKIE", "karrio_refresh_token")
84
+ refresh_token = (
85
+ request.COOKIES.get(cookie_name)
86
+ or request.data.get("refresh")
87
+ )
88
+
89
+ if not refresh_token:
90
+ raise exceptions.ValidationError(
91
+ {"refresh": _("Refresh token is required.")}
92
+ )
93
+
94
+ return refresh_token
95
+
96
+
97
+ def _build_token_response(access_token, refresh_token):
98
+ """Build a 201 token pair response with no-cache headers."""
99
+ response = Response(
100
+ {"access": access_token, "refresh": refresh_token},
101
+ status=status.HTTP_201_CREATED,
102
+ )
103
+ response["Cache-Control"] = "no-store"
104
+ response["CDN-Cache-Control"] = "no-store"
105
+ return response
106
+
107
+
108
+ # --- Serializers ---
109
+
14
110
  class AccessToken(serializers.Serializer):
15
111
  access = serializers.CharField()
16
112
 
@@ -49,7 +145,13 @@ class TokenObtainPairSerializer(jwt.TokenObtainPairSerializer):
49
145
 
50
146
  class TokenRefreshSerializer(jwt.TokenRefreshSerializer):
51
147
  def validate(self, attrs: dict):
52
- refresh = jwt.RefreshToken(attrs["refresh"])
148
+ refresh_token = attrs.get("refresh")
149
+ if not refresh_token:
150
+ raise exceptions.ValidationError(
151
+ {"refresh": _("Refresh token is required.")}
152
+ )
153
+
154
+ refresh = jwt.RefreshToken(refresh_token)
53
155
 
54
156
  if not refresh["is_verified"]:
55
157
  raise exceptions.AuthenticationFailed(
@@ -86,7 +188,13 @@ class VerifiedTokenObtainPairSerializer(jwt.TokenRefreshSerializer):
86
188
  )
87
189
 
88
190
  def validate(self, attrs):
89
- refresh = self.token_class(attrs["refresh"])
191
+ refresh_token = attrs.get("refresh")
192
+ if not refresh_token:
193
+ raise exceptions.ValidationError(
194
+ {"refresh": _("Refresh token is required.")}
195
+ )
196
+
197
+ refresh = self.token_class(refresh_token)
90
198
  user = User.objects.get(id=refresh["user_id"])
91
199
  refresh["is_verified"] = self._validate_otp(attrs["otp_token"], user)
92
200
 
@@ -126,6 +234,8 @@ class VerifiedTokenObtainPairSerializer(jwt.TokenRefreshSerializer):
126
234
  )
127
235
 
128
236
 
237
+ # --- Views ---
238
+
129
239
  class TokenObtainPair(jwt_views.TokenObtainPairView):
130
240
  serializer_class = TokenObtainPairSerializer
131
241
 
@@ -134,13 +244,17 @@ class TokenObtainPair(jwt_views.TokenObtainPairView):
134
244
  tags=["Auth"],
135
245
  operation_id=f"{ENDPOINT_ID}authenticate",
136
246
  summary="Obtain auth token pair",
137
- description="Authenticate the user and return a token pair",
247
+ description="Authenticate the user and return a token pair. Tokens are stored in HTTP-only cookies.",
138
248
  responses={201: TokenPair()},
139
249
  )
140
250
  def post(self, *args, **kwargs):
141
- response = super().post(*args, **kwargs)
142
- response["Cache-Control"] = "no-store"
143
- response["CDN-Cache-Control"] = "no-store"
251
+ serializer = self.get_serializer(data=self.request.data)
252
+ serializer.is_valid(raise_exception=True)
253
+
254
+ data = serializer.validated_data
255
+ response = _build_token_response(data["access"], data["refresh"])
256
+ set_auth_cookies(response, data["access"], data["refresh"])
257
+
144
258
  return response
145
259
 
146
260
 
@@ -152,13 +266,23 @@ class TokenRefresh(jwt_views.TokenRefreshView):
152
266
  tags=["Auth"],
153
267
  operation_id=f"{ENDPOINT_ID}refresh_token",
154
268
  summary="Refresh auth token",
155
- description="Authenticate the user and return a token pair",
269
+ description="Refresh the authentication token. Tokens are stored in HTTP-only cookies.",
156
270
  responses={201: TokenPair()},
157
271
  )
158
272
  def post(self, *args, **kwargs):
159
- response = super().post(*args, **kwargs)
160
- response["Cache-Control"] = "no-store"
161
- response["CDN-Cache-Control"] = "no-store"
273
+ refresh_token = get_refresh_token(self.request)
274
+
275
+ serializer = self.get_serializer(data={"refresh": refresh_token})
276
+ serializer.is_valid(raise_exception=True)
277
+
278
+ data = serializer.validated_data
279
+ access = data["access"]
280
+ refresh = data.get("refresh")
281
+
282
+ response = _build_token_response(access, refresh or refresh_token)
283
+ if refresh is not None:
284
+ set_auth_cookies(response, access, refresh)
285
+
162
286
  return response
163
287
 
164
288
 
@@ -187,13 +311,52 @@ class VerifiedTokenPair(jwt_views.TokenVerifyView):
187
311
  tags=["Auth"],
188
312
  operation_id=f"{ENDPOINT_ID}get_verified_token",
189
313
  summary="Get verified JWT token",
190
- description="Get a verified JWT token pair by submitting a Two-Factor authentication code.",
314
+ description="Get a verified JWT token pair by submitting a Two-Factor authentication code. Tokens are stored in HTTP-only cookies.",
191
315
  responses={201: TokenPair()},
192
316
  )
193
317
  def post(self, *args, **kwargs):
194
- response = super().post(*args, **kwargs)
318
+ refresh_token = get_refresh_token(self.request)
319
+
320
+ serializer = self.get_serializer(data={
321
+ "refresh": refresh_token,
322
+ "otp_token": self.request.data.get("otp_token"),
323
+ })
324
+ serializer.is_valid(raise_exception=True)
325
+
326
+ data = serializer.validated_data
327
+ access = data["access"]
328
+ refresh = data.get("refresh")
329
+
330
+ response = _build_token_response(access, refresh or refresh_token)
331
+ if refresh is not None:
332
+ set_auth_cookies(response, access, refresh)
333
+
334
+ return response
335
+
336
+
337
+ class LogoutView(jwt_views.TokenVerifyView):
338
+ """Logout view that clears HTTP-only auth cookies."""
339
+ permission_classes = [AllowAny]
340
+
341
+ @openapi.extend_schema(
342
+ auth=[],
343
+ tags=["Auth"],
344
+ operation_id=f"{ENDPOINT_ID}logout",
345
+ summary="Logout",
346
+ description="Clear authentication cookies and logout the user. Accessible without authentication.",
347
+ responses={200: openapi.OpenApiTypes.OBJECT},
348
+ )
349
+ def post(self, *args, **kwargs):
350
+ response = Response(
351
+ {"detail": "Successfully logged out."},
352
+ status=status.HTTP_200_OK,
353
+ )
354
+
355
+ clear_auth_cookies(response)
356
+
195
357
  response["Cache-Control"] = "no-store"
196
358
  response["CDN-Cache-Control"] = "no-store"
359
+
197
360
  return response
198
361
 
199
362
 
@@ -202,4 +365,5 @@ urlpatterns = [
202
365
  path("api/token/refresh", TokenRefresh.as_view(), name="jwt-refresh"),
203
366
  path("api/token/verify", TokenVerify.as_view(), name="jwt-verify"),
204
367
  path("api/token/verified", VerifiedTokenPair.as_view(), name="verified-jwt-pair"),
368
+ path("api/logout", LogoutView.as_view(), name="jwt-logout"),
205
369
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karrio_server
3
- Version: 2026.1.3
3
+ Version: 2026.1.4
4
4
  Summary: Multi-carrier shipping API
5
5
  Author-email: karrio <hello@karrio.io>
6
6
  License-Expression: Apache-2.0
@@ -1,4 +1,4 @@
1
- karrio/server/VERSION,sha256=WO9GTvjg5mWKSal2BTFuGS6jCULaqRd8bW8fb_aB_6A,8
1
+ karrio/server/VERSION,sha256=QphdNAnHQVjVDgkk5LVXj3qpqRHRvOVtCt2bs_goMQ4,8
2
2
  karrio/server/__init__.py,sha256=iOEMwnlORWezdO8-2vxBIPSR37D7JGjluZ8f55vzxls,81
3
3
  karrio/server/__main__.py,sha256=hy2-Zb2wSVe_Pu6zWZ-BhiM4rBaGZ3D16HSuhudygqg,632
4
4
  karrio/server/asgi.py,sha256=LsZYMWo8U9zURVPdHnvUsziOhMjdCdQoD2-gMJbS2U0,462
@@ -7,7 +7,7 @@ karrio/server/wsgi.py,sha256=SpWqkEYlMsj89_znZ8p8IjH3EgTVRWRq_9eS8t64dMw,403
7
7
  karrio/server/lib/otel_huey.py,sha256=6MP6vX6b6x6RPF2K1m8B8L8S9GK1Q3vANmLidsxh65k,5428
8
8
  karrio/server/settings/__init__.py,sha256=iw-NBcReOnDYpnvSEBdYDfV7jC0040jYdupnmSdElec,866
9
9
  karrio/server/settings/apm.py,sha256=Zt8dP2y8DjPa_iNyv3ZywXTbHlULWc06SLPJhlTTA-A,19983
10
- karrio/server/settings/base.py,sha256=9Rfe24fieBtwyU8FjBAC2tDMrUPl8Unz-1_zpjZ5Oj4,23144
10
+ karrio/server/settings/base.py,sha256=8macll9KmzuTd2jfqa5Pch_sTnDNh-MGezrNOXsmQbE,23598
11
11
  karrio/server/settings/cache.py,sha256=UYBd6pG-5pWSUlu4d3PuWGWYCiiDBL2oc1hYzq131zY,4045
12
12
  karrio/server/settings/constance.py,sha256=84Ldmwr_o_5WBy5awf-k5AWpYmpMt2dzO3xh6MoqEXU,8013
13
13
  karrio/server/settings/debug.py,sha256=fFyK2XX47UGeK0eRNSV-9ZLaComay5QvJW0692vaH98,527
@@ -73,10 +73,10 @@ karrio/server/static/karrio/js/karrio.js.map,sha256=9p642er0UNa_Knyf2OdHCEDbeV4G
73
73
  karrio/server/templates/admin/base_site.html,sha256=kbcdvehXZ1EHaw07JL7fSZmjrnVMKsnydjbTKa7Ondg,466
74
74
  karrio/server/templates/openapi/openapi.html,sha256=3ApCZ5pE6Wjv7CJllVbqD2WiDQuLy-BFS-IIHurXhBY,1133
75
75
  karrio/server/urls/__init__.py,sha256=SHmmXmVn9Hbf7eZ2wcL10hG84yQPBrcVaCsvi5cdrrA,2062
76
- karrio/server/urls/jwt.py,sha256=QN2L-EpUEQCF2UGYPu_VVlA49Fc0BtcY7Ov3-xpp7_U,6772
76
+ karrio/server/urls/jwt.py,sha256=xZXgbdZAYPObeexgprokWKOeCXr8SqhTRlR5-P1Es5E,12198
77
77
  karrio/server/urls/tokens.py,sha256=5slk9F0HPgV3dRnR9UipAl-R8837eiGqKJ31QnCdLnU,8215
78
- karrio_server-2026.1.3.dist-info/METADATA,sha256=b65q090uHERze0GXM6kS09rMfK51QPnyuSsnOMHKgl0,4303
79
- karrio_server-2026.1.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
80
- karrio_server-2026.1.3.dist-info/entry_points.txt,sha256=c2eftt6MpJjyp0OFv1OmO9nUYSDemt9fGq_RDdvpGLw,55
81
- karrio_server-2026.1.3.dist-info/top_level.txt,sha256=D1D7x8R3cTfjF_15mfiO7wCQ5QMtuM4x8GaPr7z5i78,12
82
- karrio_server-2026.1.3.dist-info/RECORD,,
78
+ karrio_server-2026.1.4.dist-info/METADATA,sha256=z6PS_ZXTUjxqx2nMUzqVnvM6a34hrU27mU1ks7Hu7vY,4303
79
+ karrio_server-2026.1.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
80
+ karrio_server-2026.1.4.dist-info/entry_points.txt,sha256=c2eftt6MpJjyp0OFv1OmO9nUYSDemt9fGq_RDdvpGLw,55
81
+ karrio_server-2026.1.4.dist-info/top_level.txt,sha256=D1D7x8R3cTfjF_15mfiO7wCQ5QMtuM4x8GaPr7z5i78,12
82
+ karrio_server-2026.1.4.dist-info/RECORD,,