refactor: created utlility functions for setting cookies and handling redirects
This commit is contained in:
@@ -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',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
|
export interface AuthErrorHandler {
|
||||||
|
message: string;
|
||||||
|
statusCode: HttpStatus;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ export interface PasswordlessToken {
|
|||||||
user?: User;
|
user?: User;
|
||||||
expiresOn: Date;
|
expiresOn: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeviceIdentifierToken {
|
||||||
|
deviceIdentifier: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user