diff --git a/front/src/apollo.tsx b/front/src/apollo.tsx
index 3c3c736840..4fa5e98d64 100644
--- a/front/src/apollo.tsx
+++ b/front/src/apollo.tsx
@@ -1,6 +1,14 @@
-import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
+import {
+ ApolloClient,
+ InMemoryCache,
+ Observable,
+ createHttpLink,
+ from,
+} from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { RestLink } from 'apollo-link-rest';
+import { onError } from '@apollo/client/link/error';
+import { refreshAccessToken } from './services/AuthService';
const apiLink = createHttpLink({
uri: `${process.env.REACT_APP_API_URL}/v1/graphql`,
@@ -16,8 +24,46 @@ const withAuthHeadersLink = setContext((_, { headers }) => {
};
});
+const errorLink = onError(({ graphQLErrors, operation, forward }) => {
+ if (graphQLErrors) {
+ for (const err of graphQLErrors) {
+ switch (err.extensions.code) {
+ case 'invalid-jwt':
+ return new Observable((observer) => {
+ (async () => {
+ try {
+ await refreshAccessToken();
+
+ const oldHeaders = operation.getContext().headers;
+
+ operation.setContext({
+ headers: {
+ ...oldHeaders,
+ authorization: `Bearer ${localStorage.getItem(
+ 'accessToken',
+ )}`,
+ },
+ });
+
+ const subscriber = {
+ next: observer.next.bind(observer),
+ error: observer.error.bind(observer),
+ complete: observer.complete.bind(observer),
+ };
+
+ forward(operation).subscribe(subscriber);
+ } catch (error) {
+ observer.error(error);
+ }
+ })();
+ });
+ }
+ }
+ }
+});
+
export const apiClient = new ApolloClient({
- link: withAuthHeadersLink.concat(apiLink),
+ link: from([errorLink, withAuthHeadersLink, apiLink]),
cache: new InMemoryCache(),
});
diff --git a/front/src/components/auth/RequireAuth.tsx b/front/src/components/auth/RequireAuth.tsx
index d63a09b9c5..befc53376c 100644
--- a/front/src/components/auth/RequireAuth.tsx
+++ b/front/src/components/auth/RequireAuth.tsx
@@ -1,17 +1,15 @@
import { useNavigate } from 'react-router-dom';
-import { useHasAccessToken } from '../../hooks/auth/useHasAccessToken';
import { useEffect } from 'react';
+import { hasAccessToken } from '../../services/AuthService';
function RequireAuth({ children }: { children: JSX.Element }): JSX.Element {
- const hasAccessToken = useHasAccessToken();
-
const navigate = useNavigate();
useEffect(() => {
- if (!hasAccessToken) {
+ if (!hasAccessToken()) {
navigate('/auth/login');
}
- }, [hasAccessToken, navigate]);
+ }, [navigate]);
return children;
}
diff --git a/front/src/hooks/auth/__tests__/useHasAccessToken.test.tsx b/front/src/hooks/auth/__tests__/useHasAccessToken.test.tsx
deleted file mode 100644
index b3ead063cc..0000000000
--- a/front/src/hooks/auth/__tests__/useHasAccessToken.test.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { render, waitFor } from '@testing-library/react';
-import { useHasAccessToken } from '../useHasAccessToken';
-
-function TestComponent() {
- const hasAccessToken = useHasAccessToken();
-
- return (
-
- );
-}
-
-test('useHasAccessToken works properly if access token is present', async () => {
- localStorage.setItem('accessToken', 'test-access-token');
- const { getByTestId } = render();
-
- await waitFor(() => {
- expect(getByTestId('has-access-token')).toBeDefined();
- });
-});
-
-test('useHasAccessToken works properly if access token is not present', async () => {
- localStorage.removeItem('accessToken');
- const { container } = render();
-
- await waitFor(() => {
- expect(container.firstChild).toBeEmptyDOMElement();
- });
-});
-
-afterEach(() => {
- jest.clearAllMocks();
-});
diff --git a/front/src/hooks/auth/__tests__/useRefreshToken.test.tsx b/front/src/hooks/auth/__tests__/useRefreshToken.test.tsx
deleted file mode 100644
index 80f53d8e4e..0000000000
--- a/front/src/hooks/auth/__tests__/useRefreshToken.test.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { render, waitFor } from '@testing-library/react';
-import { useRefreshToken } from '../useRefreshToken';
-
-function TestComponent() {
- const { loading } = useRefreshToken();
-
- return ;
-}
-
-jest.mock('@apollo/client', () => {
- return {
- __esModule: true,
- ...jest.requireActual('@apollo/client'),
- useQuery: () => ({
- data: {
- token: {
- accessToken: 'test-access-token',
- },
- },
- isLoading: false,
- error: null,
- }),
- };
-});
-
-test('useRefreshToken works properly', async () => {
- localStorage.setItem('refreshToken', 'test-refresh-token');
- render();
-
- await waitFor(() => {
- expect(localStorage.getItem('accessToken')).toBe('test-access-token');
- });
-});
-
-afterEach(() => {
- jest.clearAllMocks();
- localStorage.removeItem('refreshToken');
-});
diff --git a/front/src/hooks/auth/useHasAccessToken.tsx b/front/src/hooks/auth/useHasAccessToken.tsx
deleted file mode 100644
index 839b8b76de..0000000000
--- a/front/src/hooks/auth/useHasAccessToken.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-export const useHasAccessToken = () => {
- const accessToken = localStorage.getItem('accessToken');
-
- return accessToken ? true : false;
-};
diff --git a/front/src/hooks/auth/useRefreshToken.tsx b/front/src/hooks/auth/useRefreshToken.tsx
deleted file mode 100644
index 4ab8d3102b..0000000000
--- a/front/src/hooks/auth/useRefreshToken.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { gql, useQuery } from '@apollo/client';
-import { useEffect } from 'react';
-import { authClient } from '../../apollo';
-
-export const GET_TOKEN = gql`
- fragment Payload on REST {
- refreshToken: String
- }
- query jwt($input: Payload) {
- token(input: $input) @rest(type: "string", path: "/token", method: "POST") {
- accessToken
- }
- }
-`;
-
-export const useRefreshToken = () => {
- const refreshToken = localStorage.getItem('refreshToken');
- const { data, loading, error } = useQuery(GET_TOKEN, {
- client: authClient,
- variables: { input: { refreshToken } },
- });
- useEffect(() => {
- if (!loading && !error) {
- const accessToken = data.token.accessToken;
- if (accessToken) {
- localStorage.setItem('accessToken', accessToken || '');
- }
- }
- }, [data, refreshToken, loading, error]);
-
- return { loading, error };
-};
diff --git a/front/src/pages/auth/Callback.tsx b/front/src/pages/auth/Callback.tsx
index 16d63ac0f1..6f571dc7a7 100644
--- a/front/src/pages/auth/Callback.tsx
+++ b/front/src/pages/auth/Callback.tsx
@@ -1,18 +1,26 @@
import { useSearchParams, useNavigate } from 'react-router-dom';
-import { useRefreshToken } from '../../hooks/auth/useRefreshToken';
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
+import { refreshAccessToken } from '../../services/AuthService';
function Callback() {
const [searchParams] = useSearchParams();
+ const [isLoading, setIsLoading] = useState(true);
+
const refreshToken = searchParams.get('refreshToken');
localStorage.setItem('refreshToken', refreshToken || '');
- const { loading } = useRefreshToken();
const navigate = useNavigate();
+
useEffect(() => {
- if (!loading) {
+ async function getAccessToken() {
+ await refreshAccessToken();
+ setIsLoading(false);
navigate('/');
}
- }, [navigate, loading]);
+
+ if (isLoading) {
+ getAccessToken();
+ }
+ }, [isLoading, navigate]);
return <>>;
}
diff --git a/front/src/pages/auth/Login.tsx b/front/src/pages/auth/Login.tsx
index 29b2a67c05..37e577de4e 100644
--- a/front/src/pages/auth/Login.tsx
+++ b/front/src/pages/auth/Login.tsx
@@ -1,18 +1,17 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
-import { useHasAccessToken } from '../../hooks/auth/useHasAccessToken';
+import { hasAccessToken } from '../../services/AuthService';
function Login() {
- const hasAccessToken = useHasAccessToken();
const navigate = useNavigate();
useEffect(() => {
- if (!hasAccessToken) {
+ if (!hasAccessToken()) {
window.location.href =
process.env.REACT_APP_AUTH_URL + '/signin/provider/google' || '';
} else {
navigate('/');
}
- }, [hasAccessToken, navigate]);
+ }, [navigate]);
return <>>;
}
diff --git a/front/src/pages/auth/__tests__/Callback.test.tsx b/front/src/pages/auth/__tests__/Callback.test.tsx
index ab5f92494e..fb05f2383c 100644
--- a/front/src/pages/auth/__tests__/Callback.test.tsx
+++ b/front/src/pages/auth/__tests__/Callback.test.tsx
@@ -1,15 +1,10 @@
import { render } from '@testing-library/react';
import { CallbackDefault } from '../__stories__/Callback.stories';
+import { act } from 'react-dom/test-utils';
-jest.mock('../../../hooks/auth/useRefreshToken', () => ({
- useRefreshToken: () => ({ loading: false }),
-}));
-
-it('Checks the Callback page render', () => {
- render();
-});
-
-afterEach(() => {
- jest.clearAllMocks();
+it('Checks the Callback page render', async () => {
+ await act(async () => {
+ render();
+ });
});
diff --git a/front/src/services/AuthService.ts b/front/src/services/AuthService.ts
new file mode 100644
index 0000000000..ce24d0235c
--- /dev/null
+++ b/front/src/services/AuthService.ts
@@ -0,0 +1,34 @@
+export const hasAccessToken = () => {
+ const accessToken = localStorage.getItem('accessToken');
+
+ return accessToken ? true : false;
+};
+
+export const hasRefreshToken = () => {
+ const refreshToken = localStorage.getItem('refreshToken');
+
+ return refreshToken ? true : false;
+};
+
+export const refreshAccessToken = async () => {
+ const refreshToken = localStorage.getItem('refreshToken');
+ if (!refreshToken) {
+ localStorage.removeItem('accessToken');
+ }
+
+ const response = await fetch(
+ process.env.REACT_APP_AUTH_URL + '/token' || '',
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ refreshToken }),
+ },
+ );
+
+ if (response.ok) {
+ const { accessToken } = await response.json();
+ localStorage.setItem('accessToken', accessToken);
+ }
+};
diff --git a/front/src/services/__tests__/AuthService.test.tsx b/front/src/services/__tests__/AuthService.test.tsx
new file mode 100644
index 0000000000..3256ae4145
--- /dev/null
+++ b/front/src/services/__tests__/AuthService.test.tsx
@@ -0,0 +1,68 @@
+import { waitFor } from '@testing-library/react';
+import {
+ hasAccessToken,
+ hasRefreshToken,
+ refreshAccessToken,
+} from '../AuthService';
+
+const mockFetch = async (
+ input: RequestInfo | URL,
+ init?: RequestInit,
+): Promise => {
+ const refreshToken = init?.body
+ ? JSON.parse(init.body.toString()).refreshToken
+ : null;
+ return new Promise((resolve) => {
+ resolve(
+ new Response(
+ JSON.stringify({
+ accessToken:
+ refreshToken === 'xxx-valid-refresh' ? 'xxx-valid-access' : null,
+ }),
+ ),
+ );
+ });
+};
+
+global.fetch = mockFetch;
+
+it('hasAccessToken is true when token is present', () => {
+ localStorage.setItem('accessToken', 'xxx');
+ expect(hasAccessToken()).toBe(true);
+});
+
+it('hasAccessToken is false when token is not', () => {
+ expect(hasAccessToken()).toBe(false);
+});
+
+it('hasRefreshToken is true when token is present', () => {
+ localStorage.setItem('refreshToken', 'xxx');
+ expect(hasRefreshToken()).toBe(true);
+});
+
+it('hasRefreshToken is true when token is not', () => {
+ expect(hasRefreshToken()).toBe(false);
+});
+
+it('refreshToken does not refresh the token if refresh token is missing', () => {
+ refreshAccessToken();
+ expect(localStorage.getItem('accessToken')).toBeNull();
+});
+
+it('refreshToken does not refreh the token if refresh token is invalid', () => {
+ localStorage.setItem('refreshToken', 'xxx-invalid-refresh');
+ refreshAccessToken();
+ expect(localStorage.getItem('accessToken')).toBeNull();
+});
+
+it('refreshToken refreshes the token if refresh token is valid', async () => {
+ localStorage.setItem('refreshToken', 'xxx-valid-refresh');
+ refreshAccessToken();
+ await waitFor(() => {
+ expect(localStorage.getItem('accessToken')).toBe('xxx-valid-access');
+ });
+});
+
+afterEach(() => {
+ localStorage.clear();
+});