Skip to content

Commit 3c65577

Browse files
authored
Merge pull request #952 from karrioapi/fix-drifts
fix: sync platform drifts - HTTP-only cookies, carrier enrichments, and bug fixes
2 parents d40baee + 6ada001 commit 3c65577

File tree

129 files changed

+4672
-1067
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

129 files changed

+4672
-1067
lines changed

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,26 @@
1+
# Karrio 2026.1.4
2+
3+
## Changes
4+
5+
### Feat
6+
7+
- feat: enrich carrier connectors with services CSV data and configuration annotations
8+
- feat: add HTTP-only cookie authentication for JWT tokens
9+
10+
### Fix
11+
12+
- fix: various fixes for gateway, pickups, label creation and pricing
13+
- sec(fix): potential fix for code scanning alert no. 60: construction of a cookie using user-supplied input
14+
15+
### Chore
16+
17+
- chore: upgrade Next.js to 16.1.5
18+
- chore: fix frontend builds for Next.js 16 compatibility (Turbopack, CSS import ordering, deprecated APIs)
19+
- chore: add MANIFEST.in files for connector service CSV packaging
20+
- chore: fix SDK tracking tests for DHL Parcel DE, Hermes and DPD connectors
21+
22+
---
23+
124
# Karrio 2026.1.3
225

326
## Changes

PRDs/REPOSITORY_MERGE_AND_LICENSE_GATING.md

Lines changed: 1962 additions & 0 deletions
Large diffs are not rendered by default.

apps/api/karrio/server/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2026.1.3
1+
2026.1.4

apps/api/karrio/server/settings/base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,15 @@
513513
"SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
514514
}
515515

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+
516525
# OAuth2 config
517526
OIDC_RSA_PRIVATE_KEY = config("OIDC_RSA_PRIVATE_KEY", default="").replace("\\n", "\n")
518527
OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application"

apps/api/karrio/server/urls/jwt.py

Lines changed: 177 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from django.urls import path
22
from django.contrib.auth import get_user_model
33
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
58
from rest_framework_simplejwt import views as jwt_views, serializers as jwt
69
from two_factor.utils import default_device
710

@@ -11,6 +14,99 @@
1114
User = get_user_model()
1215

1316

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+
14110
class AccessToken(serializers.Serializer):
15111
access = serializers.CharField()
16112

@@ -49,7 +145,13 @@ def validate(self, attrs):
49145

50146
class TokenRefreshSerializer(jwt.TokenRefreshSerializer):
51147
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)
53155

54156
if not refresh["is_verified"]:
55157
raise exceptions.AuthenticationFailed(
@@ -86,7 +188,13 @@ class VerifiedTokenObtainPairSerializer(jwt.TokenRefreshSerializer):
86188
)
87189

88190
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)
90198
user = User.objects.get(id=refresh["user_id"])
91199
refresh["is_verified"] = self._validate_otp(attrs["otp_token"], user)
92200

@@ -126,6 +234,8 @@ def _validate_otp(self, otp_token, user) -> bool:
126234
)
127235

128236

237+
# --- Views ---
238+
129239
class TokenObtainPair(jwt_views.TokenObtainPairView):
130240
serializer_class = TokenObtainPairSerializer
131241

@@ -134,13 +244,17 @@ class TokenObtainPair(jwt_views.TokenObtainPairView):
134244
tags=["Auth"],
135245
operation_id=f"{ENDPOINT_ID}authenticate",
136246
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.",
138248
responses={201: TokenPair()},
139249
)
140250
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+
144258
return response
145259

146260

@@ -152,13 +266,23 @@ class TokenRefresh(jwt_views.TokenRefreshView):
152266
tags=["Auth"],
153267
operation_id=f"{ENDPOINT_ID}refresh_token",
154268
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.",
156270
responses={201: TokenPair()},
157271
)
158272
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+
162286
return response
163287

164288

@@ -187,13 +311,52 @@ class VerifiedTokenPair(jwt_views.TokenVerifyView):
187311
tags=["Auth"],
188312
operation_id=f"{ENDPOINT_ID}get_verified_token",
189313
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.",
191315
responses={201: TokenPair()},
192316
)
193317
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+
195357
response["Cache-Control"] = "no-store"
196358
response["CDN-Cache-Control"] = "no-store"
359+
197360
return response
198361

199362

@@ -202,4 +365,5 @@ def post(self, *args, **kwargs):
202365
path("api/token/refresh", TokenRefresh.as_view(), name="jwt-refresh"),
203366
path("api/token/verify", TokenVerify.as_view(), name="jwt-verify"),
204367
path("api/token/verified", VerifiedTokenPair.as_view(), name="verified-jwt-pair"),
368+
path("api/logout", LogoutView.as_view(), name="jwt-logout"),
205369
]

apps/dashboard/next-env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3+
import "./.next/types/routes.d.ts";
34

45
// NOTE: This file should not be edited
56
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

apps/dashboard/next.config.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ const nextConfig = {
8282
"@karrio/app-store",
8383
],
8484
sassOptions: {
85-
includePaths: [path.join("src", "styles")],
85+
includePaths: [
86+
path.join("src", "styles"),
87+
path.resolve(__dirname, "../../node_modules"),
88+
],
8689
},
8790
async headers() {
8891
return [

apps/dashboard/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"version": "1.0.0",
44
"scripts": {
55
"dev": "next dev --port 3002",
6-
"build": "next build",
6+
"build": "next build --webpack",
77
"start": "next start",
88
"lint": "next lint"
99
},
File renamed without changes.

apps/dashboard/src/styles/theme.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ $footer-padding: 1rem 1.5rem;
4646
$footer-background-color: $white;
4747

4848
// Import only what you need from Bulma
49-
@import "../../node_modules/bulma/bulma.sass";
49+
@import "bulma/bulma.sass";
5050

5151
.isolated-card {
5252
margin: 0px auto;

0 commit comments

Comments
 (0)