Skip to content

Commit bc2e6a4

Browse files
authored
[Human App] fix: refresh token race condition (#2824)
1 parent f503317 commit bc2e6a4

File tree

12 files changed

+107
-76
lines changed

12 files changed

+107
-76
lines changed

packages/apps/human-app/frontend/src/api/api-client.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
// eslint-disable-next-line import/no-cycle -- cause by refresh token retry
21
import { createFetcher } from '@/api/fetcher';
32
import { env } from '@/shared/env';
43

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { apiPaths } from '@/api/api-paths';
2+
import { browserAuthProvider } from '@/shared/helpers/browser-auth-provider';
3+
import { signInSuccessResponseSchema } from '@/api/services/worker/sign-in/schema';
4+
5+
export const fetchTokenRefresh = async (baseUrl: string) => {
6+
const response = await fetch(
7+
`${baseUrl}${apiPaths.worker.obtainAccessToken.path}`,
8+
{
9+
method: 'POST',
10+
headers: {
11+
'Content-Type': 'application/json',
12+
},
13+
body: JSON.stringify({
14+
// eslint-disable-next-line camelcase -- camel case defined by api
15+
refresh_token: browserAuthProvider.getRefreshToken(),
16+
}),
17+
}
18+
);
19+
20+
if (!response.ok) {
21+
return null;
22+
}
23+
24+
const data: unknown = await response.json();
25+
26+
const refetchAccessTokenSuccess = signInSuccessResponseSchema.parse(data);
27+
28+
return refetchAccessTokenSuccess;
29+
};

packages/apps/human-app/frontend/src/api/fetcher.ts

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import merge from 'lodash/merge';
22
import { ZodError, type ZodType, type ZodTypeDef } from 'zod';
33
import type { ResponseError } from '@/shared/types/global.type';
4-
import type { SignInSuccessResponse } from '@/api/services/worker/sign-in';
5-
// eslint-disable-next-line import/no-cycle -- cause by refresh token retry
6-
import { signInSuccessResponseSchema } from '@/api/services/worker/sign-in';
7-
import { apiClient } from '@/api/api-client';
8-
import { apiPaths } from '@/api/api-paths';
94
import { browserAuthProvider } from '@/shared/helpers/browser-auth-provider';
5+
import { env } from '@/shared/env';
6+
import { type SignInSuccessResponse } from '@/api/services/worker/sign-in/types';
7+
import { fetchTokenRefresh } from './fetch-refresh-token';
108

119
const appendHeader = (
1210
fetcherOptionsWithDefaults: RequestInit | undefined,
@@ -67,6 +65,29 @@ export type FetcherOptions<SuccessInput, SuccessOutput> =
6765

6866
export type FetcherUrl = string | URL;
6967

68+
let refreshPromise: Promise<SignInSuccessResponse | null> | null = null;
69+
70+
export async function refreshToken(): Promise<{
71+
access_token: string;
72+
refresh_token: string;
73+
} | null> {
74+
if (!refreshPromise) {
75+
refreshPromise = fetchTokenRefresh(env.VITE_API_URL);
76+
}
77+
78+
const result = await refreshPromise;
79+
80+
refreshPromise = null;
81+
82+
if (result) {
83+
browserAuthProvider.signIn(result, browserAuthProvider.authType);
84+
} else {
85+
browserAuthProvider.signOut({ triggerSignOutSubscriptions: true });
86+
}
87+
88+
return result;
89+
}
90+
7091
export function createFetcher(defaultFetcherConfig?: {
7192
options?: RequestInit | (() => RequestInit);
7293
baseUrl: FetcherUrl | (() => FetcherUrl);
@@ -153,33 +174,16 @@ export function createFetcher(defaultFetcherConfig?: {
153174
fetcherOptions.authenticated &&
154175
fetcherOptions.withAuthRetry
155176
) {
156-
let refetchAccessTokenSuccess: SignInSuccessResponse | undefined;
157-
try {
158-
refetchAccessTokenSuccess = await apiClient(
159-
apiPaths.worker.obtainAccessToken.path,
160-
{
161-
successSchema: signInSuccessResponseSchema,
162-
options: {
163-
method: 'POST',
164-
body: JSON.stringify({
165-
// eslint-disable-next-line camelcase -- camel case defined by api
166-
refresh_token: browserAuthProvider.getRefreshToken(),
167-
}),
168-
},
169-
}
170-
);
171-
browserAuthProvider.signIn(
172-
refetchAccessTokenSuccess,
173-
browserAuthProvider.authType
174-
);
175-
} catch {
176-
browserAuthProvider.signOut({ triggerSignOutSubscriptions: true });
177+
const refetchAccessTokenSuccess = await refreshToken();
178+
179+
if (!refetchAccessTokenSuccess) {
177180
return;
178181
}
179182

180183
const newHeaders = appendHeader(fetcherOptionsWithDefaults, {
181184
Authorization: `Bearer ${refetchAccessTokenSuccess.access_token}`,
182185
});
186+
183187
response = await fetch(fetcherUrl, newHeaders);
184188

185189
if (!response.ok) {

packages/apps/human-app/frontend/src/api/services/common/use-access-token-refresh.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import { useMutation, useQueryClient } from '@tanstack/react-query';
22
import { useNavigate } from 'react-router-dom';
3-
import { apiClient } from '@/api/api-client';
4-
import { apiPaths } from '@/api/api-paths';
5-
import { signInSuccessResponseSchema } from '@/api/services/worker/sign-in';
63
import { useAuth } from '@/auth/use-auth';
74
import { browserAuthProvider } from '@/shared/helpers/browser-auth-provider';
85
import type { AuthType } from '@/shared/types/browser-auth-provider';
96
import { useWeb3Auth } from '@/auth-web3/use-web3-auth';
107
import { routerPaths } from '@/router/router-paths';
8+
import { refreshToken } from '@/api/fetcher';
119

1210
export function useAccessTokenRefresh() {
1311
const queryClient = useQueryClient();
@@ -32,19 +30,11 @@ export function useAccessTokenRefresh() {
3230
throwExpirationModalOnSignOut?: boolean;
3331
}) => {
3432
try {
35-
const refetchAccessTokenSuccess = await apiClient(
36-
apiPaths.worker.obtainAccessToken.path,
37-
{
38-
successSchema: signInSuccessResponseSchema,
39-
options: {
40-
method: 'POST',
41-
body: JSON.stringify({
42-
// eslint-disable-next-line camelcase -- camel case defined by api
43-
refresh_token: browserAuthProvider.getRefreshToken(),
44-
}),
45-
},
46-
}
47-
);
33+
const refetchAccessTokenSuccess = await refreshToken();
34+
35+
if (!refetchAccessTokenSuccess) {
36+
throw new Error('Failed to refresh access token.');
37+
}
4838

4939
if (authType === 'web2') {
5040
signInWeb2(refetchAccessTokenSuccess);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { z } from 'zod';
2+
import { t } from 'i18next';
3+
4+
export const signInDtoSchema = z.object({
5+
email: z.string().email(t('validation.invalidEmail')),
6+
password: z
7+
.string()
8+
.min(1, t('validation.passwordMissing'))
9+
.max(50, t('validation.max', { count: 50 })),
10+
// eslint-disable-next-line camelcase -- export vite config
11+
h_captcha_token: z.string().min(1, t('validation.captcha')).default('token'),
12+
});
13+
14+
export const signInSuccessResponseSchema = z.object({
15+
// eslint-disable-next-line camelcase -- data from api
16+
access_token: z.string(),
17+
// eslint-disable-next-line camelcase -- data from api
18+
refresh_token: z.string(),
19+
});

packages/apps/human-app/frontend/src/api/services/worker/sign-in.ts renamed to packages/apps/human-app/frontend/src/api/services/worker/sign-in/sign-in.ts

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,11 @@
1-
import { z } from 'zod';
21
import { useMutation, useQueryClient } from '@tanstack/react-query';
32
import { useNavigate } from 'react-router-dom';
4-
import { t } from 'i18next';
5-
// eslint-disable-next-line import/no-cycle -- cause by refresh token retry
63
import { apiClient } from '@/api/api-client';
74
import { apiPaths } from '@/api/api-paths';
85
import { routerPaths } from '@/router/router-paths';
96
import { useAuth } from '@/auth/use-auth';
10-
11-
export const signInDtoSchema = z.object({
12-
email: z.string().email(t('validation.invalidEmail')),
13-
password: z
14-
.string()
15-
.min(1, t('validation.passwordMissing'))
16-
.max(50, t('validation.max', { count: 50 })),
17-
// eslint-disable-next-line camelcase -- export vite config
18-
h_captcha_token: z.string().min(1, t('validation.captcha')).default('token'),
19-
});
20-
21-
export type SignInDto = z.infer<typeof signInDtoSchema>;
22-
23-
export const signInSuccessResponseSchema = z.object({
24-
// eslint-disable-next-line camelcase -- data from api
25-
access_token: z.string(),
26-
// eslint-disable-next-line camelcase -- data from api
27-
refresh_token: z.string(),
28-
});
29-
30-
export type SignInSuccessResponse = z.infer<typeof signInSuccessResponseSchema>;
7+
import { type SignInDto } from './types';
8+
import { signInSuccessResponseSchema } from './schema';
319

3210
function signInMutationFn(data: SignInDto) {
3311
return apiClient(apiPaths.worker.signIn.path, {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { type z } from 'zod';
2+
import {
3+
type signInDtoSchema,
4+
type signInSuccessResponseSchema,
5+
} from './schema';
6+
7+
export type SignInDto = z.infer<typeof signInDtoSchema>;
8+
9+
export type SignInSuccessResponse = z.infer<typeof signInSuccessResponseSchema>;

packages/apps/human-app/frontend/src/auth-web3/web3-auth-context.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useState, createContext, useEffect } from 'react';
33
import { jwtDecode } from 'jwt-decode';
44
import { z } from 'zod';
55
import { useQueryClient } from '@tanstack/react-query';
6-
import type { SignInSuccessResponse } from '@/api/services/worker/sign-in';
6+
import type { SignInSuccessResponse } from '@/api/services/worker/sign-in/sign-in';
77
import { browserAuthProvider } from '@/shared/helpers/browser-auth-provider';
88
import { useModalStore } from '@/components/ui/modal/modal.store';
99

packages/apps/human-app/frontend/src/auth/auth-context.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useState, createContext, useEffect } from 'react';
33
import { jwtDecode } from 'jwt-decode';
44
import { z } from 'zod';
55
import { useQueryClient } from '@tanstack/react-query';
6-
import type { SignInSuccessResponse } from '@/api/services/worker/sign-in';
6+
import type { SignInSuccessResponse } from '@/api/services/worker/sign-in/sign-in';
77
import { browserAuthProvider } from '@/shared/helpers/browser-auth-provider';
88
import { useModalStore } from '@/components/ui/modal/modal.store';
99

packages/apps/human-app/frontend/src/pages/worker/sign-in.page.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,16 @@ import { PageCard } from '@/components/ui/page-card';
99
import { Input } from '@/components/data-entry/input';
1010
import { Button } from '@/components/ui/button';
1111
import { Password } from '@/components/data-entry/password/password';
12-
import type { SignInDto } from '@/api/services/worker/sign-in';
13-
import {
14-
signInDtoSchema,
15-
useSignInMutation,
16-
} from '@/api/services/worker/sign-in';
12+
import { useSignInMutation } from '@/api/services/worker/sign-in/sign-in';
1713
import { FetchError } from '@/api/fetcher';
1814
import { routerPaths } from '@/router/router-paths';
1915
import { defaultErrorMessage } from '@/shared/helpers/default-error-message';
2016
import { Alert } from '@/components/ui/alert';
2117
import { FormCaptcha } from '@/components/h-captcha';
2218
import { useResetMutationErrors } from '@/hooks/use-reset-mutation-errors';
2319
import { browserAuthProvider } from '@/shared/helpers/browser-auth-provider';
20+
import { type SignInDto } from '@/api/services/worker/sign-in/types';
21+
import { signInDtoSchema } from '@/api/services/worker/sign-in/schema';
2422

2523
function formattedSignInErrorMessage(unknownError: unknown) {
2624
if (unknownError instanceof FetchError && unknownError.status === 400) {

0 commit comments

Comments
 (0)