diff --git a/packages/hoppscotch-backend/src/auth/auth.controller.ts b/packages/hoppscotch-backend/src/auth/auth.controller.ts index f861c02a0..115f4fe29 100644 --- a/packages/hoppscotch-backend/src/auth/auth.controller.ts +++ b/packages/hoppscotch-backend/src/auth/auth.controller.ts @@ -11,15 +11,15 @@ import { } from '@nestjs/common'; import { AuthService } from './auth.service'; import { SignInMagicDto } from './dto/signin-magic.dto'; -import { verifyMagicDto } from './dto/verify-magic.dto'; +import { VerifyMagicDto } from './dto/verify-magic.dto'; import { Response } from 'express'; import * as E from 'fp-ts/Either'; -import { authCookieHandler, throwHTTPErr } from 'src/utils'; import { RTJwtAuthGuard } from './guards/rt-jwt-auth.guard'; import { GqlUser } from 'src/decorators/gql-user.decorator'; import { AuthUser } from 'src/types/AuthUser'; import { RTCookie } from 'src/decorators/rt-cookie.decorator'; import { AuthGuard } from '@nestjs/passport'; +import { authCookieHandler, throwHTTPErr } from './helper'; @Controller('/v1/auth') export class AuthController { @@ -41,7 +41,7 @@ export class AuthController { ** Route to verify and sign in a valid user via magic-link */ @Post('verify') - async verify(@Body() data: verifyMagicDto, @Res() res: Response) { + async verify(@Body() data: VerifyMagicDto, @Res() res: Response) { const authTokens = await this.authService.verifyMagicLinkTokens(data); if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); authCookieHandler(res, authTokens.right, false); diff --git a/packages/hoppscotch-backend/src/auth/helper.ts b/packages/hoppscotch-backend/src/auth/helper.ts new file mode 100644 index 000000000..43c3ab94d --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/helper.ts @@ -0,0 +1,56 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { DateTime } from 'luxon'; +import { AuthError } from 'src/types/AuthError'; +import { AuthTokens } from 'src/types/AuthTokens'; +import { Response } from 'express'; + +/** + * This function allows throw to be used as an expression + * @param errMessage Message present in the error message + */ +export function throwHTTPErr(errorData: AuthError): never { + const { message, statusCode } = errorData; + throw new HttpException(message, statusCode); +} + +/** + * Sets and returns the cookies in the response object on successful authentication + * @param res Express Response Object + * @param authTokens Object containing the access and refresh tokens + * @param redirect if true will redirect to provided URL else just send a 200 status code + */ +export const authCookieHandler = ( + res: Response, + authTokens: AuthTokens, + redirect: boolean, +) => { + const currentTime = DateTime.now(); + const accessTokenValidity = currentTime + .plus({ + milliseconds: parseInt(process.env.ACCESS_TOKEN_VALIDITY), + }) + .toMillis(); + const refreshTokenValidity = currentTime + .plus({ + milliseconds: parseInt(process.env.REFRESH_TOKEN_VALIDITY), + }) + .toMillis(); + + res.cookie('access_token', authTokens.access_token, { + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: accessTokenValidity, + signed: true, + }); + res.cookie('refresh_token', authTokens.refresh_token, { + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: refreshTokenValidity, + signed: true, + }); + if (redirect) { + res.status(HttpStatus.OK).redirect(process.env.REDIRECT_URL); + } else res.status(HttpStatus.OK).send(); +}; diff --git a/packages/hoppscotch-backend/src/user/user.service.spec.ts b/packages/hoppscotch-backend/src/user/user.service.spec.ts index 972faddd7..a85830094 100644 --- a/packages/hoppscotch-backend/src/user/user.service.spec.ts +++ b/packages/hoppscotch-backend/src/user/user.service.spec.ts @@ -40,274 +40,276 @@ beforeEach(() => { mockPubSub.publish.mockClear(); }); -describe('findUserByEmail', () => { - test('should successfully return a valid user given a valid email', async () => { - mockPrisma.user.findUniqueOrThrow.mockResolvedValueOnce(user); +describe('UserService', () => { + describe('findUserByEmail', () => { + test('should successfully return a valid user given a valid email', async () => { + mockPrisma.user.findUniqueOrThrow.mockResolvedValueOnce(user); - const result = await userService.findUserByEmail( - 'dwight@dundermifflin.com', - ); - expect(result).toEqualSome(user); - }); - - test('should return a null user given a invalid email', async () => { - mockPrisma.user.findUniqueOrThrow.mockRejectedValueOnce('NotFoundError'); - - const result = await userService.findUserByEmail('jim@dundermifflin.com'); - expect(result).resolves.toBeNone; - }); -}); - -describe('findUserById', () => { - test('should successfully return a valid user given a valid user uid', async () => { - mockPrisma.user.findUniqueOrThrow.mockResolvedValueOnce(user); - - const result = await userService.findUserById('123344'); - expect(result).toEqualSome(user); - }); - - test('should return a null user given a invalid user uid', async () => { - mockPrisma.user.findUniqueOrThrow.mockRejectedValueOnce('NotFoundError'); - - const result = await userService.findUserById('sdcvbdbr'); - expect(result).resolves.toBeNone; - }); -}); - -describe('createUserViaMagicLink', () => { - test('should successfully create user and account for magic-link given valid inputs', async () => { - mockPrisma.user.create.mockResolvedValueOnce(user); - - const result = await userService.createUserViaMagicLink( - 'dwight@dundermifflin.com', - ); - expect(result).toEqual(user); - }); -}); - -describe('createUserSSO', () => { - test('should successfully create user and account for SSO provider given valid inputs ', async () => { - mockPrisma.user.create.mockResolvedValueOnce(user); - - const result = await userService.createUserSSO( - 'sdcsdcsdc', - 'dscsdc', - exampleSSOProfileData, - ); - expect(result).toEqual(user); - }); - - test('should successfully create user and account for SSO provider given no displayName ', async () => { - mockPrisma.user.create.mockResolvedValueOnce({ - ...user, - displayName: null, + const result = await userService.findUserByEmail( + 'dwight@dundermifflin.com', + ); + expect(result).toEqualSome(user); }); - const result = await userService.createUserSSO('sdcsdcsdc', 'dscsdc', { - ...exampleSSOProfileData, - displayName: null, - }); + test('should return a null user given a invalid email', async () => { + mockPrisma.user.findUniqueOrThrow.mockRejectedValueOnce('NotFoundError'); - expect(result).toEqual({ - ...user, - displayName: null, + const result = await userService.findUserByEmail('jim@dundermifflin.com'); + expect(result).resolves.toBeNone; }); }); - test('should successfully create user and account for SSO provider given no photoURL ', async () => { - mockPrisma.user.create.mockResolvedValueOnce({ - ...user, - photoURL: null, + describe('findUserById', () => { + test('should successfully return a valid user given a valid user uid', async () => { + mockPrisma.user.findUniqueOrThrow.mockResolvedValueOnce(user); + + const result = await userService.findUserById('123344'); + expect(result).toEqualSome(user); }); - const result = await userService.createUserSSO('sdcsdcsdc', 'dscsdc', { - ...exampleSSOProfileData, - photoURL: null, - }); + test('should return a null user given a invalid user uid', async () => { + mockPrisma.user.findUniqueOrThrow.mockRejectedValueOnce('NotFoundError'); - expect(result).toEqual({ - ...user, - photoURL: null, - }); - }); -}); - -describe('createProviderAccount', () => { - test('should successfully create ProviderAccount for user given valid inputs ', async () => { - mockPrisma.account.create.mockResolvedValueOnce({ - id: '123dcdc', - userId: user.uid, - provider: exampleSSOProfileData.provider, - providerAccountId: exampleSSOProfileData.id, - providerRefreshToken: 'dscsdc', - providerAccessToken: 'sdcsdcsdc', - providerScope: 'user.email', - loggedIn: currentTime, - }); - - const result = await userService.createProviderAccount( - user, - 'sdcsdcsdc', - 'dscsdc', - exampleSSOProfileData, - ); - expect(result).toEqual({ - id: '123dcdc', - userId: user.uid, - provider: exampleSSOProfileData.provider, - providerAccountId: exampleSSOProfileData.id, - providerRefreshToken: 'dscsdc', - providerAccessToken: 'sdcsdcsdc', - providerScope: 'user.email', - loggedIn: currentTime, + const result = await userService.findUserById('sdcvbdbr'); + expect(result).resolves.toBeNone; }); }); - test('should successfully create ProviderAccount for user given no accessToken ', async () => { - mockPrisma.account.create.mockResolvedValueOnce({ - id: '123dcdc', - userId: user.uid, - provider: exampleSSOProfileData.provider, - providerAccountId: exampleSSOProfileData.id, - providerRefreshToken: 'dscsdc', - providerAccessToken: null, - providerScope: 'user.email', - loggedIn: currentTime, - }); + describe('createUserViaMagicLink', () => { + test('should successfully create user and account for magic-link given valid inputs', async () => { + mockPrisma.user.create.mockResolvedValueOnce(user); - const result = await userService.createProviderAccount( - user, - 'sdcsdcsdc', - 'dscsdc', - exampleSSOProfileData, - ); - expect(result).toEqual({ - id: '123dcdc', - userId: user.uid, - provider: exampleSSOProfileData.provider, - providerAccountId: exampleSSOProfileData.id, - providerRefreshToken: 'dscsdc', - providerAccessToken: null, - providerScope: 'user.email', - loggedIn: currentTime, + const result = await userService.createUserViaMagicLink( + 'dwight@dundermifflin.com', + ); + expect(result).toEqual(user); }); }); - test('should successfully create ProviderAccount for user given no refreshToken', async () => { - mockPrisma.account.create.mockResolvedValueOnce({ - id: '123dcdc', - userId: user.uid, - provider: exampleSSOProfileData.provider, - providerAccountId: exampleSSOProfileData.id, - providerRefreshToken: null, - providerAccessToken: 'sdcsdcsdc', - providerScope: 'user.email', - loggedIn: currentTime, + describe('createUserSSO', () => { + test('should successfully create user and account for SSO provider given valid inputs ', async () => { + mockPrisma.user.create.mockResolvedValueOnce(user); + + const result = await userService.createUserSSO( + 'sdcsdcsdc', + 'dscsdc', + exampleSSOProfileData, + ); + expect(result).toEqual(user); }); - const result = await userService.createProviderAccount( - user, - 'sdcsdcsdc', - 'dscsdc', - exampleSSOProfileData, - ); - expect(result).toEqual({ - id: '123dcdc', - userId: user.uid, - provider: exampleSSOProfileData.provider, - providerAccountId: exampleSSOProfileData.id, - providerRefreshToken: null, - providerAccessToken: 'sdcsdcsdc', - providerScope: 'user.email', - loggedIn: currentTime, - }); - }); -}); - -describe('updateUserSessions', () => { - test('Should resolve right and update users GQL session', async () => { - const sessionData = user.currentGQLSession; - - mockPrisma.user.update.mockResolvedValue({ - ...user, - currentGQLSession: sessionData, - currentRESTSession: null, - }); - - const result = await userService.updateUserSessions( - user, - JSON.stringify(sessionData), - 'GQL', - ); - - expect(result).toEqualRight({ - ...user, - currentGQLSession: JSON.stringify(sessionData), - currentRESTSession: null, - }); - }); - test('Should resolve right and update users REST session', async () => { - const sessionData = user.currentGQLSession; - mockPrisma.user.update.mockResolvedValue({ - ...user, - currentGQLSession: null, - currentRESTSession: sessionData, - }); - - const result = await userService.updateUserSessions( - user, - JSON.stringify(sessionData), - 'REST', - ); - - expect(result).toEqualRight({ - ...user, - currentGQLSession: null, - currentRESTSession: JSON.stringify(sessionData), - }); - }); - test('Should reject left and update user for invalid GQL session', async () => { - const sessionData = 'invalid json'; - - const result = await userService.updateUserSessions( - user, - sessionData, - 'GQL', - ); - - expect(result).toEqualLeft(JSON_INVALID); - }); - test('Should reject left and update user for invalid REST session', async () => { - const sessionData = 'invalid json'; - - const result = await userService.updateUserSessions( - user, - sessionData, - 'REST', - ); - - expect(result).toEqualLeft(JSON_INVALID); - }); - - test('Should publish pubsub message on user update sessions', async () => { - mockPrisma.user.update.mockResolvedValue({ - ...user, - }); - - await userService.updateUserSessions( - user, - JSON.stringify(user.currentGQLSession), - 'GQL', - ); - - expect(mockPubSub.publish).toHaveBeenCalledTimes(1); - expect(mockPubSub.publish).toHaveBeenCalledWith( - `user/${user.uid}/updated`, - { + test('should successfully create user and account for SSO provider given no displayName ', async () => { + mockPrisma.user.create.mockResolvedValueOnce({ ...user, - currentGQLSession: JSON.stringify(user.currentGQLSession), - currentRESTSession: JSON.stringify(user.currentRESTSession), - }, - ); + displayName: null, + }); + + const result = await userService.createUserSSO('sdcsdcsdc', 'dscsdc', { + ...exampleSSOProfileData, + displayName: null, + }); + + expect(result).toEqual({ + ...user, + displayName: null, + }); + }); + + test('should successfully create user and account for SSO provider given no photoURL ', async () => { + mockPrisma.user.create.mockResolvedValueOnce({ + ...user, + photoURL: null, + }); + + const result = await userService.createUserSSO('sdcsdcsdc', 'dscsdc', { + ...exampleSSOProfileData, + photoURL: null, + }); + + expect(result).toEqual({ + ...user, + photoURL: null, + }); + }); + }); + + describe('createProviderAccount', () => { + test('should successfully create ProviderAccount for user given valid inputs ', async () => { + mockPrisma.account.create.mockResolvedValueOnce({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: 'dscsdc', + providerAccessToken: 'sdcsdcsdc', + providerScope: 'user.email', + loggedIn: currentTime, + }); + + const result = await userService.createProviderAccount( + user, + 'sdcsdcsdc', + 'dscsdc', + exampleSSOProfileData, + ); + expect(result).toEqual({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: 'dscsdc', + providerAccessToken: 'sdcsdcsdc', + providerScope: 'user.email', + loggedIn: currentTime, + }); + }); + + test('should successfully create ProviderAccount for user given no accessToken ', async () => { + mockPrisma.account.create.mockResolvedValueOnce({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: 'dscsdc', + providerAccessToken: null, + providerScope: 'user.email', + loggedIn: currentTime, + }); + + const result = await userService.createProviderAccount( + user, + 'sdcsdcsdc', + 'dscsdc', + exampleSSOProfileData, + ); + expect(result).toEqual({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: 'dscsdc', + providerAccessToken: null, + providerScope: 'user.email', + loggedIn: currentTime, + }); + }); + + test('should successfully create ProviderAccount for user given no refreshToken', async () => { + mockPrisma.account.create.mockResolvedValueOnce({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: null, + providerAccessToken: 'sdcsdcsdc', + providerScope: 'user.email', + loggedIn: currentTime, + }); + + const result = await userService.createProviderAccount( + user, + 'sdcsdcsdc', + 'dscsdc', + exampleSSOProfileData, + ); + expect(result).toEqual({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: null, + providerAccessToken: 'sdcsdcsdc', + providerScope: 'user.email', + loggedIn: currentTime, + }); + }); + }); + + describe('updateUserSessions', () => { + test('Should resolve right and update users GQL session', async () => { + const sessionData = user.currentGQLSession; + + mockPrisma.user.update.mockResolvedValue({ + ...user, + currentGQLSession: sessionData, + currentRESTSession: null, + }); + + const result = await userService.updateUserSessions( + user, + JSON.stringify(sessionData), + 'GQL', + ); + + expect(result).toEqualRight({ + ...user, + currentGQLSession: JSON.stringify(sessionData), + currentRESTSession: null, + }); + }); + test('Should resolve right and update users REST session', async () => { + const sessionData = user.currentGQLSession; + mockPrisma.user.update.mockResolvedValue({ + ...user, + currentGQLSession: null, + currentRESTSession: sessionData, + }); + + const result = await userService.updateUserSessions( + user, + JSON.stringify(sessionData), + 'REST', + ); + + expect(result).toEqualRight({ + ...user, + currentGQLSession: null, + currentRESTSession: JSON.stringify(sessionData), + }); + }); + test('Should reject left and update user for invalid GQL session', async () => { + const sessionData = 'invalid json'; + + const result = await userService.updateUserSessions( + user, + sessionData, + 'GQL', + ); + + expect(result).toEqualLeft(JSON_INVALID); + }); + test('Should reject left and update user for invalid REST session', async () => { + const sessionData = 'invalid json'; + + const result = await userService.updateUserSessions( + user, + sessionData, + 'REST', + ); + + expect(result).toEqualLeft(JSON_INVALID); + }); + + test('Should publish pubsub message on user update sessions', async () => { + mockPrisma.user.update.mockResolvedValue({ + ...user, + }); + + await userService.updateUserSessions( + user, + JSON.stringify(user.currentGQLSession), + 'GQL', + ); + + expect(mockPubSub.publish).toHaveBeenCalledTimes(1); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user/${user.uid}/updated`, + { + ...user, + currentGQLSession: JSON.stringify(user.currentGQLSession), + currentRESTSession: JSON.stringify(user.currentRESTSession), + }, + ); + }); }); }); diff --git a/packages/hoppscotch-backend/src/utils.ts b/packages/hoppscotch-backend/src/utils.ts index bbd3e8928..7deafd76b 100644 --- a/packages/hoppscotch-backend/src/utils.ts +++ b/packages/hoppscotch-backend/src/utils.ts @@ -1,4 +1,4 @@ -import { ExecutionContext, HttpException, HttpStatus } from '@nestjs/common'; +import { ExecutionContext } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; import { pipe } from 'fp-ts/lib/function'; import * as O from 'fp-ts/Option'; @@ -7,10 +7,6 @@ import * as T from 'fp-ts/Task'; import * as E from 'fp-ts/Either'; import { User } from './user/user.model'; import * as A from 'fp-ts/Array'; -import { AuthError } from './types/AuthError'; -import { AuthTokens } from './types/AuthTokens'; -import { Response } from 'express'; -import { DateTime } from 'luxon'; import { JSON_INVALID } from './errors'; /** @@ -23,15 +19,6 @@ export function throwErr(errMessage: string): never { throw new Error(errMessage); } -/** - * This function allows throw to be used as an expression - * @param errMessage Message present in the error message - */ -export function throwHTTPErr(errorData: AuthError): never { - const { message, statusCode } = errorData; - throw new HttpException(message, statusCode); -} - /** * Prints the given value to log and returns the same value. * Used for debugging functional pipelines. @@ -136,48 +123,6 @@ export const validateEmail = (email: string) => { ).test(email); }; -/** - * Sets and returns the cookies in the response object on successful authentication - * @param res Express Response Object - * @param authTokens Object containing the access and refresh tokens - * @param redirect if true will redirect to provided URL else just send a 200 status code - */ -export const authCookieHandler = ( - res: Response, - authTokens: AuthTokens, - redirect: boolean, -) => { - const currentTime = DateTime.now(); - const accessTokenValidity = currentTime - .plus({ - milliseconds: parseInt(process.env.ACCESS_TOKEN_VALIDITY), - }) - .toMillis(); - const refreshTokenValidity = currentTime - .plus({ - milliseconds: parseInt(process.env.REFRESH_TOKEN_VALIDITY), - }) - .toMillis(); - - res.cookie('access_token', authTokens.access_token, { - httpOnly: true, - secure: true, - sameSite: 'lax', - maxAge: accessTokenValidity, - signed: true, - }); - res.cookie('refresh_token', authTokens.refresh_token, { - httpOnly: true, - secure: true, - sameSite: 'lax', - maxAge: refreshTokenValidity, - signed: true, - }); - if (redirect) { - res.status(HttpStatus.OK).redirect(process.env.REDIRECT_URL); - } else res.status(HttpStatus.OK).send(); -}; - /* * String to JSON parser * @param {str} str The string to parse