Merge pull request #64 from twentyhq/cbo-fetch-jwt-token
Refresh JWT when expired
This commit is contained in:
@ -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 { setContext } from '@apollo/client/link/context';
|
||||||
import { RestLink } from 'apollo-link-rest';
|
import { RestLink } from 'apollo-link-rest';
|
||||||
|
import { onError } from '@apollo/client/link/error';
|
||||||
|
import { refreshAccessToken } from './services/AuthService';
|
||||||
|
|
||||||
const apiLink = createHttpLink({
|
const apiLink = createHttpLink({
|
||||||
uri: `${process.env.REACT_APP_API_URL}/v1/graphql`,
|
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({
|
export const apiClient = new ApolloClient({
|
||||||
link: withAuthHeadersLink.concat(apiLink),
|
link: from([errorLink, withAuthHeadersLink, apiLink]),
|
||||||
cache: new InMemoryCache(),
|
cache: new InMemoryCache(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useHasAccessToken } from '../../hooks/auth/useHasAccessToken';
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { hasAccessToken } from '../../services/AuthService';
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: JSX.Element }): JSX.Element {
|
function RequireAuth({ children }: { children: JSX.Element }): JSX.Element {
|
||||||
const hasAccessToken = useHasAccessToken();
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasAccessToken) {
|
if (!hasAccessToken()) {
|
||||||
navigate('/auth/login');
|
navigate('/auth/login');
|
||||||
}
|
}
|
||||||
}, [hasAccessToken, navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
import { render, waitFor } from '@testing-library/react';
|
|
||||||
import { useHasAccessToken } from '../useHasAccessToken';
|
|
||||||
|
|
||||||
function TestComponent() {
|
|
||||||
const hasAccessToken = useHasAccessToken();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>{hasAccessToken && <div data-testid="has-access-token"></div>}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('useHasAccessToken works properly if access token is present', async () => {
|
|
||||||
localStorage.setItem('accessToken', 'test-access-token');
|
|
||||||
const { getByTestId } = render(<TestComponent />);
|
|
||||||
|
|
||||||
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(<TestComponent />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(container.firstChild).toBeEmptyDOMElement();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
import { render, waitFor } from '@testing-library/react';
|
|
||||||
import { useRefreshToken } from '../useRefreshToken';
|
|
||||||
|
|
||||||
function TestComponent() {
|
|
||||||
const { loading } = useRefreshToken();
|
|
||||||
|
|
||||||
return <div>{!loading && <div>Refreshed</div>}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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(<TestComponent />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(localStorage.getItem('accessToken')).toBe('test-access-token');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
localStorage.removeItem('refreshToken');
|
|
||||||
});
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export const useHasAccessToken = () => {
|
|
||||||
const accessToken = localStorage.getItem('accessToken');
|
|
||||||
|
|
||||||
return accessToken ? true : false;
|
|
||||||
};
|
|
||||||
@ -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 };
|
|
||||||
};
|
|
||||||
@ -1,18 +1,26 @@
|
|||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { useRefreshToken } from '../../hooks/auth/useRefreshToken';
|
import { useEffect, useState } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { refreshAccessToken } from '../../services/AuthService';
|
||||||
|
|
||||||
function Callback() {
|
function Callback() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const refreshToken = searchParams.get('refreshToken');
|
const refreshToken = searchParams.get('refreshToken');
|
||||||
localStorage.setItem('refreshToken', refreshToken || '');
|
localStorage.setItem('refreshToken', refreshToken || '');
|
||||||
const { loading } = useRefreshToken();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading) {
|
async function getAccessToken() {
|
||||||
|
await refreshAccessToken();
|
||||||
|
setIsLoading(false);
|
||||||
navigate('/');
|
navigate('/');
|
||||||
}
|
}
|
||||||
}, [navigate, loading]);
|
|
||||||
|
if (isLoading) {
|
||||||
|
getAccessToken();
|
||||||
|
}
|
||||||
|
}, [isLoading, navigate]);
|
||||||
|
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useHasAccessToken } from '../../hooks/auth/useHasAccessToken';
|
import { hasAccessToken } from '../../services/AuthService';
|
||||||
|
|
||||||
function Login() {
|
function Login() {
|
||||||
const hasAccessToken = useHasAccessToken();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasAccessToken) {
|
if (!hasAccessToken()) {
|
||||||
window.location.href =
|
window.location.href =
|
||||||
process.env.REACT_APP_AUTH_URL + '/signin/provider/google' || '';
|
process.env.REACT_APP_AUTH_URL + '/signin/provider/google' || '';
|
||||||
} else {
|
} else {
|
||||||
navigate('/');
|
navigate('/');
|
||||||
}
|
}
|
||||||
}, [hasAccessToken, navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,10 @@
|
|||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
|
|
||||||
import { CallbackDefault } from '../__stories__/Callback.stories';
|
import { CallbackDefault } from '../__stories__/Callback.stories';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
jest.mock('../../../hooks/auth/useRefreshToken', () => ({
|
it('Checks the Callback page render', async () => {
|
||||||
useRefreshToken: () => ({ loading: false }),
|
await act(async () => {
|
||||||
}));
|
render(<CallbackDefault />);
|
||||||
|
});
|
||||||
it('Checks the Callback page render', () => {
|
|
||||||
render(<CallbackDefault />);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|||||||
34
front/src/services/AuthService.ts
Normal file
34
front/src/services/AuthService.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
68
front/src/services/__tests__/AuthService.test.tsx
Normal file
68
front/src/services/__tests__/AuthService.test.tsx
Normal file
@ -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<Response> => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user