From d98e7b941607c0f6df7bd2ef1540907d0597e2b3 Mon Sep 17 00:00:00 2001 From: Balu Babu Date: Tue, 10 Jan 2023 17:00:39 +0530 Subject: [PATCH] refactor: created utlility functions for setting cookies and handling redirects --- .../src/auth/auth.controller.ts | 32 ++++---- .../src/auth/auth.service.ts | 76 +++++++++++++------ .../src/types/AuthErrorHandler.ts | 6 ++ .../src/types/AuthTokens.ts | 4 +- .../src/types/Passwordless.ts | 4 + packages/hoppscotch-backend/src/utils.ts | 43 ++++++++++- 6 files changed, 123 insertions(+), 42 deletions(-) create mode 100644 packages/hoppscotch-backend/src/types/AuthErrorHandler.ts diff --git a/packages/hoppscotch-backend/src/auth/auth.controller.ts b/packages/hoppscotch-backend/src/auth/auth.controller.ts index 918b6fa5d..3a5638369 100644 --- a/packages/hoppscotch-backend/src/auth/auth.controller.ts +++ b/packages/hoppscotch-backend/src/auth/auth.controller.ts @@ -1,8 +1,18 @@ -import { Body, Controller, Get, HttpStatus, Post, Res } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpException, + HttpStatus, + Post, + Res, +} from '@nestjs/common'; import { AuthService } from './auth.service'; import { signInMagicDto } from './dto/signin-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'; @Controller('auth') export class AuthController { @@ -10,25 +20,15 @@ export class AuthController { @Post('signin') async signIn(@Body() authData: signInMagicDto) { - return this.authService.signIn(authData.email); + const data = await this.authService.signIn(authData.email); + if (E.isLeft(data)) throwHTTPErr(data.left); + return data.right; } - //TODO: set expiresOn to cookies @Post('verify') async verify(@Body() data: verifyMagicDto, @Res() res: Response) { const authTokens = await this.authService.verify(data); - res.cookie('access_token', authTokens.access_token, { - httpOnly: true, - secure: true, - sameSite: 'lax', - }); - res.cookie('refresh_token', authTokens.refresh_token, { - httpOnly: true, - secure: true, - sameSite: 'lax', - }); - res.status(HttpStatus.OK).json({ - message: 'Successfully logged in', - }); + if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); + authCookieHandler(res, authTokens.right, false); } } diff --git a/packages/hoppscotch-backend/src/auth/auth.service.ts b/packages/hoppscotch-backend/src/auth/auth.service.ts index a72739b58..52d83bfcd 100644 --- a/packages/hoppscotch-backend/src/auth/auth.service.ts +++ b/packages/hoppscotch-backend/src/auth/auth.service.ts @@ -10,18 +10,18 @@ import * as bcrypt from 'bcrypt'; import * as O from 'fp-ts/Option'; import * as E from 'fp-ts/Either'; import * as TE from 'fp-ts/TaskEither'; -import { PasswordlessToken } from 'src/types/Passwordless'; -import { EmailCodec } from 'src/types/Email'; +import { + DeviceIdentifierToken, + PasswordlessToken, +} from 'src/types/Passwordless'; import { INVALID_EMAIL, INVALID_MAGIC_LINK_DATA, PASSWORDLESS_DATA_NOT_FOUND, MAGIC_LINK_EXPIRED, - TOKEN_EXPIRED, USER_NOT_FOUND, } from 'src/errors'; -import { pipe } from 'fp-ts/lib/function'; -import { throwErr, validateEmail } from 'src/utils'; +import { validateEmail } from 'src/utils'; import { AccessTokenPayload, AuthTokens, @@ -29,7 +29,7 @@ import { } from 'src/types/AuthTokens'; import { ProviderAccount } from 'src/types/ProviderAccount'; import { JwtService } from '@nestjs/jwt'; -import { pass } from 'fp-ts/lib/Writer'; +import { AuthErrorHandler } from 'src/types/AuthErrorHandler'; @Injectable() export class AuthService { @@ -109,9 +109,12 @@ export class AuthService { userUid, ); if (E.isLeft(updatedUser)) - throw new HttpException(updatedUser.left, HttpStatus.NOT_FOUND); + return E.left({ + message: updatedUser.left, + statusCode: HttpStatus.NOT_FOUND, + }); - return refreshToken; + return E.right(refreshToken); } async generateAuthTokens(userUid: string) { @@ -122,13 +125,18 @@ export class AuthService { }; const refreshToken = await this.generateRefreshToken(userUid); + if (E.isLeft(refreshToken)) + return E.left({ + message: refreshToken.left.message, + statusCode: refreshToken.left.statusCode, + }); - return { + return E.right({ access_token: await this.jwtService.sign(accessTokenPayload, { expiresIn: process.env.ACCESS_TOKEN_VALIDITY, //1 Day }), - refresh_token: refreshToken, - }; + refresh_token: refreshToken.right, + }); } private async deletePasswordlessVerificationToken( @@ -166,9 +174,14 @@ export class AuthService { return O.some(provider); } - async signIn(email: string) { + async signIn( + email: string, + ): Promise | E.Right> { if (!validateEmail(email)) - throw new HttpException(INVALID_EMAIL, HttpStatus.BAD_REQUEST); + return E.left({ + message: INVALID_EMAIL, + statusCode: HttpStatus.BAD_REQUEST, + }); let user: User; const queriedUser = await this.usersService.findUserByEmail(email); @@ -189,31 +202,46 @@ export class AuthService { }, }); - return { deviceIdentifier: generatedTokens.deviceIdentifier }; + return E.right({ + deviceIdentifier: generatedTokens.deviceIdentifier, + }); } - async verify(data: verifyMagicDto) { + async verify( + data: verifyMagicDto, + ): Promise | E.Left> { const passwordlessTokens = await this.validatePasswordlessTokens(data); if (O.isNone(passwordlessTokens)) - throw new HttpException(INVALID_MAGIC_LINK_DATA, HttpStatus.NOT_FOUND); + return E.left({ + message: INVALID_MAGIC_LINK_DATA, + statusCode: HttpStatus.NOT_FOUND, + }); const currentTime = DateTime.now().toISOTime(); - if (currentTime > passwordlessTokens.value.expiresOn.toISOString()) { - throw new HttpException(MAGIC_LINK_EXPIRED, HttpStatus.UNAUTHORIZED); - } + if (currentTime > passwordlessTokens.value.expiresOn.toISOString()) + return E.left({ + message: MAGIC_LINK_EXPIRED, + statusCode: HttpStatus.UNAUTHORIZED, + }); + const tokens = await this.generateAuthTokens( passwordlessTokens.value.userUid, ); + if (E.isLeft(tokens)) + return E.left({ + message: tokens.left.message, + statusCode: tokens.left.statusCode, + }); const deletedPasswordlessToken = await this.deletePasswordlessVerificationToken(passwordlessTokens.value); if (E.isLeft(deletedPasswordlessToken)) - throw new HttpException( - deletedPasswordlessToken.left, - HttpStatus.NOT_FOUND, - ); + return E.left({ + message: deletedPasswordlessToken.left, + statusCode: HttpStatus.NOT_FOUND, + }); - return tokens; + return E.right(tokens.right); } } diff --git a/packages/hoppscotch-backend/src/types/AuthErrorHandler.ts b/packages/hoppscotch-backend/src/types/AuthErrorHandler.ts new file mode 100644 index 000000000..a5639af03 --- /dev/null +++ b/packages/hoppscotch-backend/src/types/AuthErrorHandler.ts @@ -0,0 +1,6 @@ +import { HttpStatus } from '@nestjs/common'; + +export interface AuthErrorHandler { + message: string; + statusCode: HttpStatus; +} diff --git a/packages/hoppscotch-backend/src/types/AuthTokens.ts b/packages/hoppscotch-backend/src/types/AuthTokens.ts index dc3ca5f71..a7eb0d7bf 100644 --- a/packages/hoppscotch-backend/src/types/AuthTokens.ts +++ b/packages/hoppscotch-backend/src/types/AuthTokens.ts @@ -1,4 +1,6 @@ -// refer to https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims#registered-claims +/** + * @see https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims#registered-claims + **/ export interface AccessTokenPayload { iss: string; // iss:issuer sub: string; // sub:subject diff --git a/packages/hoppscotch-backend/src/types/Passwordless.ts b/packages/hoppscotch-backend/src/types/Passwordless.ts index 7478730db..9a8247daf 100644 --- a/packages/hoppscotch-backend/src/types/Passwordless.ts +++ b/packages/hoppscotch-backend/src/types/Passwordless.ts @@ -7,3 +7,7 @@ export interface PasswordlessToken { user?: User; expiresOn: Date; } + +export interface DeviceIdentifierToken { + deviceIdentifier: string; +} diff --git a/packages/hoppscotch-backend/src/utils.ts b/packages/hoppscotch-backend/src/utils.ts index 630f1f880..d2fa14cdb 100644 --- a/packages/hoppscotch-backend/src/utils.ts +++ b/packages/hoppscotch-backend/src/utils.ts @@ -1,4 +1,4 @@ -import { ExecutionContext } from '@nestjs/common'; +import { ExecutionContext, HttpException, HttpStatus } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; import { pipe } from 'fp-ts/lib/function'; import * as O from 'fp-ts/Option'; @@ -6,6 +6,10 @@ import * as TE from 'fp-ts/TaskEither'; import * as T from 'fp-ts/Task'; import { User } from './user/user.model'; import * as A from 'fp-ts/Array'; +import * as E from 'fp-ts/Either'; +import { AuthErrorHandler } from './types/AuthErrorHandler'; +import { AuthTokens } from './types/AuthTokens'; +import { Response } from 'express'; /** * A workaround to throw an exception in an expression. @@ -17,6 +21,15 @@ 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: AuthErrorHandler): 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. @@ -112,6 +125,7 @@ export const taskEitherValidateArraySeq = ( /** * Checks to see if the email is valid or not * @param email The email + * @see https://emailregex.com/ for information on email regex * @returns A Boolean depending on the format of the email */ export const validateEmail = (email: string) => { @@ -119,3 +133,30 @@ export const validateEmail = (email: string) => { /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, ).test(email); }; + +//TODO: set expiresOn to cookies +/** + * 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, +) => { + res.cookie('access_token', authTokens.access_token, { + httpOnly: true, + secure: true, + sameSite: 'lax', + }); + res.cookie('refresh_token', authTokens.refresh_token, { + httpOnly: true, + secure: true, + sameSite: 'lax', + }); + if (redirect) { + res.status(HttpStatus.OK).redirect('/'); + } else res.status(HttpStatus.OK).send(); +};