Skip to content

Commit 2c740c4

Browse files
committed
implements JWT token refresh in the frontend
removes comments
1 parent 36178d1 commit 2c740c4

File tree

7 files changed

+160
-51
lines changed

7 files changed

+160
-51
lines changed

api/app/settings/common.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -822,7 +822,7 @@
822822
}
823823
SIMPLE_JWT = {
824824
"AUTH_TOKEN_CLASSES": ["rest_framework_simplejwt.tokens.AccessToken"],
825-
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), # Shorter lifetime
825+
"ACCESS_TOKEN_LIFETIME": timedelta(hours=1),
826826
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
827827
"SIGNING_KEY": env.str("COOKIE_AUTH_JWT_SIGNING_KEY", default=SECRET_KEY),
828828
}

api/custom_auth/jwt_cookie/authentication.py

+3-11
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
)
88
from rest_framework_simplejwt.tokens import Token
99

10-
from custom_auth.jwt_cookie.constants import ACCESS_TOKEN_COOKIE_KEY, REFRESH_TOKEN_COOKIE_KEY
10+
from custom_auth.jwt_cookie.constants import ACCESS_TOKEN_COOKIE_KEY
1111
from users.models import FFAdminUser
1212

1313

@@ -17,19 +17,11 @@ def authenticate_header(self, request: Request) -> str:
1717

1818
def authenticate(self, request: Request) -> tuple[FFAdminUser, Token] | None:
1919
raw_access_token = request.COOKIES.get(ACCESS_TOKEN_COOKIE_KEY)
20-
raw_refresh_token = request.COOKIES.get(REFRESH_TOKEN_COOKIE_KEY)
2120

2221
if raw_access_token:
2322
try:
24-
validated_access_token = self.get_validated_token(raw_access_token) # type: ignore[arg-type]
25-
return self.get_user(validated_access_token), validated_access_token # type: ignore[return-value]
26-
except (InvalidToken, TokenError, AuthenticationFailed):
27-
pass
28-
29-
if raw_refresh_token:
30-
try:
31-
validated_refresh_token = self.get_validated_token(raw_refresh_token) # type: ignore[arg-type]
32-
return self.get_user(validated_refresh_token), validated_refresh_token # type: ignore[return-value]
23+
validated_access_token = self.get_validated_token(raw_access_token)
24+
return self.get_user(validated_access_token), validated_access_token
3325
except (InvalidToken, TokenError, AuthenticationFailed):
3426
pass
3527

api/custom_auth/jwt_cookie/views.py

+43-5
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,56 @@
11
from djoser.views import TokenDestroyView # type: ignore[import-untyped]
22
from rest_framework.request import Request
33
from rest_framework.response import Response
4+
5+
46
from rest_framework_simplejwt.tokens import RefreshToken
7+
from rest_framework_simplejwt.views import TokenRefreshView
8+
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
9+
from rest_framework_simplejwt.serializers import TokenRefreshSerializer
10+
from django.conf import settings
11+
12+
from custom_auth.jwt_cookie.constants import (
13+
REFRESH_TOKEN_COOKIE_KEY,
14+
ACCESS_TOKEN_COOKIE_KEY,
15+
)
516

6-
from custom_auth.jwt_cookie.constants import REFRESH_TOKEN_COOKIE_KEY
717
class JWTTokenLogoutView(TokenDestroyView): # type: ignore[misc]
818
def post(self, request: Request) -> Response:
919
response = super().post(request)
1020

1121
raw_refresh_token = request.COOKIES.get(REFRESH_TOKEN_COOKIE_KEY)
1222
if raw_refresh_token:
13-
refresh_token = RefreshToken(raw_refresh_token) # type: ignore[union-attr]
23+
refresh_token = RefreshToken(raw_refresh_token)
1424
refresh_token.blacklist()
1525

16-
response.delete_cookie('access_token')
17-
response.delete_cookie('refresh_token')
18-
return response # type: ignore[no-any-return]
26+
response.delete_cookie(ACCESS_TOKEN_COOKIE_KEY)
27+
response.delete_cookie(REFRESH_TOKEN_COOKIE_KEY)
28+
return response
29+
30+
31+
class JWTCookieTokenRefreshView(TokenRefreshView):
32+
def post(self, request: Request, *args, **kwargs) -> Response:
33+
raw_refresh_token = request.COOKIES.get(REFRESH_TOKEN_COOKIE_KEY)
34+
35+
if not raw_refresh_token:
36+
raise InvalidToken("No valid refresh token found in cookie")
37+
38+
serializer = self.get_serializer(data={"refresh": raw_refresh_token})
39+
serializer.is_valid(raise_exception=True)
40+
41+
response = Response(serializer.validated_data)
42+
43+
print(serializer.validated_data["access"])
44+
45+
response.set_cookie(
46+
ACCESS_TOKEN_COOKIE_KEY,
47+
str(serializer.validated_data["access"]),
48+
httponly=True,
49+
secure=settings.USE_SECURE_COOKIES,
50+
samesite=settings.COOKIE_SAME_SITE,
51+
max_age=int(
52+
settings.SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"].total_seconds()
53+
),
54+
)
55+
56+
return response

api/custom_auth/urls.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from django.urls import include, path
22
from rest_framework.routers import DefaultRouter
3-
from rest_framework_simplejwt.views import TokenRefreshView
43

54
from custom_auth.jwt_cookie.views import JWTTokenLogoutView
5+
from custom_auth.jwt_cookie.views import JWTCookieTokenRefreshView
66
from custom_auth.views import (
77
CustomAuthTokenLoginOrRequestMFACode,
88
CustomAuthTokenLoginWithMFACode,
@@ -34,7 +34,7 @@
3434
),
3535
path(
3636
"token/refresh/",
37-
TokenRefreshView.as_view(),
37+
JWTCookieTokenRefreshView.as_view(),
3838
name='token_refresh',
3939
),
4040
path("", include(ffadmin_user_router.urls)),

api/users/serializers.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
UserSerializer as DjoserUserSerializer,
33
)
44
from rest_framework import serializers
5-
from rest_framework.exceptions import ValidationError
5+
from rest_framework.exceptions import ValidationError, NotAuthenticated
66

77
from organisations.models import Organisation
88
from organisations.serializers import UserOrganisationSerializer
@@ -160,6 +160,11 @@ class Meta(DjoserUserSerializer.Meta): # type: ignore[misc]
160160
"date_joined",
161161
"uuid",
162162
)
163+
def to_representation(self, instance):
164+
if not instance.is_authenticated:
165+
raise NotAuthenticated("User is not authenticated.")
166+
167+
return super().to_representation(instance)
163168

164169

165170
class ListUsersQuerySerializer(serializers.Serializer): # type: ignore[type-arg]

frontend/common/data/base/_data.js

+31-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ module.exports = {
5656
document.getElementById('e2e-request').innerText = JSON.stringify(payload)
5757
}
5858

59-
return fetch(url, options)
59+
return this.fetchWithInterceptor(url, options)
6060
.then((response) => this.status(response, isExternal))
6161
.then((response) => {
6262
// always return json
@@ -73,9 +73,39 @@ module.exports = {
7373
return response
7474
})
7575
},
76+
7677
delete(url, data, headers) {
7778
return this._request('delete', url, data, headers)
7879
},
80+
fetchWithInterceptor(url, options) {
81+
const originalFetch = fetch
82+
const isExternal = !url.startsWith(Project.api)
83+
const excludedUrls = [
84+
`${Project.api}auth/token/refresh/`,
85+
`${Project.api}auth/logout/`,
86+
]
87+
const isExcluded = excludedUrls.includes(url)
88+
const isCookieAuthEnabled = Project.cookieAuthEnabled
89+
90+
return originalFetch(url, options).then((response) => {
91+
if (
92+
isCookieAuthEnabled &&
93+
response.status === 401 &&
94+
!isExternal &&
95+
!isExcluded
96+
) {
97+
return this.post(`${Project.api}auth/token/refresh/`)
98+
.then(() => {
99+
return originalFetch(url, options)
100+
})
101+
.catch((error) => {
102+
AppActions.setUser(null)
103+
return Promise.reject(error)
104+
})
105+
}
106+
return Promise.resolve(response)
107+
})
108+
},
79109

80110
get(url, data, headers) {
81111
return this._request('get', url, data || null, headers)

frontend/common/service.ts

+74-30
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,93 @@
1-
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/dist/query/react'
1+
import {
2+
BaseQueryApi,
3+
createApi,
4+
fetchBaseQuery,
5+
} from '@reduxjs/toolkit/dist/query/react'
26

3-
import { FetchBaseQueryArgs } from '@reduxjs/toolkit/dist/query/fetchBaseQuery'
7+
import {
8+
FetchArgs,
9+
FetchBaseQueryArgs,
10+
} from '@reduxjs/toolkit/dist/query/fetchBaseQuery'
411
import { CreateApiOptions } from '@reduxjs/toolkit/dist/query/createApi'
512
import { StoreStateType } from './store'
613

714
const Project = require('./project')
815
const _data = require('./data/base/_data.js')
916

17+
const excludedEndpoints = [
18+
'register',
19+
'createConfirmEmail',
20+
'createResetPassword',
21+
'createResendConfirmationEmail',
22+
'createForgotPassword',
23+
]
24+
25+
const refreshAccessToken = async () => {
26+
try {
27+
const response = await fetch(`${Project.api}auth/token/refresh/`, {
28+
credentials: 'include',
29+
method: 'POST',
30+
})
31+
32+
if (!response.ok) {
33+
throw new Error('Failed to refresh token')
34+
}
35+
36+
return
37+
} catch (error) {
38+
console.error('Token refresh failed', error)
39+
throw error
40+
}
41+
}
42+
1043
export const baseApiOptions = (queryArgs?: Partial<FetchBaseQueryArgs>) => {
44+
const baseQuery = fetchBaseQuery({
45+
baseUrl: Project.api,
46+
credentials: Project.cookieAuthEnabled ? 'include' : undefined,
47+
prepareHeaders: async (headers, { endpoint, getState }) => {
48+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
49+
const state = getState() as StoreStateType
50+
if (!excludedEndpoints.includes(endpoint)) {
51+
try {
52+
const token = _data.token
53+
if (token && !Project.cookieAuthEnabled) {
54+
headers.set('Authorization', `Token ${token}`)
55+
}
56+
} catch (e) {}
57+
}
58+
59+
return headers
60+
},
61+
...queryArgs,
62+
})
63+
64+
const baseQueryWithInterceptor = async (
65+
args: string | FetchArgs,
66+
api: BaseQueryApi,
67+
extraOptions: {},
68+
) => {
69+
const result = await baseQuery(args, api, extraOptions)
70+
if (
71+
Project.cookieAuthEnabled &&
72+
result.error &&
73+
result.error.status === 401
74+
) {
75+
await refreshAccessToken()
76+
return baseQuery(args, api, extraOptions)
77+
}
78+
return result
79+
}
80+
1181
const res: Pick<
1282
CreateApiOptions<any, any, any, any>,
1383
| 'baseQuery'
1484
| 'refetchOnReconnect'
1585
| 'refetchOnFocus'
1686
| 'extractRehydrationInfo'
1787
> = {
18-
baseQuery: fetchBaseQuery({
19-
baseUrl: Project.api,
20-
credentials: Project.cookieAuthEnabled ? 'include' : undefined,
21-
prepareHeaders: async (headers, { endpoint, getState }) => {
22-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
23-
const state = getState() as StoreStateType
24-
if (
25-
endpoint !== 'register' &&
26-
endpoint !== 'createConfirmEmail' &&
27-
endpoint !== 'createResetPassword' &&
28-
endpoint !== 'createResendConfirmationEmail' &&
29-
endpoint !== 'createForgotPassword'
30-
) {
31-
try {
32-
const token = _data.token
33-
if (token && !Project.cookieAuthEnabled) {
34-
headers.set('Authorization', `Token ${token}`)
35-
}
36-
} catch (e) {}
37-
}
38-
39-
return headers
40-
},
41-
...queryArgs,
42-
}),
43-
44-
refetchOnFocus: true,
45-
refetchOnReconnect: true,
88+
baseQuery: baseQueryWithInterceptor,
4689
}
90+
4791
return res
4892
}
4993

0 commit comments

Comments
 (0)