feat: rewrite auth (#364)

* feat: wip rewrite auth

* feat: restructure folders and fix stories and tests

* feat: remove auth provider and fix tests
This commit is contained in:
Jérémy M
2023-06-23 17:49:50 +02:00
committed by GitHub
parent 1c7980b270
commit c6708b2c1f
54 changed files with 1268 additions and 584 deletions

View File

@ -1,13 +1,81 @@
import {
ApolloClient,
ApolloLink,
HttpLink,
InMemoryCache,
UriFunction,
} from '@apollo/client';
import jwt from 'jwt-decode';
export const hasAccessToken = () => {
const accessToken = localStorage.getItem('accessToken');
import { cookieStorage } from '@/utils/cookie-storage';
import {
RenewTokenDocument,
RenewTokenMutation,
RenewTokenMutationVariables,
} from '~/generated/graphql';
import { loggerLink } from '~/providers/apollo/logger';
return accessToken ? true : false;
import { tokenService } from './TokenService';
const logger = loggerLink(() => 'Twenty-Refresh');
/**
* Renew token mutation with custom apollo client
* @param uri string | UriFunction | undefined
* @param refreshToken string
* @returns RenewTokenMutation
*/
const renewTokenMutation = async (
uri: string | UriFunction | undefined,
refreshToken: string,
) => {
const httpLink = new HttpLink({ uri });
// Create new client to call refresh token graphql mutation
const client = new ApolloClient({
link: ApolloLink.from([logger, httpLink]),
cache: new InMemoryCache({}),
});
const { data, errors } = await client.mutate<
RenewTokenMutation,
RenewTokenMutationVariables
>({
mutation: RenewTokenDocument,
variables: {
refreshToken: refreshToken,
},
fetchPolicy: 'network-only',
});
if (errors || !data) {
throw new Error('Something went wrong during token renewal');
}
return data;
};
/**
* Renew token and update cookie storage
* @param uri string | UriFunction | undefined
* @returns TokenPair
*/
export const renewToken = async (uri: string | UriFunction | undefined) => {
const tokenPair = tokenService.getTokenPair();
if (!tokenPair) {
throw new Error('Refresh token is not defined');
}
const data = await renewTokenMutation(uri, tokenPair.refreshToken);
tokenService.setTokenPair(data.renewToken.tokens);
return data.renewToken;
};
export const getUserIdFromToken: () => string | null = () => {
const accessToken = localStorage.getItem('accessToken');
const accessToken = cookieStorage.getItem('accessToken');
if (!accessToken) {
return null;
}
@ -18,76 +86,3 @@ export const getUserIdFromToken: () => string | null = () => {
return null;
}
};
export const hasRefreshToken = () => {
const refreshToken = localStorage.getItem('refreshToken');
return refreshToken ? true : false;
};
export const getTokensFromLoginToken = async (loginToken: string) => {
if (!loginToken) {
return;
}
const response = await fetch(
process.env.REACT_APP_AUTH_URL + '/verify' || '',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ loginToken }),
},
);
if (response.ok) {
const { tokens } = await response.json();
if (!tokens) {
return;
}
localStorage.setItem('accessToken', tokens.accessToken.token);
localStorage.setItem('refreshToken', tokens.refreshToken.token);
} else {
localStorage.removeItem('refreshToken');
localStorage.removeItem('accessToken');
}
};
export const getTokensFromRefreshToken = async () => {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
localStorage.removeItem('accessToken');
return;
}
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 { tokens } = await response.json();
if (!tokens) {
return;
}
localStorage.setItem('accessToken', tokens.accessToken.token);
localStorage.setItem('refreshToken', tokens.refreshToken.token);
} else {
localStorage.removeItem('refreshToken');
localStorage.removeItem('accessToken');
}
};
export const removeTokens = () => {
localStorage.removeItem('refreshToken');
localStorage.removeItem('accessToken');
};

View File

@ -0,0 +1,34 @@
import { cookieStorage } from '@/utils/cookie-storage';
import { AuthTokenPair } from '~/generated/graphql';
export class TokenService {
getTokenPair() {
const accessToken = cookieStorage.getItem('accessToken');
const refreshToken = cookieStorage.getItem('refreshToken');
if (!accessToken || !refreshToken) {
return null;
}
return {
accessToken,
refreshToken,
};
}
setTokenPair(tokens: AuthTokenPair) {
cookieStorage.setItem('accessToken', tokens.accessToken.token, {
secure: true,
});
cookieStorage.setItem('refreshToken', tokens.refreshToken.token, {
secure: true,
});
}
removeTokenPair() {
cookieStorage.removeItem('accessToken');
cookieStorage.removeItem('refreshToken');
}
}
export const tokenService = new TokenService();

View File

@ -1,109 +1,6 @@
import { waitFor } from '@testing-library/react';
import { cookieStorage } from '@/utils/cookie-storage';
import {
getTokensFromLoginToken,
getTokensFromRefreshToken,
getUserIdFromToken,
hasAccessToken,
hasRefreshToken,
} from '../AuthService';
const validTokensPayload = {
accessToken: {
token:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzRmZTNhNS1kZjFlLTQxMTktYWZlMC0yYTYyYTJiYTQ4MWUiLCJ3b3Jrc3BhY2VJZCI6InR3ZW50eS03ZWQ5ZDIxMi0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJpYXQiOjE2ODY5OTMxODIsImV4cCI6MTY4Njk5MzQ4Mn0.F_FD6nJ5fssR_47v2XFhtzqjr-wrEQpqaWVq8iIlLJw',
expiresAt: '2023-06-17T09:18:02.942Z',
},
refreshToken: {
token:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzRmZTNhNS1kZjFlLTQxMTktYWZlMC0yYTYyYTJiYTQ4MWUiLCJpYXQiOjE2ODY5OTMxODIsImV4cCI6OTQ2Mjk5MzE4MiwianRpIjoiNzBmMWNhMjctOTYxYi00ZGZlLWEwOTUtMTY2OWEwOGViMTVjIn0.xEdX9dOGzrPHrPsivQYB9ipYGJH-mJ7GSIVPacmIzfY',
expiresAt: '2023-09-15T09:13:02.952Z',
},
};
const mockFetch = async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
if (input.toString().match(/\/auth\/token$/g)) {
const refreshToken = init?.body
? JSON.parse(init.body.toString()).refreshToken
: null;
return new Promise((resolve) => {
resolve(
new Response(
JSON.stringify({
tokens:
refreshToken === 'xxx-valid-refresh' ? validTokensPayload : null,
}),
),
);
});
}
if (input.toString().match(/\/auth\/verify$/g)) {
const loginToken = init?.body
? JSON.parse(init.body.toString()).loginToken
: null;
return new Promise((resolve) => {
resolve(
new Response(
JSON.stringify({
tokens:
loginToken === 'xxx-valid-login' ? validTokensPayload : null,
}),
),
);
});
}
return new Promise(() => new Response());
};
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', () => {
getTokensFromRefreshToken();
expect(localStorage.getItem('accessToken')).toBeNull();
});
it('refreshToken does not refreh the token if refresh token is invalid', () => {
localStorage.setItem('refreshToken', 'xxx-invalid-refresh');
getTokensFromRefreshToken();
expect(localStorage.getItem('accessToken')).toBeNull();
});
it('refreshToken does not refreh the token if refresh token is empty', () => {
getTokensFromRefreshToken();
expect(localStorage.getItem('accessToken')).toBeNull();
});
it('refreshToken refreshes the token if refresh token is valid', async () => {
localStorage.setItem('refreshToken', 'xxx-valid-refresh');
getTokensFromRefreshToken();
await waitFor(() => {
expect(localStorage.getItem('accessToken')).toBe(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzRmZTNhNS1kZjFlLTQxMTktYWZlMC0yYTYyYTJiYTQ4MWUiLCJ3b3Jrc3BhY2VJZCI6InR3ZW50eS03ZWQ5ZDIxMi0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJpYXQiOjE2ODY5OTMxODIsImV4cCI6MTY4Njk5MzQ4Mn0.F_FD6nJ5fssR_47v2XFhtzqjr-wrEQpqaWVq8iIlLJw',
);
});
});
import { getUserIdFromToken } from '../AuthService';
it('getUserIdFromToken returns null when the token is not present', async () => {
const userId = getUserIdFromToken();
@ -111,13 +8,13 @@ it('getUserIdFromToken returns null when the token is not present', async () =>
});
it('getUserIdFromToken returns null when the token is not valid', async () => {
localStorage.setItem('accessToken', 'xxx-invalid-access');
cookieStorage.setItem('accessToken', 'xxx-invalid-access');
const userId = getUserIdFromToken();
expect(userId).toBeNull();
});
it('getUserIdFromToken returns the right userId when the token is valid', async () => {
localStorage.setItem(
cookieStorage.setItem(
'accessToken',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzRmZTNhNS1kZjFlLTQxMTktYWZlMC0yYTYyYTJiYTQ4MWUiLCJ3b3Jrc3BhY2VJZCI6InR3ZW50eS03ZWQ5ZDIxMi0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJpYXQiOjE2ODY5OTI0ODgsImV4cCI6MTY4Njk5Mjc4OH0.IO7U5G14IrrQriw3JjrKVxmZgd6XKL6yUIwuNe_R55E',
);
@ -125,28 +22,6 @@ it('getUserIdFromToken returns the right userId when the token is valid', async
expect(userId).toBe('374fe3a5-df1e-4119-afe0-2a62a2ba481e');
});
it('getTokensFromLoginToken does nothing if loginToken is empty', async () => {
await getTokensFromLoginToken('');
expect(localStorage.getItem('accessToken')).toBeNull();
expect(localStorage.getItem('refreshToken')).toBeNull();
});
it('getTokensFromLoginToken does nothing if loginToken is not valid', async () => {
await getTokensFromLoginToken('xxx-invalid-login');
expect(localStorage.getItem('accessToken')).toBeNull();
expect(localStorage.getItem('refreshToken')).toBeNull();
});
it('getTokensFromLoginToken does nothing if loginToken is not valid', async () => {
await getTokensFromLoginToken('xxx-valid-login');
expect(localStorage.getItem('accessToken')).toBe(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzRmZTNhNS1kZjFlLTQxMTktYWZlMC0yYTYyYTJiYTQ4MWUiLCJ3b3Jrc3BhY2VJZCI6InR3ZW50eS03ZWQ5ZDIxMi0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJpYXQiOjE2ODY5OTMxODIsImV4cCI6MTY4Njk5MzQ4Mn0.F_FD6nJ5fssR_47v2XFhtzqjr-wrEQpqaWVq8iIlLJw',
);
expect(localStorage.getItem('refreshToken')).toBe(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzRmZTNhNS1kZjFlLTQxMTktYWZlMC0yYTYyYTJiYTQ4MWUiLCJpYXQiOjE2ODY5OTMxODIsImV4cCI6OTQ2Mjk5MzE4MiwianRpIjoiNzBmMWNhMjctOTYxYi00ZGZlLWEwOTUtMTY2OWEwOGViMTVjIn0.xEdX9dOGzrPHrPsivQYB9ipYGJH-mJ7GSIVPacmIzfY',
);
});
afterEach(() => {
localStorage.clear();
cookieStorage.clear();
});

View File

@ -0,0 +1,43 @@
import Cookies from 'js-cookie';
import { tokenService } from '../TokenService';
const tokenPair = {
accessToken: {
token:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzRmZTNhNS1kZjFlLTQxMTktYWZlMC0yYTYyYTJiYTQ4MWUiLCJ3b3Jrc3BhY2VJZCI6InR3ZW50eS03ZWQ5ZDIxMi0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJpYXQiOjE2ODY5OTMxODIsImV4cCI6MTY4Njk5MzQ4Mn0.F_FD6nJ5fssR_47v2XFhtzqjr-wrEQpqaWVq8iIlLJw',
expiresAt: '2023-06-17T09:18:02.942Z',
},
refreshToken: {
token:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzRmZTNhNS1kZjFlLTQxMTktYWZlMC0yYTYyYTJiYTQ4MWUiLCJpYXQiOjE2ODY5OTMxODIsImV4cCI6OTQ2Mjk5MzE4MiwianRpIjoiNzBmMWNhMjctOTYxYi00ZGZlLWEwOTUtMTY2OWEwOGViMTVjIn0.xEdX9dOGzrPHrPsivQYB9ipYGJH-mJ7GSIVPacmIzfY',
expiresAt: '2023-09-15T09:13:02.952Z',
},
};
it('getTokenPair is fullfiled when token is present', () => {
tokenService.setTokenPair(tokenPair);
// Otherwise the test will fail because Cookies-js seems to be async but functions aren't promises
setTimeout(() => {
expect(tokenService.getTokenPair()).toBe({
accessToken: tokenPair.accessToken,
refreshToken: tokenPair.refreshToken,
});
}, 10);
});
it('getTokenPair is null when token is not set', () => {
expect(tokenService.getTokenPair()).toBeNull();
});
it('removeTokenPair clean cookie storage', () => {
tokenService.setTokenPair(tokenPair);
tokenService.removeTokenPair();
expect(tokenService.getTokenPair()).toBeNull();
});
afterEach(() => {
Cookies.remove('accessToken');
Cookies.remove('refreshToken');
});

View File

@ -0,0 +1 @@
export * from './index';

View File

@ -0,0 +1,60 @@
import { gql } from '@apollo/client';
export const CHALLENGE = gql`
mutation Challenge($email: String!, $password: String!) {
challenge(email: $email, password: $password) {
loginToken {
expiresAt
token
}
}
}
`;
export const VERIFY = gql`
mutation Verify($loginToken: String!) {
verify(loginToken: $loginToken) {
user {
id
email
displayName
workspaceMember {
id
workspace {
id
domainName
displayName
logo
}
}
}
tokens {
accessToken {
token
expiresAt
}
refreshToken {
token
expiresAt
}
}
}
}
`;
export const RENEW_TOKEN = gql`
mutation RenewToken($refreshToken: String!) {
renewToken(refreshToken: $refreshToken) {
tokens {
accessToken {
expiresAt
token
}
refreshToken {
token
expiresAt
}
}
}
}
`;