refactor: created utlility functions for setting cookies and handling redirects

This commit is contained in:
Balu Babu
2023-01-10 17:00:39 +05:30
parent fc284fd0a2
commit d98e7b9416
6 changed files with 123 additions and 42 deletions

View File

@@ -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 { AuthService } from './auth.service';
import { signInMagicDto } from './dto/signin-magic.dto'; 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 { Response } from 'express';
import * as E from 'fp-ts/Either';
import { authCookieHandler, throwHTTPErr } from 'src/utils';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
@@ -10,25 +20,15 @@ export class AuthController {
@Post('signin') @Post('signin')
async signIn(@Body() authData: signInMagicDto) { 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') @Post('verify')
async verify(@Body() data: verifyMagicDto, @Res() res: Response) { async verify(@Body() data: verifyMagicDto, @Res() res: Response) {
const authTokens = await this.authService.verify(data); const authTokens = await this.authService.verify(data);
res.cookie('access_token', authTokens.access_token, { if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
httpOnly: true, authCookieHandler(res, authTokens.right, false);
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',
});
} }
} }

View File

@@ -10,18 +10,18 @@ import * as bcrypt from 'bcrypt';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import * as TE from 'fp-ts/TaskEither'; import * as TE from 'fp-ts/TaskEither';
import { PasswordlessToken } from 'src/types/Passwordless'; import {
import { EmailCodec } from 'src/types/Email'; DeviceIdentifierToken,
PasswordlessToken,
} from 'src/types/Passwordless';
import { import {
INVALID_EMAIL, INVALID_EMAIL,
INVALID_MAGIC_LINK_DATA, INVALID_MAGIC_LINK_DATA,
PASSWORDLESS_DATA_NOT_FOUND, PASSWORDLESS_DATA_NOT_FOUND,
MAGIC_LINK_EXPIRED, MAGIC_LINK_EXPIRED,
TOKEN_EXPIRED,
USER_NOT_FOUND, USER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { pipe } from 'fp-ts/lib/function'; import { validateEmail } from 'src/utils';
import { throwErr, validateEmail } from 'src/utils';
import { import {
AccessTokenPayload, AccessTokenPayload,
AuthTokens, AuthTokens,
@@ -29,7 +29,7 @@ import {
} from 'src/types/AuthTokens'; } from 'src/types/AuthTokens';
import { ProviderAccount } from 'src/types/ProviderAccount'; import { ProviderAccount } from 'src/types/ProviderAccount';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { pass } from 'fp-ts/lib/Writer'; import { AuthErrorHandler } from 'src/types/AuthErrorHandler';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -109,9 +109,12 @@ export class AuthService {
userUid, userUid,
); );
if (E.isLeft(updatedUser)) if (E.isLeft(updatedUser))
throw new HttpException(updatedUser.left, HttpStatus.NOT_FOUND); return E.left(<AuthErrorHandler>{
message: updatedUser.left,
statusCode: HttpStatus.NOT_FOUND,
});
return refreshToken; return E.right(refreshToken);
} }
async generateAuthTokens(userUid: string) { async generateAuthTokens(userUid: string) {
@@ -122,13 +125,18 @@ export class AuthService {
}; };
const refreshToken = await this.generateRefreshToken(userUid); const refreshToken = await this.generateRefreshToken(userUid);
if (E.isLeft(refreshToken))
return E.left(<AuthErrorHandler>{
message: refreshToken.left.message,
statusCode: refreshToken.left.statusCode,
});
return <AuthTokens>{ return E.right(<AuthTokens>{
access_token: await this.jwtService.sign(accessTokenPayload, { access_token: await this.jwtService.sign(accessTokenPayload, {
expiresIn: process.env.ACCESS_TOKEN_VALIDITY, //1 Day expiresIn: process.env.ACCESS_TOKEN_VALIDITY, //1 Day
}), }),
refresh_token: refreshToken, refresh_token: refreshToken.right,
}; });
} }
private async deletePasswordlessVerificationToken( private async deletePasswordlessVerificationToken(
@@ -166,9 +174,14 @@ export class AuthService {
return O.some(provider); return O.some(provider);
} }
async signIn(email: string) { async signIn(
email: string,
): Promise<E.Left<AuthErrorHandler> | E.Right<DeviceIdentifierToken>> {
if (!validateEmail(email)) if (!validateEmail(email))
throw new HttpException(INVALID_EMAIL, HttpStatus.BAD_REQUEST); return E.left({
message: INVALID_EMAIL,
statusCode: HttpStatus.BAD_REQUEST,
});
let user: User; let user: User;
const queriedUser = await this.usersService.findUserByEmail(email); const queriedUser = await this.usersService.findUserByEmail(email);
@@ -189,31 +202,46 @@ export class AuthService {
}, },
}); });
return { deviceIdentifier: generatedTokens.deviceIdentifier }; return E.right(<DeviceIdentifierToken>{
deviceIdentifier: generatedTokens.deviceIdentifier,
});
} }
async verify(data: verifyMagicDto) { async verify(
data: verifyMagicDto,
): Promise<E.Right<AuthTokens> | E.Left<AuthErrorHandler>> {
const passwordlessTokens = await this.validatePasswordlessTokens(data); const passwordlessTokens = await this.validatePasswordlessTokens(data);
if (O.isNone(passwordlessTokens)) 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(); const currentTime = DateTime.now().toISOTime();
if (currentTime > passwordlessTokens.value.expiresOn.toISOString()) { if (currentTime > passwordlessTokens.value.expiresOn.toISOString())
throw new HttpException(MAGIC_LINK_EXPIRED, HttpStatus.UNAUTHORIZED); return E.left({
} message: MAGIC_LINK_EXPIRED,
statusCode: HttpStatus.UNAUTHORIZED,
});
const tokens = await this.generateAuthTokens( const tokens = await this.generateAuthTokens(
passwordlessTokens.value.userUid, passwordlessTokens.value.userUid,
); );
if (E.isLeft(tokens))
return E.left({
message: tokens.left.message,
statusCode: tokens.left.statusCode,
});
const deletedPasswordlessToken = const deletedPasswordlessToken =
await this.deletePasswordlessVerificationToken(passwordlessTokens.value); await this.deletePasswordlessVerificationToken(passwordlessTokens.value);
if (E.isLeft(deletedPasswordlessToken)) if (E.isLeft(deletedPasswordlessToken))
throw new HttpException( return E.left({
deletedPasswordlessToken.left, message: deletedPasswordlessToken.left,
HttpStatus.NOT_FOUND, statusCode: HttpStatus.NOT_FOUND,
); });
return tokens; return E.right(tokens.right);
} }
} }

View File

@@ -0,0 +1,6 @@
import { HttpStatus } from '@nestjs/common';
export interface AuthErrorHandler {
message: string;
statusCode: HttpStatus;
}

View File

@@ -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 { export interface AccessTokenPayload {
iss: string; // iss:issuer iss: string; // iss:issuer
sub: string; // sub:subject sub: string; // sub:subject

View File

@@ -7,3 +7,7 @@ export interface PasswordlessToken {
user?: User; user?: User;
expiresOn: Date; expiresOn: Date;
} }
export interface DeviceIdentifierToken {
deviceIdentifier: string;
}

View File

@@ -1,4 +1,4 @@
import { ExecutionContext } from '@nestjs/common'; import { ExecutionContext, HttpException, HttpStatus } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
import { pipe } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/function';
import * as O from 'fp-ts/Option'; 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 * as T from 'fp-ts/Task';
import { User } from './user/user.model'; import { User } from './user/user.model';
import * as A from 'fp-ts/Array'; 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. * A workaround to throw an exception in an expression.
@@ -17,6 +21,15 @@ export function throwErr(errMessage: string): never {
throw new Error(errMessage); 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. * Prints the given value to log and returns the same value.
* Used for debugging functional pipelines. * Used for debugging functional pipelines.
@@ -112,6 +125,7 @@ export const taskEitherValidateArraySeq = <A, B>(
/** /**
* Checks to see if the email is valid or not * Checks to see if the email is valid or not
* @param email The email * @param email The email
* @see https://emailregex.com/ for information on email regex
* @returns A Boolean depending on the format of the email * @returns A Boolean depending on the format of the email
*/ */
export const validateEmail = (email: string) => { 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,}))$/, /^(([^<>()\[\]\\.,;:\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); ).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();
};