From c6708b2c1fd5422469ed483efee9db982dbddccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20M?= Date: Fri, 23 Jun 2023 17:49:50 +0200 Subject: [PATCH] feat: rewrite auth (#364) * feat: wip rewrite auth * feat: restructure folders and fix stories and tests * feat: remove auth provider and fix tests --- front/.nvmrc | 1 + front/package.json | 2 + front/src/AppWrapper.tsx | 24 +-- front/src/__stories__/shared.tsx | 20 +- front/src/apollo.tsx | 125 ----------- front/src/generated/graphql.tsx | 204 ++++++++++++++++++ .../modules/auth/components/RequireAuth.tsx | 12 +- .../auth/components/RequireNotAuth.tsx | 12 +- front/src/modules/auth/hooks/useAuth.ts | 82 +++++++ front/src/modules/auth/hooks/useIsLogged.ts | 21 ++ .../src/modules/auth/services/AuthService.ts | 149 +++++++------ .../src/modules/auth/services/TokenService.ts | 34 +++ .../services/__tests__/AuthService.test.tsx | 135 +----------- .../services/__tests__/TokenService.test.tsx | 43 ++++ front/src/modules/auth/services/index.ts | 1 + front/src/modules/auth/services/update.ts | 60 ++++++ .../settings/components/SettingsNavbar.tsx | 16 +- front/src/modules/users/services/index.ts | 10 - front/src/modules/utils/assert.ts | 3 + front/src/modules/utils/cookie-storage.ts | 62 ++++++ .../modules/utils/promise-to-observable.ts | 16 ++ front/src/pages/auth/Index.tsx | 13 +- front/src/pages/auth/PasswordLogin.tsx | 46 ++-- front/src/pages/auth/Verify.tsx | 21 +- front/src/providers/AuthProvider.tsx | 29 --- front/src/providers/apollo/ApolloProvider.tsx | 19 ++ front/src/providers/apollo/apollo-client.ts | 36 ++++ front/src/providers/apollo/apollo.factory.ts | 167 ++++++++++++++ .../interfaces/apollo-manager.interface.ts | 5 + .../providers/apollo/logger/format-title.ts | 45 ++++ front/src/providers/apollo/logger/index.ts | 102 +++++++++ .../providers/apollo/logger/operation-type.ts | 6 + front/src/providers/apollo/mock-client.ts | 36 ++++ .../{ => theme}/AppThemeProvider.tsx | 0 front/src/testing/renderWrappers.tsx | 5 +- front/yarn.lock | 10 + server/src/core/auth/auth.module.ts | 13 +- server/src/core/auth/auth.resolver.spec.ts | 30 +++ server/src/core/auth/auth.resolver.ts | 49 +++++ .../password-auth.controller.spec.ts | 30 --- .../controllers/password-auth.controller.ts | 23 -- .../core/auth/controllers/token.controller.ts | 21 -- ...spec.ts => verify-auth.controller.spec.ts} | 10 +- ...ontroller.ts => verify-auth.controller.ts} | 10 +- server/src/core/auth/dto/challenge.input.ts | 4 + .../src/core/auth/dto/login-token.entity.ts | 9 +- .../src/core/auth/dto/refresh-token.input.ts | 3 + server/src/core/auth/dto/register.input.ts | 5 + server/src/core/auth/dto/token.entity.ts | 23 +- server/src/core/auth/dto/verify.entity.ts | 16 +- server/src/core/auth/dto/verify.input.ts | 3 + server/src/core/auth/services/auth.service.ts | 12 +- .../src/core/auth/services/token.service.ts | 12 +- server/src/core/workspace/workspace.module.ts | 7 +- 54 files changed, 1268 insertions(+), 584 deletions(-) create mode 100644 front/.nvmrc delete mode 100644 front/src/apollo.tsx create mode 100644 front/src/modules/auth/hooks/useAuth.ts create mode 100644 front/src/modules/auth/hooks/useIsLogged.ts create mode 100644 front/src/modules/auth/services/TokenService.ts create mode 100644 front/src/modules/auth/services/__tests__/TokenService.test.tsx create mode 100644 front/src/modules/auth/services/index.ts create mode 100644 front/src/modules/auth/services/update.ts create mode 100644 front/src/modules/utils/assert.ts create mode 100644 front/src/modules/utils/cookie-storage.ts create mode 100644 front/src/modules/utils/promise-to-observable.ts delete mode 100644 front/src/providers/AuthProvider.tsx create mode 100644 front/src/providers/apollo/ApolloProvider.tsx create mode 100644 front/src/providers/apollo/apollo-client.ts create mode 100644 front/src/providers/apollo/apollo.factory.ts create mode 100644 front/src/providers/apollo/interfaces/apollo-manager.interface.ts create mode 100644 front/src/providers/apollo/logger/format-title.ts create mode 100644 front/src/providers/apollo/logger/index.ts create mode 100644 front/src/providers/apollo/logger/operation-type.ts create mode 100644 front/src/providers/apollo/mock-client.ts rename front/src/providers/{ => theme}/AppThemeProvider.tsx (100%) create mode 100644 server/src/core/auth/auth.resolver.spec.ts create mode 100644 server/src/core/auth/auth.resolver.ts delete mode 100644 server/src/core/auth/controllers/password-auth.controller.spec.ts delete mode 100644 server/src/core/auth/controllers/password-auth.controller.ts delete mode 100644 server/src/core/auth/controllers/token.controller.ts rename server/src/core/auth/controllers/{auth.controller.spec.ts => verify-auth.controller.spec.ts} (67%) rename server/src/core/auth/controllers/{auth.controller.ts => verify-auth.controller.ts} (81%) diff --git a/front/.nvmrc b/front/.nvmrc new file mode 100644 index 000000000..807e54170 --- /dev/null +++ b/front/.nvmrc @@ -0,0 +1 @@ +18.6.0 \ No newline at end of file diff --git a/front/package.json b/front/package.json index d911ab49e..818067298 100644 --- a/front/package.json +++ b/front/package.json @@ -19,6 +19,7 @@ "cmdk": "^0.2.0", "date-fns": "^2.30.0", "graphql": "^16.6.0", + "js-cookie": "^3.0.5", "jwt-decode": "^3.1.2", "libphonenumber-js": "^1.10.26", "luxon": "^3.3.0", @@ -107,6 +108,7 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "@types/jest": "^27.5.2", + "@types/js-cookie": "^3.0.3", "@types/luxon": "^3.3.0", "@types/react-datepicker": "^4.11.2", "@types/scroll-into-view": "^1.16.0", diff --git a/front/src/AppWrapper.tsx b/front/src/AppWrapper.tsx index 1ae1494f5..94076c583 100644 --- a/front/src/AppWrapper.tsx +++ b/front/src/AppWrapper.tsx @@ -1,27 +1,19 @@ import { StrictMode } from 'react'; import { BrowserRouter } from 'react-router-dom'; -import { ApolloProvider } from '@apollo/client'; -import { useRecoilState } from 'recoil'; -import { isMockModeState } from '@/auth/states/isMockModeState'; - -import { AppThemeProvider } from './providers/AppThemeProvider'; -import { AuthProvider } from './providers/AuthProvider'; -import { apiClient, mockClient } from './apollo'; +import { ApolloProvider } from './providers/apollo/ApolloProvider'; +import { AppThemeProvider } from './providers/theme/AppThemeProvider'; import { App } from './App'; export function AppWrapper() { - const [isMockMode] = useRecoilState(isMockModeState); return ( - + - - - - - - - + + + + + ); diff --git a/front/src/__stories__/shared.tsx b/front/src/__stories__/shared.tsx index de2dfde0a..679667744 100644 --- a/front/src/__stories__/shared.tsx +++ b/front/src/__stories__/shared.tsx @@ -1,22 +1,34 @@ import { MemoryRouter } from 'react-router-dom'; import { ApolloProvider } from '@apollo/client'; import { ThemeProvider } from '@emotion/react'; -import { RecoilRoot } from 'recoil'; +import { RecoilRoot, useRecoilState } from 'recoil'; +import { currentUserState } from '@/auth/states/currentUserState'; +import { isAuthenticatingState } from '@/auth/states/isAuthenticatingState'; import { darkTheme } from '@/ui/layout/styles/themes'; import { App } from '~/App'; -import { AuthProvider } from '~/providers/AuthProvider'; import { FullHeightStorybookLayout } from '~/testing/FullHeightStorybookLayout'; +import { mockedUsersData } from '~/testing/mock-data/users'; import { mockedClient } from '~/testing/mockedClient'; export const render = () => renderWithDarkMode(false); +const MockedAuth: React.FC = ({ children }) => { + const [, setCurrentUser] = useRecoilState(currentUserState); + const [, setIsAuthenticating] = useRecoilState(isAuthenticatingState); + + setCurrentUser(mockedUsersData[0]); + setIsAuthenticating(false); + + return <>{children}; +}; + export const renderWithDarkMode = (forceDarkMode?: boolean) => { const AppInStoryBook = ( - + - + ); diff --git a/front/src/apollo.tsx b/front/src/apollo.tsx deleted file mode 100644 index 4b512af97..000000000 --- a/front/src/apollo.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { - ApolloClient, - ApolloLink, - createHttpLink, - from, - InMemoryCache, - Observable, -} from '@apollo/client'; -import { setContext } from '@apollo/client/link/context'; -import { onError } from '@apollo/client/link/error'; -import { RestLink } from 'apollo-link-rest'; - -import { CommentThreadTarget } from './generated/graphql'; -import { getTokensFromRefreshToken } from './modules/auth/services/AuthService'; -import { mockedCompaniesData } from './testing/mock-data/companies'; -import { mockedUsersData } from './testing/mock-data/users'; - -const apiLink = createHttpLink({ - uri: `${process.env.REACT_APP_API_URL}`, -}); - -const withAuthHeadersLink = setContext((_, { headers }) => { - const token = localStorage.getItem('accessToken'); - return { - headers: { - ...headers, - authorization: token ? `Bearer ${token}` : '', - }, - }; -}); - -const errorLink = onError(({ graphQLErrors, operation, forward }) => { - if (graphQLErrors) { - for (const err of graphQLErrors) { - switch (err.extensions.code) { - case 'UNAUTHENTICATED': - return new Observable((observer) => { - (async () => { - try { - await getTokensFromRefreshToken(); - - 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: from([errorLink, withAuthHeadersLink, apiLink]), - cache: new InMemoryCache({ - typePolicies: { - CommentThread: { - fields: { - commentThreadTargets: { - merge( - existing: CommentThreadTarget[] = [], - incoming: CommentThreadTarget[], - ) { - return [...incoming]; - }, - }, - }, - }, - }, - }), - defaultOptions: { - query: { - fetchPolicy: 'cache-first', - }, - }, -}); - -const authLink = new RestLink({ - uri: `${process.env.REACT_APP_AUTH_URL}`, - credentials: 'same-origin', -}); - -export const authClient = new ApolloClient({ - link: authLink, - cache: new InMemoryCache(), -}); - -const mockLink = new ApolloLink((operation, forward) => { - return forward(operation).map((response) => { - if (operation.operationName === 'GetCompanies') { - return { data: { companies: mockedCompaniesData } }; - } - if (operation.operationName === 'GetCurrentUser') { - return { data: { users: [mockedUsersData[0]] } }; - } - return response; - }); -}); - -export const mockClient = new ApolloClient({ - link: from([mockLink, apiLink]), - cache: new InMemoryCache(), - defaultOptions: { - query: { - fetchPolicy: 'cache-first', - }, - }, -}); diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 11f9373ea..ae25ddabd 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -22,6 +22,23 @@ export type AffectedRows = { count: Scalars['Int']; }; +export type AuthToken = { + __typename?: 'AuthToken'; + expiresAt: Scalars['DateTime']; + token: Scalars['String']; +}; + +export type AuthTokenPair = { + __typename?: 'AuthTokenPair'; + accessToken: AuthToken; + refreshToken: AuthToken; +}; + +export type AuthTokens = { + __typename?: 'AuthTokens'; + tokens: AuthTokenPair; +}; + export type BoolFieldUpdateOperationsInput = { set?: InputMaybe; }; @@ -681,8 +698,14 @@ export type JsonNullableFilter = { string_starts_with?: InputMaybe; }; +export type LoginToken = { + __typename?: 'LoginToken'; + loginToken: AuthToken; +}; + export type Mutation = { __typename?: 'Mutation'; + challenge: LoginToken; createOneComment: Comment; createOneCommentThread: CommentThread; createOneCompany: Company; @@ -691,10 +714,18 @@ export type Mutation = { deleteManyCompany: AffectedRows; deleteManyPerson: AffectedRows; deleteManyPipelineProgress: AffectedRows; + renewToken: AuthTokens; updateOneCommentThread: CommentThread; updateOneCompany?: Maybe; updateOnePerson?: Maybe; updateOnePipelineProgress?: Maybe; + verify: Verify; +}; + + +export type MutationChallengeArgs = { + email: Scalars['String']; + password: Scalars['String']; }; @@ -738,6 +769,11 @@ export type MutationDeleteManyPipelineProgressArgs = { }; +export type MutationRenewTokenArgs = { + refreshToken: Scalars['String']; +}; + + export type MutationUpdateOneCommentThreadArgs = { data: CommentThreadUpdateInput; where: CommentThreadWhereUniqueInput; @@ -761,6 +797,11 @@ export type MutationUpdateOnePipelineProgressArgs = { where: PipelineProgressWhereUniqueInput; }; + +export type MutationVerifyArgs = { + loginToken: Scalars['String']; +}; + export type NestedBoolFilter = { equals?: InputMaybe; not?: InputMaybe; @@ -1489,6 +1530,12 @@ export type UserWhereUniqueInput = { id?: InputMaybe; }; +export type Verify = { + __typename?: 'Verify'; + tokens: AuthTokenPair; + user: User; +}; + export type Workspace = { __typename?: 'Workspace'; commentThreads?: Maybe>; @@ -1519,6 +1566,28 @@ export type WorkspaceMember = { workspace: Workspace; }; +export type ChallengeMutationVariables = Exact<{ + email: Scalars['String']; + password: Scalars['String']; +}>; + + +export type ChallengeMutation = { __typename?: 'Mutation', challenge: { __typename?: 'LoginToken', loginToken: { __typename?: 'AuthToken', expiresAt: string, token: string } } }; + +export type VerifyMutationVariables = Exact<{ + loginToken: Scalars['String']; +}>; + + +export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: string, email: string, displayName: string, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, workspace: { __typename?: 'Workspace', id: string, domainName: string, displayName: string, logo?: string | null } } | null }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; + +export type RenewTokenMutationVariables = Exact<{ + refreshToken: Scalars['String']; +}>; + + +export type RenewTokenMutation = { __typename?: 'Mutation', renewToken: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', expiresAt: string, token: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; + export type CreateCommentMutationVariables = Exact<{ commentId: Scalars['String']; commentText: Scalars['String']; @@ -1730,6 +1799,141 @@ export type GetUsersQueryVariables = Exact<{ [key: string]: never; }>; export type GetUsersQuery = { __typename?: 'Query', findManyUser: Array<{ __typename?: 'User', id: string, email: string, displayName: string }> }; +export const ChallengeDocument = gql` + mutation Challenge($email: String!, $password: String!) { + challenge(email: $email, password: $password) { + loginToken { + expiresAt + token + } + } +} + `; +export type ChallengeMutationFn = Apollo.MutationFunction; + +/** + * __useChallengeMutation__ + * + * To run a mutation, you first call `useChallengeMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useChallengeMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [challengeMutation, { data, loading, error }] = useChallengeMutation({ + * variables: { + * email: // value for 'email' + * password: // value for 'password' + * }, + * }); + */ +export function useChallengeMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ChallengeDocument, options); + } +export type ChallengeMutationHookResult = ReturnType; +export type ChallengeMutationResult = Apollo.MutationResult; +export type ChallengeMutationOptions = Apollo.BaseMutationOptions; +export const VerifyDocument = 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 type VerifyMutationFn = Apollo.MutationFunction; + +/** + * __useVerifyMutation__ + * + * To run a mutation, you first call `useVerifyMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useVerifyMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [verifyMutation, { data, loading, error }] = useVerifyMutation({ + * variables: { + * loginToken: // value for 'loginToken' + * }, + * }); + */ +export function useVerifyMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(VerifyDocument, options); + } +export type VerifyMutationHookResult = ReturnType; +export type VerifyMutationResult = Apollo.MutationResult; +export type VerifyMutationOptions = Apollo.BaseMutationOptions; +export const RenewTokenDocument = gql` + mutation RenewToken($refreshToken: String!) { + renewToken(refreshToken: $refreshToken) { + tokens { + accessToken { + expiresAt + token + } + refreshToken { + token + expiresAt + } + } + } +} + `; +export type RenewTokenMutationFn = Apollo.MutationFunction; + +/** + * __useRenewTokenMutation__ + * + * To run a mutation, you first call `useRenewTokenMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useRenewTokenMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [renewTokenMutation, { data, loading, error }] = useRenewTokenMutation({ + * variables: { + * refreshToken: // value for 'refreshToken' + * }, + * }); + */ +export function useRenewTokenMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(RenewTokenDocument, options); + } +export type RenewTokenMutationHookResult = ReturnType; +export type RenewTokenMutationResult = Apollo.MutationResult; +export type RenewTokenMutationOptions = Apollo.BaseMutationOptions; export const CreateCommentDocument = gql` mutation CreateComment($commentId: String!, $commentText: String!, $authorId: String!, $commentThreadId: String!, $createdAt: DateTime!) { createOneComment( diff --git a/front/src/modules/auth/components/RequireAuth.tsx b/front/src/modules/auth/components/RequireAuth.tsx index 4781e0faa..7c4692b25 100644 --- a/front/src/modules/auth/components/RequireAuth.tsx +++ b/front/src/modules/auth/components/RequireAuth.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { keyframes } from '@emotion/react'; import styled from '@emotion/styled'; -import { hasAccessToken } from '../services/AuthService'; +import { useIsLogged } from '../hooks/useIsLogged'; const EmptyContainer = styled.div` align-items: center; @@ -34,13 +34,15 @@ export function RequireAuth({ }): JSX.Element { const navigate = useNavigate(); + const isLogged = useIsLogged(); + useEffect(() => { - if (!hasAccessToken()) { + if (!isLogged) { navigate('/auth'); } - }, [navigate]); + }, [isLogged, navigate]); - if (!hasAccessToken()) + if (!isLogged) { return ( @@ -48,5 +50,7 @@ export function RequireAuth({ ); + } + return children; } diff --git a/front/src/modules/auth/components/RequireNotAuth.tsx b/front/src/modules/auth/components/RequireNotAuth.tsx index 9f51800c5..494bd296f 100644 --- a/front/src/modules/auth/components/RequireNotAuth.tsx +++ b/front/src/modules/auth/components/RequireNotAuth.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { keyframes } from '@emotion/react'; import styled from '@emotion/styled'; -import { hasAccessToken } from '../services/AuthService'; +import { useIsLogged } from '../hooks/useIsLogged'; const EmptyContainer = styled.div` align-items: center; @@ -34,13 +34,15 @@ export function RequireNotAuth({ }): JSX.Element { const navigate = useNavigate(); + const isLogged = useIsLogged(); + useEffect(() => { - if (hasAccessToken()) { + if (isLogged) { navigate('/'); } - }, [navigate]); + }, [isLogged, navigate]); - if (hasAccessToken()) + if (isLogged) { return ( @@ -48,5 +50,7 @@ export function RequireNotAuth({ ); + } + return children; } diff --git a/front/src/modules/auth/hooks/useAuth.ts b/front/src/modules/auth/hooks/useAuth.ts new file mode 100644 index 000000000..26fbb3f97 --- /dev/null +++ b/front/src/modules/auth/hooks/useAuth.ts @@ -0,0 +1,82 @@ +import { useCallback } from 'react'; +import { useRecoilState } from 'recoil'; + +import { useChallengeMutation, useVerifyMutation } from '~/generated/graphql'; + +import { tokenService } from '../services/TokenService'; +import { currentUserState } from '../states/currentUserState'; +import { isAuthenticatingState } from '../states/isAuthenticatingState'; + +export function useAuth() { + const [, setCurrentUser] = useRecoilState(currentUserState); + const [, setIsAuthenticating] = useRecoilState(isAuthenticatingState); + + const [challenge] = useChallengeMutation(); + const [verify] = useVerifyMutation(); + + const handleChallenge = useCallback( + async (email: string, password: string) => { + const challengeResult = await challenge({ + variables: { + email, + password, + }, + }); + + if (challengeResult.errors) { + throw challengeResult.errors; + } + + if (!challengeResult.data?.challenge) { + throw new Error('No login token'); + } + + return challengeResult.data.challenge; + }, + [challenge], + ); + + const handleVerify = useCallback( + async (loginToken: string) => { + const verifyResult = await verify({ + variables: { loginToken }, + }); + + if (verifyResult.errors) { + throw verifyResult.errors; + } + + if (!verifyResult.data?.verify) { + throw new Error('No verify result'); + } + + tokenService.setTokenPair(verifyResult.data?.verify.tokens); + + setIsAuthenticating(false); + setCurrentUser(verifyResult.data?.verify.user); + + return verifyResult.data?.verify; + }, + [setCurrentUser, setIsAuthenticating, verify], + ); + + const handleLogin = useCallback( + async (email: string, password: string) => { + const { loginToken } = await handleChallenge(email, password); + + await handleVerify(loginToken.token); + }, + [handleChallenge, handleVerify], + ); + + const handleLogout = useCallback(() => { + tokenService.removeTokenPair(); + }, []); + + return { + challenge: handleChallenge, + verify: handleVerify, + login: handleLogin, + logout: handleLogout, + }; +} diff --git a/front/src/modules/auth/hooks/useIsLogged.ts b/front/src/modules/auth/hooks/useIsLogged.ts new file mode 100644 index 000000000..16b05eb51 --- /dev/null +++ b/front/src/modules/auth/hooks/useIsLogged.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react'; + +import { cookieStorage } from '@/utils/cookie-storage'; + +export function useIsLogged(): boolean { + const [value, setValue] = useState( + cookieStorage.getItem('accessToken'), + ); + + useEffect(() => { + const updateValue = (newValue: string | undefined) => setValue(newValue); + + cookieStorage.addEventListener('accessToken', updateValue); + + return () => { + cookieStorage.removeEventListener('accessToken', updateValue); + }; + }, []); + + return !!value; +} diff --git a/front/src/modules/auth/services/AuthService.ts b/front/src/modules/auth/services/AuthService.ts index 3434cfd65..3d5bb9c4a 100644 --- a/front/src/modules/auth/services/AuthService.ts +++ b/front/src/modules/auth/services/AuthService.ts @@ -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'); -}; diff --git a/front/src/modules/auth/services/TokenService.ts b/front/src/modules/auth/services/TokenService.ts new file mode 100644 index 000000000..9834ec88e --- /dev/null +++ b/front/src/modules/auth/services/TokenService.ts @@ -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(); diff --git a/front/src/modules/auth/services/__tests__/AuthService.test.tsx b/front/src/modules/auth/services/__tests__/AuthService.test.tsx index f725ad7f4..1225cf744 100644 --- a/front/src/modules/auth/services/__tests__/AuthService.test.tsx +++ b/front/src/modules/auth/services/__tests__/AuthService.test.tsx @@ -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 => { - 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(); }); diff --git a/front/src/modules/auth/services/__tests__/TokenService.test.tsx b/front/src/modules/auth/services/__tests__/TokenService.test.tsx new file mode 100644 index 000000000..47c3496cb --- /dev/null +++ b/front/src/modules/auth/services/__tests__/TokenService.test.tsx @@ -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'); +}); diff --git a/front/src/modules/auth/services/index.ts b/front/src/modules/auth/services/index.ts new file mode 100644 index 000000000..ea465c2a3 --- /dev/null +++ b/front/src/modules/auth/services/index.ts @@ -0,0 +1 @@ +export * from './index'; diff --git a/front/src/modules/auth/services/update.ts b/front/src/modules/auth/services/update.ts new file mode 100644 index 000000000..7a15ccce4 --- /dev/null +++ b/front/src/modules/auth/services/update.ts @@ -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 + } + } + } + } +`; diff --git a/front/src/modules/settings/components/SettingsNavbar.tsx b/front/src/modules/settings/components/SettingsNavbar.tsx index cce5c0d2c..e67961aa5 100644 --- a/front/src/modules/settings/components/SettingsNavbar.tsx +++ b/front/src/modules/settings/components/SettingsNavbar.tsx @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { useMatch, useResolvedPath } from 'react-router-dom'; import { useTheme } from '@emotion/react'; -import { removeTokens } from '@/auth/services/AuthService'; +import { useAuth } from '@/auth/hooks/useAuth'; import { IconColorSwatch, IconLogout, @@ -15,11 +15,15 @@ import NavTitle from '@/ui/layout/navbar/NavTitle'; import SubNavbarContainer from '@/ui/layout/navbar/sub-navbar/SubNavBarContainer'; export function SettingsNavbar() { - const logout = useCallback(() => { - removeTokens(); - window.location.href = '/'; - }, []); const theme = useTheme(); + + const { logout } = useAuth(); + + const handleLogout = useCallback(() => { + logout(); + window.location.href = '/'; + }, [logout]); + return ( @@ -63,7 +67,7 @@ export function SettingsNavbar() { } danger={true} /> diff --git a/front/src/modules/users/services/index.ts b/front/src/modules/users/services/index.ts index 61eab390c..47f9e280c 100644 --- a/front/src/modules/users/services/index.ts +++ b/front/src/modules/users/services/index.ts @@ -1,7 +1,5 @@ import { gql } from '@apollo/client'; -import { useGetCurrentUserQuery as generatedUseGetCurrentUserQuery } from '~/generated/graphql'; - export const GET_CURRENT_USER = gql` query GetCurrentUser($uuid: String) { users: findManyUser(where: { id: { equals: $uuid } }) { @@ -30,11 +28,3 @@ export const GET_USERS = gql` } } `; - -export function useGetCurrentUserQuery(userId: string | null) { - return generatedUseGetCurrentUserQuery({ - variables: { - uuid: userId, - }, - }); -} diff --git a/front/src/modules/utils/assert.ts b/front/src/modules/utils/assert.ts new file mode 100644 index 000000000..793ce6c97 --- /dev/null +++ b/front/src/modules/utils/assert.ts @@ -0,0 +1,3 @@ +export function assertNotNull(item: T): item is NonNullable { + return item !== null && item !== undefined; +} diff --git a/front/src/modules/utils/cookie-storage.ts b/front/src/modules/utils/cookie-storage.ts new file mode 100644 index 000000000..ab98fa162 --- /dev/null +++ b/front/src/modules/utils/cookie-storage.ts @@ -0,0 +1,62 @@ +import Cookies, { CookieAttributes } from 'js-cookie'; + +type Listener = ( + newValue: string | undefined, + oldValue: string | undefined, +) => void; + +class CookieStorage { + private listeners: Record = {}; + private keys: Set = new Set(); + + getItem(key: string): string | undefined { + return Cookies.get(key); + } + + setItem(key: string, value: string, attributes?: CookieAttributes): void { + const oldValue = this.getItem(key); + + this.keys.add(key); + Cookies.set(key, value, attributes); + this.dispatch(key, value, oldValue); + } + + removeItem(key: string): void { + const oldValue = this.getItem(key); + + this.keys.delete(key); + Cookies.remove(key); + this.dispatch(key, undefined, oldValue); + } + + clear(): void { + this.keys.forEach((key) => this.removeItem(key)); + } + + private dispatch( + key: string, + newValue: string | undefined, + oldValue: string | undefined, + ): void { + if (this.listeners[key]) { + this.listeners[key].forEach((callback) => callback(newValue, oldValue)); + } + } + + addEventListener(key: string, callback: Listener): void { + if (!this.listeners[key]) { + this.listeners[key] = []; + } + this.listeners[key].push(callback); + } + + removeEventListener(key: string, callback: Listener): void { + if (this.listeners[key]) { + this.listeners[key] = this.listeners[key].filter( + (listener) => listener !== callback, + ); + } + } +} + +export const cookieStorage = new CookieStorage(); diff --git a/front/src/modules/utils/promise-to-observable.ts b/front/src/modules/utils/promise-to-observable.ts new file mode 100644 index 000000000..41eac9e85 --- /dev/null +++ b/front/src/modules/utils/promise-to-observable.ts @@ -0,0 +1,16 @@ +import { Observable } from '@apollo/client'; + +export const promiseToObservable = (promise: Promise) => + new Observable((subscriber) => { + promise.then( + (value) => { + if (subscriber.closed) { + return; + } + + subscriber.next(value); + subscriber.complete(); + }, + (err) => subscriber.error(err), + ); + }); diff --git a/front/src/pages/auth/Index.tsx b/front/src/pages/auth/Index.tsx index 57659c967..535fd0bd3 100644 --- a/front/src/pages/auth/Index.tsx +++ b/front/src/pages/auth/Index.tsx @@ -10,7 +10,6 @@ import { HorizontalSeparator } from '@/auth/components/ui/HorizontalSeparator'; import { Logo } from '@/auth/components/ui/Logo'; import { Modal } from '@/auth/components/ui/Modal'; import { Title } from '@/auth/components/ui/Title'; -import { hasAccessToken } from '@/auth/services/AuthService'; import { authFlowUserEmailState } from '@/auth/states/authFlowUserEmailState'; import { isMockModeState } from '@/auth/states/isMockModeState'; import { PrimaryButton } from '@/ui/components/buttons/PrimaryButton'; @@ -34,14 +33,6 @@ export function Index() { authFlowUserEmailState, ); - useEffect(() => { - setMockMode(true); - - if (hasAccessToken()) { - navigate('/'); - } - }, [navigate, setMockMode]); - const onGoogleLoginClick = useCallback(() => { window.location.href = process.env.REACT_APP_AUTH_URL + '/google' || ''; }, []); @@ -62,6 +53,10 @@ export function Index() { [onPasswordLoginClick], ); + useEffect(() => { + setMockMode(true); + }, [navigate, setMockMode]); + return ( <> diff --git a/front/src/pages/auth/PasswordLogin.tsx b/front/src/pages/auth/PasswordLogin.tsx index b237a30f4..0ba5ff550 100644 --- a/front/src/pages/auth/PasswordLogin.tsx +++ b/front/src/pages/auth/PasswordLogin.tsx @@ -9,7 +9,7 @@ import { Logo } from '@/auth/components/ui/Logo'; import { Modal } from '@/auth/components/ui/Modal'; import { SubTitle } from '@/auth/components/ui/SubTitle'; import { Title } from '@/auth/components/ui/Title'; -import { getTokensFromLoginToken } from '@/auth/services/AuthService'; +import { useAuth } from '@/auth/hooks/useAuth'; import { authFlowUserEmailState } from '@/auth/states/authFlowUserEmailState'; import { isMockModeState } from '@/auth/states/isMockModeState'; import { PrimaryButton } from '@/ui/components/buttons/PrimaryButton'; @@ -47,48 +47,28 @@ export function PasswordLogin() { const [internalPassword, setInternalPassword] = useState(prefillPassword); const [formError, setFormError] = useState(''); - const userLogin = useCallback(async () => { - const response = await fetch( - process.env.REACT_APP_AUTH_URL + '/password' || '', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email: authFlowUserEmail, - password: internalPassword, - }), - }, - ); + const { login } = useAuth(); - if (!response.ok) { - const errorData = await response.json(); - setFormError(errorData.message); - return; + const handleLogin = useCallback(async () => { + try { + await login(authFlowUserEmail, internalPassword); + setMockMode(false); + navigate('/'); + } catch (err: any) { + setFormError(err.message); } - const { loginToken } = await response.json(); - - if (!loginToken) { - return; - } - - await getTokensFromLoginToken(loginToken.token); - setMockMode(false); - - navigate('/'); - }, [authFlowUserEmail, internalPassword, navigate, setMockMode]); + }, [authFlowUserEmail, internalPassword, login, navigate, setMockMode]); useHotkeys( 'enter', () => { - userLogin(); + handleLogin(); }, { enableOnContentEditable: true, enableOnFormTags: true, }, - [userLogin], + [handleLogin], ); return ( @@ -118,7 +98,7 @@ export function PasswordLogin() { type="password" /> - + Continue diff --git a/front/src/pages/auth/Verify.tsx b/front/src/pages/auth/Verify.tsx index c67017135..f903bdb64 100644 --- a/front/src/pages/auth/Verify.tsx +++ b/front/src/pages/auth/Verify.tsx @@ -1,30 +1,33 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { getTokensFromLoginToken } from '@/auth/services/AuthService'; +import { useAuth } from '@/auth/hooks/useAuth'; +import { useIsLogged } from '@/auth/hooks/useIsLogged'; export function Verify() { const [searchParams] = useSearchParams(); - const [isLoading, setIsLoading] = useState(false); - const loginToken = searchParams.get('loginToken'); + + const isLogged = useIsLogged(); const navigate = useNavigate(); + const { verify } = useAuth(); + useEffect(() => { async function getTokens() { if (!loginToken) { return; } - setIsLoading(true); - await getTokensFromLoginToken(loginToken); - setIsLoading(false); + await verify(loginToken); navigate('/'); } - if (!isLoading) { + if (!isLogged) { getTokens(); } - }, [isLoading, navigate, loginToken]); + // Verify only needs to run once at mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return <>; } diff --git a/front/src/providers/AuthProvider.tsx b/front/src/providers/AuthProvider.tsx deleted file mode 100644 index 966e0612b..000000000 --- a/front/src/providers/AuthProvider.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useEffect } from 'react'; -import { useRecoilState } from 'recoil'; - -import { getUserIdFromToken } from '@/auth/services/AuthService'; -import { currentUserState } from '@/auth/states/currentUserState'; -import { isAuthenticatingState } from '@/auth/states/isAuthenticatingState'; -import { useGetCurrentUserQuery } from '@/users/services'; - -type OwnProps = { - children: JSX.Element; -}; - -export function AuthProvider({ children }: OwnProps) { - const [, setCurrentUser] = useRecoilState(currentUserState); - const [, setIsAuthenticating] = useRecoilState(isAuthenticatingState); - - const userIdFromToken = getUserIdFromToken(); - - const { data } = useGetCurrentUserQuery(userIdFromToken); - const user = data?.users?.[0]; - useEffect(() => { - if (user) { - setCurrentUser(user); - setIsAuthenticating(false); - } - }, [user, setCurrentUser, setIsAuthenticating]); - - return <>{children}; -} diff --git a/front/src/providers/apollo/ApolloProvider.tsx b/front/src/providers/apollo/ApolloProvider.tsx new file mode 100644 index 000000000..baceed4b8 --- /dev/null +++ b/front/src/providers/apollo/ApolloProvider.tsx @@ -0,0 +1,19 @@ +import { ApolloProvider as ApolloProviderBase } from '@apollo/client'; +import { useRecoilState } from 'recoil'; + +import { isMockModeState } from '@/auth/states/isMockModeState'; + +import { apolloClient } from './apollo-client'; +import { mockClient } from './mock-client'; + +export const ApolloProvider: React.FC = ({ + children, +}) => { + const [isMockMode] = useRecoilState(isMockModeState); + + return ( + + {children} + + ); +}; diff --git a/front/src/providers/apollo/apollo-client.ts b/front/src/providers/apollo/apollo-client.ts new file mode 100644 index 000000000..ec415b977 --- /dev/null +++ b/front/src/providers/apollo/apollo-client.ts @@ -0,0 +1,36 @@ +import { InMemoryCache } from '@apollo/client'; + +import { tokenService } from '@/auth/services/TokenService'; +import { CommentThreadTarget } from '~/generated/graphql'; + +import { ApolloFactory } from './apollo.factory'; + +const apollo = new ApolloFactory({ + uri: `${process.env.REACT_APP_API_URL}`, + cache: new InMemoryCache({ + typePolicies: { + CommentThread: { + fields: { + commentThreadTargets: { + merge( + existing: CommentThreadTarget[] = [], + incoming: CommentThreadTarget[], + ) { + return [...incoming]; + }, + }, + }, + }, + }, + }), + defaultOptions: { + query: { + fetchPolicy: 'cache-first', + }, + }, + onUnauthenticatedError() { + tokenService.removeTokenPair(); + }, +}); + +export const apolloClient = apollo.getClient(); diff --git a/front/src/providers/apollo/apollo.factory.ts b/front/src/providers/apollo/apollo.factory.ts new file mode 100644 index 000000000..0dcb0358a --- /dev/null +++ b/front/src/providers/apollo/apollo.factory.ts @@ -0,0 +1,167 @@ +/* eslint-disable no-loop-func */ +import { + ApolloClient, + ApolloClientOptions, + ApolloLink, + createHttpLink, + ServerError, + ServerParseError, +} from '@apollo/client'; +import { GraphQLErrors } from '@apollo/client/errors'; +import { setContext } from '@apollo/client/link/context'; +import { onError } from '@apollo/client/link/error'; +import { RetryLink } from '@apollo/client/link/retry'; +import { Observable } from '@apollo/client/utilities'; + +import { renewToken } from '@/auth/services/AuthService'; +import { tokenService } from '@/auth/services/TokenService'; + +import { assertNotNull } from '../../modules/utils/assert'; +import { promiseToObservable } from '../../modules/utils/promise-to-observable'; + +import { ApolloManager } from './interfaces/apollo-manager.interface'; +import { loggerLink } from './logger'; + +const logger = loggerLink(() => 'Twenty'); + +let isRefreshing = false; +let pendingRequests: (() => void)[] = []; + +const resolvePendingRequests = () => { + pendingRequests.map((callback) => callback()); + pendingRequests = []; +}; + +export interface Options extends ApolloClientOptions { + onError?: (err: GraphQLErrors | undefined) => void; + onNetworkError?: (err: Error | ServerParseError | ServerError) => void; + onUnauthenticatedError?: () => void; +} + +export class ApolloFactory implements ApolloManager { + private client: ApolloClient; + + constructor(opts: Options) { + const { + uri, + onError: onErrorCb, + onNetworkError, + onUnauthenticatedError, + ...options + } = opts; + + const buildApolloLink = (): ApolloLink => { + const httpLink = createHttpLink({ + uri, + }); + + const authLink = setContext(async (_, { headers }) => { + const credentials = tokenService.getTokenPair(); + + return { + headers: { + ...headers, + authorization: credentials?.accessToken + ? `Bearer ${credentials?.accessToken}` + : '', + }, + }; + }); + + const retryLink = new RetryLink({ + delay: { + initial: 100, + }, + attempts: { + max: 2, + retryIf: (error) => !!error, + }, + }); + + const errorLink = onError( + ({ graphQLErrors, networkError, forward, operation }) => { + if (graphQLErrors) { + onErrorCb?.(graphQLErrors); + + for (const graphQLError of graphQLErrors) { + switch (graphQLError?.extensions?.code) { + case 'UNAUTHENTICATED': { + // error code is set to UNAUTHENTICATED + // when AuthenticationError thrown in resolver + let forward$: Observable; + + if (!isRefreshing) { + isRefreshing = true; + forward$ = promiseToObservable( + renewToken(uri) + .then(() => { + resolvePendingRequests(); + return true; + }) + .catch(() => { + pendingRequests = []; + onUnauthenticatedError?.(); + return false; + }) + .finally(() => { + isRefreshing = false; + }), + ).filter((value) => Boolean(value)); + } else { + // Will only emit once the Promise is resolved + forward$ = promiseToObservable( + new Promise((resolve) => { + pendingRequests.push(() => resolve(true)); + }), + ); + } + + return forward$.flatMap(() => forward(operation)); + } + default: + if (process.env.NODE_ENV === 'development') { + console.warn( + `[GraphQL error]: Message: ${ + graphQLError.message + }, Location: ${ + graphQLError.locations + ? JSON.stringify(graphQLError.locations) + : graphQLError.locations + }, Path: ${graphQLError.path}`, + ); + } + } + } + } + + if (networkError) { + if (process.env.NODE_ENV === 'development') { + console.warn(`[Network error]: ${networkError}`); + } + onNetworkError?.(networkError); + } + }, + ); + + return ApolloLink.from( + [ + errorLink, + authLink, + // Only show logger in dev mode + process.env.NODE_ENV !== 'production' ? logger : null, + retryLink, + httpLink, + ].filter(assertNotNull), + ); + }; + + this.client = new ApolloClient({ + ...options, + link: buildApolloLink(), + }); + } + + getClient() { + return this.client; + } +} diff --git a/front/src/providers/apollo/interfaces/apollo-manager.interface.ts b/front/src/providers/apollo/interfaces/apollo-manager.interface.ts new file mode 100644 index 000000000..f996688e8 --- /dev/null +++ b/front/src/providers/apollo/interfaces/apollo-manager.interface.ts @@ -0,0 +1,5 @@ +import { ApolloClient } from '@apollo/client'; + +export interface ApolloManager { + getClient(): ApolloClient; +} diff --git a/front/src/providers/apollo/logger/format-title.ts b/front/src/providers/apollo/logger/format-title.ts new file mode 100644 index 000000000..7a09a23e0 --- /dev/null +++ b/front/src/providers/apollo/logger/format-title.ts @@ -0,0 +1,45 @@ +import { OperationType } from './operation-type'; + +const operationTypeColors = { + query: '#03A9F4', + mutation: '#61A600', + subscription: '#61A600', + error: '#F51818', + default: '#61A600', +}; + +const getOperationColor = (operationType: OperationType) => { + return operationTypeColors[operationType] ?? operationTypeColors.default; +}; + +const formatTitle = ( + operationType: OperationType, + schemaName: string, + queryName: string, + time: string | number, +) => { + const headerCss = [ + 'color: gray; font-weight: lighter', // title + `color: ${getOperationColor(operationType)}; font-weight: bold;`, // operationType + 'color: gray; font-weight: lighter;', // schemaName + 'color: black; font-weight: bold;', // queryName + ]; + + const parts = [ + '%c apollo', + `%c${operationType}`, + `%c${schemaName}::%c${queryName}`, + ]; + + if (operationType !== OperationType.Subscription) { + parts.push(`%c(in ${time} ms)`); + headerCss.push('color: gray; font-weight: lighter;'); // time + } else { + parts.push(`%c(@ ${time})`); + headerCss.push('color: gray; font-weight: lighter;'); // time + } + + return [parts.join(' '), ...headerCss]; +}; + +export default formatTitle; diff --git a/front/src/providers/apollo/logger/index.ts b/front/src/providers/apollo/logger/index.ts new file mode 100644 index 000000000..35ffbb509 --- /dev/null +++ b/front/src/providers/apollo/logger/index.ts @@ -0,0 +1,102 @@ +import { ApolloLink, gql, Operation } from '@apollo/client'; + +import formatTitle from './format-title'; + +const getGroup = (collapsed: boolean) => + collapsed + ? console.groupCollapsed.bind(console) + : console.group.bind(console); + +const parseQuery = (queryString: string) => { + const queryObj = gql` + ${queryString} + `; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { name } = queryObj.definitions[0] as any; + return [name ? name.value : 'Generic', queryString.trim()]; +}; + +export const loggerLink = (getSchemaName: (operation: Operation) => string) => + new ApolloLink((operation, forward) => { + const schemaName = getSchemaName(operation); + operation.setContext({ start: Date.now() }); + + const { variables } = operation; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const operationType = (operation.query.definitions[0] as any).operation; + const headers = operation.getContext().headers; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const [queryName, query] = parseQuery(operation.query.loc!.source.body); + + if (operationType === 'subscription') { + const date = new Date().toLocaleTimeString(); + + const titleArgs = formatTitle(operationType, schemaName, queryName, date); + + console.groupCollapsed(...titleArgs); + + if (variables && Object.keys(variables).length !== 0) { + console.log('VARIABLES', variables); + } + + console.log('QUERY', query); + + console.groupEnd(); + + return forward(operation); + } + + return forward(operation).map((result) => { + const time = Date.now() - operation.getContext().start; + const errors = result.errors ?? result.data?.[queryName]?.errors; + const hasError = Boolean(errors); + + try { + const titleArgs = formatTitle( + operationType, + schemaName, + queryName, + time, + ); + + getGroup(!hasError)(...titleArgs); + + if (errors) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + errors.forEach((err: any) => { + console.log( + `%c${err.message}`, + 'color: #F51818; font-weight: lighter', + ); + }); + } + + console.log('HEADERS: ', headers); + + if (variables && Object.keys(variables).length !== 0) { + console.log('VARIABLES', variables); + } + + console.log('QUERY', query); + + if (result.data) { + console.log('RESULT', result.data); + } + if (errors) { + console.log('ERRORS', errors); + } + + console.groupEnd(); + } catch { + // this may happen if console group is not supported + console.log( + `${operationType} ${schemaName}::${queryName} (in ${time} ms)`, + ); + if (errors) { + console.error(errors); + } + } + + return result; + }); + }); diff --git a/front/src/providers/apollo/logger/operation-type.ts b/front/src/providers/apollo/logger/operation-type.ts new file mode 100644 index 000000000..17fbf5720 --- /dev/null +++ b/front/src/providers/apollo/logger/operation-type.ts @@ -0,0 +1,6 @@ +export enum OperationType { + Query = 'query', + Mutation = 'mutation', + Subscription = 'subscription', + Error = 'error', +} diff --git a/front/src/providers/apollo/mock-client.ts b/front/src/providers/apollo/mock-client.ts new file mode 100644 index 000000000..adbf3756c --- /dev/null +++ b/front/src/providers/apollo/mock-client.ts @@ -0,0 +1,36 @@ +import { + ApolloClient, + ApolloLink, + createHttpLink, + from, + InMemoryCache, +} from '@apollo/client'; + +import { mockedCompaniesData } from '~/testing/mock-data/companies'; +import { mockedUsersData } from '~/testing/mock-data/users'; + +const apiLink = createHttpLink({ + uri: `${process.env.REACT_APP_API_URL}`, +}); + +const mockLink = new ApolloLink((operation, forward) => { + return forward(operation).map((response) => { + if (operation.operationName === 'GetCompanies') { + return { data: { companies: mockedCompaniesData } }; + } + if (operation.operationName === 'Verify') { + return { data: { user: [mockedUsersData[0]], tokens: {} } }; + } + return response; + }); +}); + +export const mockClient = new ApolloClient({ + link: from([mockLink, apiLink]), + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: 'cache-first', + }, + }, +}); diff --git a/front/src/providers/AppThemeProvider.tsx b/front/src/providers/theme/AppThemeProvider.tsx similarity index 100% rename from front/src/providers/AppThemeProvider.tsx rename to front/src/providers/theme/AppThemeProvider.tsx diff --git a/front/src/testing/renderWrappers.tsx b/front/src/testing/renderWrappers.tsx index 57cf84e72..d95a19d81 100644 --- a/front/src/testing/renderWrappers.tsx +++ b/front/src/testing/renderWrappers.tsx @@ -4,7 +4,6 @@ import { ApolloProvider } from '@apollo/client'; import { RecoilRoot } from 'recoil'; import { DefaultLayout } from '@/ui/layout/DefaultLayout'; -import { AuthProvider } from '~/providers/AuthProvider'; import { ComponentStorybookLayout } from './ComponentStorybookLayout'; import { FullHeightStorybookLayout } from './FullHeightStorybookLayout'; @@ -20,9 +19,7 @@ export function getRenderWrapperForPage( - - {children} - + {children} diff --git a/front/yarn.lock b/front/yarn.lock index d7b2ed1d5..dc3d5e5b5 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -4892,6 +4892,11 @@ jest-matcher-utils "^27.0.0" pretty-format "^27.0.0" +"@types/js-cookie@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.3.tgz#d6bfbbdd0c187354ca555213d1962f6d0691ff4e" + integrity sha512-Xe7IImK09HP1sv2M/aI+48a20VX+TdRJucfq4vfRVy6nWN8PYPOEnlMRSgxJAgYQIXJVL8dZ4/ilAM7dWNaOww== + "@types/js-levenshtein@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@types/js-levenshtein/-/js-levenshtein-1.1.1.tgz#ba05426a43f9e4e30b631941e0aa17bf0c890ed5" @@ -11937,6 +11942,11 @@ jose@^4.11.4: resolved "https://registry.yarnpkg.com/jose/-/jose-4.14.4.tgz#59e09204e2670c3164ee24cbfe7115c6f8bff9ca" integrity sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g== +js-cookie@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" + integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== + js-levenshtein@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" diff --git a/server/src/core/auth/auth.module.ts b/server/src/core/auth/auth.module.ts index 7b76a717d..91e116778 100644 --- a/server/src/core/auth/auth.module.ts +++ b/server/src/core/auth/auth.module.ts @@ -5,13 +5,12 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; import { AuthService } from './services/auth.service'; import { GoogleAuthController } from './controllers/google-auth.controller'; import { GoogleStrategy } from './strategies/google.auth.strategy'; -import { TokenController } from './controllers/token.controller'; import { PrismaService } from 'src/database/prisma.service'; import { UserModule } from '../user/user.module'; -import { AuthController } from './controllers/auth.controller'; -import { PasswordAuthController } from './controllers/password-auth.controller'; +import { VerifyAuthController } from './controllers/verify-auth.controller'; import { TokenService } from './services/token.service'; +import { AuthResolver } from './auth.resolver'; const jwtModule = JwtModule.registerAsync({ useFactory: async (configService: ConfigService) => { @@ -28,18 +27,14 @@ const jwtModule = JwtModule.registerAsync({ @Module({ imports: [jwtModule, ConfigModule.forRoot({}), UserModule], - controllers: [ - GoogleAuthController, - PasswordAuthController, - TokenController, - AuthController, - ], + controllers: [GoogleAuthController, VerifyAuthController], providers: [ AuthService, TokenService, JwtAuthStrategy, GoogleStrategy, PrismaService, + AuthResolver, ], exports: [jwtModule], }) diff --git a/server/src/core/auth/auth.resolver.spec.ts b/server/src/core/auth/auth.resolver.spec.ts new file mode 100644 index 000000000..0c9ae4bc7 --- /dev/null +++ b/server/src/core/auth/auth.resolver.spec.ts @@ -0,0 +1,30 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthResolver } from './auth.resolver'; +import { TokenService } from './services/token.service'; +import { AuthService } from './services/auth.service'; + +describe('AuthResolver', () => { + let resolver: AuthResolver; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthResolver, + { + provide: AuthService, + useValue: {}, + }, + { + provide: TokenService, + useValue: {}, + }, + ], + }).compile(); + + resolver = module.get(AuthResolver); + }); + + it('should be defined', () => { + expect(resolver).toBeDefined(); + }); +}); diff --git a/server/src/core/auth/auth.resolver.ts b/server/src/core/auth/auth.resolver.ts new file mode 100644 index 000000000..f2ff3e212 --- /dev/null +++ b/server/src/core/auth/auth.resolver.ts @@ -0,0 +1,49 @@ +import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { AuthTokens } from './dto/token.entity'; +import { TokenService } from './services/token.service'; +import { RefreshTokenInput } from './dto/refresh-token.input'; +import { BadRequestException } from '@nestjs/common'; +import { Verify } from './dto/verify.entity'; +import { VerifyInput } from './dto/verify.input'; +import { AuthService } from './services/auth.service'; +import { LoginToken } from './dto/login-token.entity'; +import { ChallengeInput } from './dto/challenge.input'; + +@Resolver() +export class AuthResolver { + constructor( + private authService: AuthService, + private tokenService: TokenService, + ) {} + + @Mutation(() => LoginToken) + async challenge(@Args() challengeInput: ChallengeInput): Promise { + const user = await this.authService.challenge(challengeInput); + const loginToken = await this.tokenService.generateLoginToken(user.email); + + return { loginToken }; + } + + @Mutation(() => Verify) + async verify(@Args() verifyInput: VerifyInput): Promise { + const email = await this.tokenService.verifyLoginToken( + verifyInput.loginToken, + ); + const result = await this.authService.verify(email); + + return result; + } + + @Mutation(() => AuthTokens) + async renewToken(@Args() args: RefreshTokenInput): Promise { + if (!args.refreshToken) { + throw new BadRequestException('Refresh token is mendatory'); + } + + const tokens = await this.tokenService.generateTokensFromRefreshToken( + args.refreshToken, + ); + + return { tokens: tokens }; + } +} diff --git a/server/src/core/auth/controllers/password-auth.controller.spec.ts b/server/src/core/auth/controllers/password-auth.controller.spec.ts deleted file mode 100644 index a3f87709d..000000000 --- a/server/src/core/auth/controllers/password-auth.controller.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PasswordAuthController } from './password-auth.controller'; -import { AuthService } from '../services/auth.service'; -import { TokenService } from '../services/token.service'; - -describe('PasswordAuthController', () => { - let controller: PasswordAuthController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [PasswordAuthController], - providers: [ - { - provide: AuthService, - useValue: {}, - }, - { - provide: TokenService, - useValue: {}, - }, - ], - }).compile(); - - controller = module.get(PasswordAuthController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/server/src/core/auth/controllers/password-auth.controller.ts b/server/src/core/auth/controllers/password-auth.controller.ts deleted file mode 100644 index ead3351bd..000000000 --- a/server/src/core/auth/controllers/password-auth.controller.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Body, Controller, Post } from '@nestjs/common'; -import { ChallengeInput } from '../dto/challenge.input'; -import { AuthService } from '../services/auth.service'; -import { LoginTokenEntity } from '../dto/login-token.entity'; -import { TokenService } from '../services/token.service'; - -@Controller('auth/password') -export class PasswordAuthController { - constructor( - private readonly authService: AuthService, - private readonly tokenService: TokenService, - ) {} - - @Post() - async challenge( - @Body() challengeInput: ChallengeInput, - ): Promise { - const user = await this.authService.challenge(challengeInput); - const loginToken = await this.tokenService.generateLoginToken(user.email); - - return { loginToken }; - } -} diff --git a/server/src/core/auth/controllers/token.controller.ts b/server/src/core/auth/controllers/token.controller.ts deleted file mode 100644 index 3b813bef1..000000000 --- a/server/src/core/auth/controllers/token.controller.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { BadRequestException, Body, Controller, Post } from '@nestjs/common'; -import { RefreshTokenInput } from '../dto/refresh-token.input'; -import { TokenService } from '../services/token.service'; - -@Controller('auth/token') -export class TokenController { - constructor(private tokenService: TokenService) {} - - @Post() - async generateAccessToken(@Body() body: RefreshTokenInput) { - if (!body.refreshToken) { - throw new BadRequestException('Refresh token is mendatory'); - } - - const tokens = await this.tokenService.generateTokensFromRefreshToken( - body.refreshToken, - ); - - return { tokens: tokens }; - } -} diff --git a/server/src/core/auth/controllers/auth.controller.spec.ts b/server/src/core/auth/controllers/verify-auth.controller.spec.ts similarity index 67% rename from server/src/core/auth/controllers/auth.controller.spec.ts rename to server/src/core/auth/controllers/verify-auth.controller.spec.ts index 7874c788d..b5d989a34 100644 --- a/server/src/core/auth/controllers/auth.controller.spec.ts +++ b/server/src/core/auth/controllers/verify-auth.controller.spec.ts @@ -1,14 +1,14 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { AuthController } from './auth.controller'; +import { VerifyAuthController } from './verify-auth.controller'; import { AuthService } from '../services/auth.service'; import { TokenService } from '../services/token.service'; -describe('AuthController', () => { - let controller: AuthController; +describe('VerifyAuthController', () => { + let controller: VerifyAuthController; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - controllers: [AuthController], + controllers: [VerifyAuthController], providers: [ { provide: AuthService, @@ -21,7 +21,7 @@ describe('AuthController', () => { ], }).compile(); - controller = module.get(AuthController); + controller = module.get(VerifyAuthController); }); it('should be defined', () => { diff --git a/server/src/core/auth/controllers/auth.controller.ts b/server/src/core/auth/controllers/verify-auth.controller.ts similarity index 81% rename from server/src/core/auth/controllers/auth.controller.ts rename to server/src/core/auth/controllers/verify-auth.controller.ts index 2dbe0c5a7..92a0ead6b 100644 --- a/server/src/core/auth/controllers/auth.controller.ts +++ b/server/src/core/auth/controllers/verify-auth.controller.ts @@ -1,18 +1,18 @@ import { Body, Controller, Post } from '@nestjs/common'; import { AuthService } from '../services/auth.service'; import { VerifyInput } from '../dto/verify.input'; -import { VerifyEntity } from '../dto/verify.entity'; +import { Verify } from '../dto/verify.entity'; import { TokenService } from '../services/token.service'; -@Controller('auth') -export class AuthController { +@Controller('auth/verify') +export class VerifyAuthController { constructor( private readonly authService: AuthService, private readonly tokenService: TokenService, ) {} - @Post('verify') - async verify(@Body() verifyInput: VerifyInput): Promise { + @Post() + async verify(@Body() verifyInput: VerifyInput): Promise { const email = await this.tokenService.verifyLoginToken( verifyInput.loginToken, ); diff --git a/server/src/core/auth/dto/challenge.input.ts b/server/src/core/auth/dto/challenge.input.ts index 2254fc0a4..8c6dc82f0 100644 --- a/server/src/core/auth/dto/challenge.input.ts +++ b/server/src/core/auth/dto/challenge.input.ts @@ -1,10 +1,14 @@ +import { ArgsType, Field } from '@nestjs/graphql'; import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; +@ArgsType() export class ChallengeInput { + @Field(() => String) @IsNotEmpty() @IsEmail() email: string; + @Field(() => String) @IsNotEmpty() @IsString() password: string; diff --git a/server/src/core/auth/dto/login-token.entity.ts b/server/src/core/auth/dto/login-token.entity.ts index 713210a31..93dde970e 100644 --- a/server/src/core/auth/dto/login-token.entity.ts +++ b/server/src/core/auth/dto/login-token.entity.ts @@ -1,5 +1,8 @@ -import { TokenEntity } from './token.entity'; +import { Field, ObjectType } from '@nestjs/graphql'; +import { AuthToken } from './token.entity'; -export class LoginTokenEntity { - loginToken: TokenEntity; +@ObjectType() +export class LoginToken { + @Field(() => AuthToken) + loginToken: AuthToken; } diff --git a/server/src/core/auth/dto/refresh-token.input.ts b/server/src/core/auth/dto/refresh-token.input.ts index 14a66a530..ebd1aeb94 100644 --- a/server/src/core/auth/dto/refresh-token.input.ts +++ b/server/src/core/auth/dto/refresh-token.input.ts @@ -1,6 +1,9 @@ +import { ArgsType, Field } from '@nestjs/graphql'; import { IsNotEmpty, IsString } from 'class-validator'; +@ArgsType() export class RefreshTokenInput { + @Field(() => String) @IsNotEmpty() @IsString() refreshToken: string; diff --git a/server/src/core/auth/dto/register.input.ts b/server/src/core/auth/dto/register.input.ts index ab872497c..ab7a5a683 100644 --- a/server/src/core/auth/dto/register.input.ts +++ b/server/src/core/auth/dto/register.input.ts @@ -6,18 +6,23 @@ import { MinLength, } from 'class-validator'; import { PASSWORD_REGEX } from '../auth.util'; +import { ArgsType, Field } from '@nestjs/graphql'; +@ArgsType() export class RegisterInput { + @Field(() => String) @IsNotEmpty() @IsEmail() email: string; + @Field(() => String) @IsNotEmpty() @IsString() @MinLength(8) @Matches(PASSWORD_REGEX, { message: 'password too weak' }) password: string; + @Field(() => String) @IsNotEmpty() @IsString() displayName: string; diff --git a/server/src/core/auth/dto/token.entity.ts b/server/src/core/auth/dto/token.entity.ts index ce45e2c11..a1b8b6f7a 100644 --- a/server/src/core/auth/dto/token.entity.ts +++ b/server/src/core/auth/dto/token.entity.ts @@ -1,4 +1,25 @@ -export class TokenEntity { +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class AuthToken { + @Field(() => String) token: string; + + @Field(() => Date) expiresAt: Date; } + +@ObjectType() +export class AuthTokenPair { + @Field(() => AuthToken) + accessToken: AuthToken; + + @Field(() => AuthToken) + refreshToken: AuthToken; +} + +@ObjectType() +export class AuthTokens { + @Field(() => AuthTokenPair) + tokens: AuthTokenPair; +} diff --git a/server/src/core/auth/dto/verify.entity.ts b/server/src/core/auth/dto/verify.entity.ts index 3bdccc2a4..087de7eb7 100644 --- a/server/src/core/auth/dto/verify.entity.ts +++ b/server/src/core/auth/dto/verify.entity.ts @@ -1,11 +1,9 @@ -import { TokenEntity } from './token.entity'; -import { User } from '@prisma/client'; +import { Field, ObjectType } from '@nestjs/graphql'; +import { AuthTokens } from './token.entity'; +import { User } from 'src/core/@generated/user/user.model'; -export class VerifyEntity { - user: Omit; - - tokens: { - accessToken: TokenEntity; - refreshToken: TokenEntity; - }; +@ObjectType() +export class Verify extends AuthTokens { + @Field(() => User) + user: User; } diff --git a/server/src/core/auth/dto/verify.input.ts b/server/src/core/auth/dto/verify.input.ts index 506a3b297..635f2d8c7 100644 --- a/server/src/core/auth/dto/verify.input.ts +++ b/server/src/core/auth/dto/verify.input.ts @@ -1,6 +1,9 @@ +import { ArgsType, Field } from '@nestjs/graphql'; import { IsNotEmpty, IsString } from 'class-validator'; +@ArgsType() export class VerifyInput { + @Field(() => String) @IsNotEmpty() @IsString() loginToken: string; diff --git a/server/src/core/auth/services/auth.service.ts b/server/src/core/auth/services/auth.service.ts index 3b4343660..0a4c86f36 100644 --- a/server/src/core/auth/services/auth.service.ts +++ b/server/src/core/auth/services/auth.service.ts @@ -9,7 +9,7 @@ import { UserService } from 'src/core/user/user.service'; import { assert } from 'src/utils/assert'; import { RegisterInput } from '../dto/register.input'; import { PASSWORD_REGEX, compareHash, hashPassword } from '../auth.util'; -import { VerifyEntity } from '../dto/verify.entity'; +import { Verify } from '../dto/verify.entity'; import { TokenService } from './token.service'; export type UserPayload = { @@ -73,17 +73,17 @@ export class AuthService { return user; } - async verify(email: string): Promise { - const data = await this.userService.findUnique({ + async verify(email: string): Promise { + const user = await this.userService.findUnique({ where: { email, }, }); - assert(data, "This user doesn't exist", NotFoundException); + assert(user, "This user doesn't exist", NotFoundException); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { passwordHash: _, ...user } = data; + // passwordHash is hidden for security reasons + user.passwordHash = ''; const accessToken = await this.tokenService.generateAccessToken(user.id); const refreshToken = await this.tokenService.generateRefreshToken(user.id); diff --git a/server/src/core/auth/services/token.service.ts b/server/src/core/auth/services/token.service.ts index ce82bbee7..579b20ac0 100644 --- a/server/src/core/auth/services/token.service.ts +++ b/server/src/core/auth/services/token.service.ts @@ -13,7 +13,7 @@ import { PrismaService } from 'src/database/prisma.service'; import { assert } from 'src/utils/assert'; import { addMilliseconds } from 'date-fns'; import ms from 'ms'; -import { TokenEntity } from '../dto/token.entity'; +import { AuthToken } from '../dto/token.entity'; import { TokenExpiredError } from 'jsonwebtoken'; @Injectable() @@ -24,7 +24,7 @@ export class TokenService { private readonly prismaService: PrismaService, ) {} - async generateAccessToken(userId: string): Promise { + async generateAccessToken(userId: string): Promise { const expiresIn = this.configService.get('ACCESS_TOKEN_EXPIRES_IN'); assert(expiresIn, '', InternalServerErrorException); const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); @@ -55,7 +55,7 @@ export class TokenService { }; } - async generateRefreshToken(userId: string): Promise { + async generateRefreshToken(userId: string): Promise { const secret = this.configService.get('REFRESH_TOKEN_SECRET'); const expiresIn = this.configService.get( 'REFRESH_TOKEN_EXPIRES_IN', @@ -86,7 +86,7 @@ export class TokenService { }; } - async generateLoginToken(email: string): Promise { + async generateLoginToken(email: string): Promise { const secret = this.configService.get('LOGIN_TOKEN_SECRET'); const expiresIn = this.configService.get('LOGIN_TOKEN_EXPIRES_IN'); assert(expiresIn, '', InternalServerErrorException); @@ -163,8 +163,8 @@ export class TokenService { } async generateTokensFromRefreshToken(token: string): Promise<{ - accessToken: TokenEntity; - refreshToken: TokenEntity; + accessToken: AuthToken; + refreshToken: AuthToken; }> { const { user, diff --git a/server/src/core/workspace/workspace.module.ts b/server/src/core/workspace/workspace.module.ts index ebddae64d..fd60a4b9c 100644 --- a/server/src/core/workspace/workspace.module.ts +++ b/server/src/core/workspace/workspace.module.ts @@ -1,14 +1,9 @@ import { Module } from '@nestjs/common'; import { WorkspaceService } from './services/workspace.service'; import { WorkspaceMemberService } from './services/workspace-member.service'; -import { WorkspaceMemberResolver } from './resolvers/workspace-member.resolver'; @Module({ - providers: [ - WorkspaceService, - WorkspaceMemberService, - WorkspaceMemberResolver, - ], + providers: [WorkspaceService, WorkspaceMemberService], exports: [WorkspaceService, WorkspaceMemberService], }) export class WorkspaceModule {}