fix: fixed all issues raised in initial PR review

This commit is contained in:
Balu Babu
2023-01-30 18:55:53 +05:30
parent a8d50223aa
commit 3afc89db6b
21 changed files with 6640 additions and 152 deletions

View File

@@ -25,20 +25,32 @@ import { AuthGuard } from '@nestjs/passport';
export class AuthController {
constructor(private authService: AuthService) {}
/**
** Route to initiate magic-link auth for a users email
*/
@Post('signin')
async signIn(@Body() authData: signInMagicDto) {
const data = await this.authService.signIn(authData.email);
if (E.isLeft(data)) throwHTTPErr(data.left);
return data.right;
async signInMagicLink(@Body() authData: signInMagicDto) {
const deviceIdToken = await this.authService.signInMagicLink(
authData.email,
);
if (E.isLeft(deviceIdToken)) throwHTTPErr(deviceIdToken.left);
return deviceIdToken.right;
}
/**
** Route to verify and sign in a valid user via magic-link
*/
@Post('verify')
async verify(@Body() data: verifyMagicDto, @Res() res: Response) {
const authTokens = await this.authService.verifyPasswordlessTokens(data);
const authTokens = await this.authService.verifyMagicLinkTokens(data);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
authCookieHandler(res, authTokens.right, false);
}
/**
** Route to refresh auth tokens with Refresh Token Rotation
* @see https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation
*/
@Get('refresh')
@UseGuards(RTJwtAuthGuard)
async refresh(
@@ -54,10 +66,17 @@ export class AuthController {
authCookieHandler(res, newTokenPair.right, false);
}
/**
** Route to initiate SSO auth via Google
*/
@Get('google')
@UseGuards(AuthGuard('google'))
async googleAuth(@Request() req) {}
/**
** Callback URL for Google SSO
* @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works
*/
@Get('google/callback')
@UseGuards(AuthGuard('google'))
async googleAuthRedirect(@Request() req, @Res() res) {
@@ -66,10 +85,17 @@ export class AuthController {
authCookieHandler(res, authTokens.right, true);
}
/**
** Route to initiate SSO auth via Github
*/
@Get('github')
@UseGuards(AuthGuard('github'))
async githubAuth(@Request() req) {}
/**
** Callback URL for Github SSO
* @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works
*/
@Get('github/callback')
@UseGuards(AuthGuard('github'))
async githubAuthRedirect(@Request() req, @Res() res) {
@@ -78,10 +104,17 @@ export class AuthController {
authCookieHandler(res, authTokens.right, true);
}
/**
** Route to initiate SSO auth via Microsoft
*/
@Get('microsoft')
@UseGuards(AuthGuard('microsoft'))
async microsoftAuth(@Request() req) {}
/**
** Callback URL for Microsoft SSO
* @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works
*/
@Get('microsoft/callback')
@UseGuards(AuthGuard('microsoft'))
async microsoftAuthRedirect(@Request() req, @Res() res) {
@@ -90,6 +123,9 @@ export class AuthController {
authCookieHandler(res, authTokens.right, true);
}
/**
** Log user out by clearing cookies containing auth tokens
*/
@Get('logout')
async logout(@Res() res: Response) {
res.clearCookie('access_token');

View File

@@ -71,9 +71,9 @@ nowPlus30 = new Date(nowPlus30);
const encodedRefreshToken =
'$argon2id$v=19$m=65536,t=3,p=4$JTP8yZ8YXMHdafb5pB9Rfg$tdZrILUxMb9dQbu0uuyeReLgKxsgYnyUNbc5ZxQmy5I';
describe('signIn', () => {
describe('signInMagicLink', () => {
test('should throw error if email is not in valid format', async () => {
const result = await authService.signIn('bbbgmail.com');
const result = await authService.signInMagicLink('bbbgmail.com');
expect(result).toEqualLeft({
message: INVALID_EMAIL,
statusCode: HttpStatus.BAD_REQUEST,
@@ -81,16 +81,18 @@ describe('signIn', () => {
});
test('should successfully create a new user account and return the passwordless details', async () => {
// check to see if user exists, return error
// check to see if user exists, return none
mockUser.findUserByEmail.mockResolvedValue(O.none);
// create new user
mockUser.createUserMagic.mockResolvedValue(user);
mockUser.createUserViaMagicLink.mockResolvedValue(user);
// create new entry in passwordlessVerification table
mockPrisma.passwordlessVerification.create.mockResolvedValueOnce(
passwordlessData,
);
const result = await authService.signIn('dwight@dundermifflin.com');
const result = await authService.signInMagicLink(
'dwight@dundermifflin.com',
);
expect(result).toEqualRight({
deviceIdentifier: passwordlessData.deviceIdentifier,
});
@@ -104,20 +106,22 @@ describe('signIn', () => {
passwordlessData,
);
const result = await authService.signIn('dwight@dundermifflin.com');
const result = await authService.signInMagicLink(
'dwight@dundermifflin.com',
);
expect(result).toEqualRight({
deviceIdentifier: passwordlessData.deviceIdentifier,
});
});
});
describe('verifyPasswordlessTokens', () => {
describe('verifyMagicLinkTokens', () => {
test('should throw INVALID_MAGIC_LINK_DATA if data is invalid', async () => {
mockPrisma.passwordlessVerification.findUniqueOrThrow.mockRejectedValueOnce(
'NotFoundError',
);
const result = await authService.verifyPasswordlessTokens(magicLinkVerify);
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualLeft({
message: INVALID_MAGIC_LINK_DATA,
statusCode: HttpStatus.NOT_FOUND,
@@ -132,7 +136,7 @@ describe('verifyPasswordlessTokens', () => {
// findUserById
mockUser.findUserById.mockResolvedValue(O.none);
const result = await authService.verifyPasswordlessTokens(magicLinkVerify);
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualLeft({
message: USER_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
@@ -160,7 +164,7 @@ describe('verifyPasswordlessTokens', () => {
passwordlessData,
);
const result = await authService.verifyPasswordlessTokens(magicLinkVerify);
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualRight({
access_token: user.refreshToken,
refresh_token: user.refreshToken,
@@ -188,7 +192,7 @@ describe('verifyPasswordlessTokens', () => {
passwordlessData,
);
const result = await authService.verifyPasswordlessTokens(magicLinkVerify);
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualRight({
access_token: user.refreshToken,
refresh_token: user.refreshToken,
@@ -205,7 +209,7 @@ describe('verifyPasswordlessTokens', () => {
// checkIfProviderAccountExists
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
const result = await authService.verifyPasswordlessTokens(magicLinkVerify);
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualLeft({
message: MAGIC_LINK_EXPIRED,
statusCode: HttpStatus.UNAUTHORIZED,
@@ -229,7 +233,7 @@ describe('verifyPasswordlessTokens', () => {
mockJWT.sign.mockReturnValue(user.refreshToken);
mockPrisma.user.update.mockRejectedValueOnce('RecordNotFound');
const result = await authService.verifyPasswordlessTokens(magicLinkVerify);
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualLeft({
message: USER_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
@@ -257,7 +261,7 @@ describe('verifyPasswordlessTokens', () => {
'RecordNotFound',
);
const result = await authService.verifyPasswordlessTokens(magicLinkVerify);
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualLeft({
message: PASSWORDLESS_DATA_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,

View File

@@ -9,7 +9,6 @@ import * as argon2 from 'argon2';
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 { DeviceIdentifierToken } from 'src/types/Passwordless';
import {
INVALID_EMAIL,
@@ -26,9 +25,8 @@ import {
RefreshTokenPayload,
} from 'src/types/AuthTokens';
import { JwtService } from '@nestjs/jwt';
import { AuthErrorHandler } from 'src/types/AuthErrorHandler';
import { AuthError } from 'src/types/AuthError';
import { AuthUser } from 'src/types/AuthUser';
import { isLeafType } from 'graphql';
import { PasswordlessVerification } from '@prisma/client';
@Injectable()
@@ -40,16 +38,20 @@ export class AuthService {
private readonly mailerService: MailerService,
) {}
//
/**
* Generate Id and token for email Magic-Link auth
*
* @param {AuthUser} user User Object
* @returns {Promise<PasswordlessVerification>} Created PasswordlessVerification token
* @param user User Object
* @returns Created PasswordlessVerification token
*/
private async generatePasswordlessTokens(user: AuthUser) {
const salt = await bcrypt.genSalt(10);
const expiresOn = DateTime.now().plus({ hours: 3 }).toISO().toString();
private async generateMagicLinkTokens(user: AuthUser) {
const salt = await bcrypt.genSalt(
parseInt(process.env.TOKEN_SALT_COMPLEXITY),
);
const expiresOn = DateTime.now()
.plus({ hours: parseInt(process.env.MAGIC_LINK_TOKEN_VALIDITY) })
.toISO()
.toString();
const idToken = await this.prismaService.passwordlessVerification.create({
data: {
@@ -63,19 +65,19 @@ export class AuthService {
}
/**
* Find and check passwordlessVerification exists or not
* Check if passwordlessVerification exist or not
*
* @param {verifyMagicDto} data Object containing deviceIdentifier and token
* @returns {Promise<O.None | O.Some<PasswordlessVerification>>} Option of PasswordlessVerification token
* @param magicLinkTokens Object containing deviceIdentifier and token
* @returns Option of PasswordlessVerification token
*/
private async validatePasswordlessTokens(data: verifyMagicDto) {
private async validatePasswordlessTokens(magicLinkTokens: verifyMagicDto) {
try {
const tokens =
await this.prismaService.passwordlessVerification.findUniqueOrThrow({
where: {
passwordless_deviceIdentifier_tokens: {
deviceIdentifier: data.deviceIdentifier,
token: data.token,
deviceIdentifier: magicLinkTokens.deviceIdentifier,
token: magicLinkTokens.token,
},
},
});
@@ -85,35 +87,11 @@ export class AuthService {
}
}
/**
* Update User with new generated hashed refresh token
*
* @param {string} tokenHash Hash of newly generated refresh token
* @param {string} userUid User uid
* @returns {Promise<E.Right<User> | E.Left<"user/not_found">>} Either of User with updated refreshToken
*/
private async UpdateUserRefreshToken(tokenHash: string, userUid: string) {
try {
const user = await this.prismaService.user.update({
where: {
uid: userUid,
},
data: {
refreshToken: tokenHash,
},
});
return E.right(user);
} catch (error) {
return E.left(USER_NOT_FOUND);
}
}
/**
* Generate new refresh token for user
*
* @param {string} userUid User Id
* @returns {Promise<E.Left<AuthErrorHandler> | E.Right<string>>} Generated refreshToken
* @param userUid User Id
* @returns Generated refreshToken
*/
private async generateRefreshToken(userUid: string) {
const refreshTokenPayload: RefreshTokenPayload = {
@@ -128,12 +106,12 @@ export class AuthService {
const refreshTokenHash = await argon2.hash(refreshToken);
const updatedUser = await this.UpdateUserRefreshToken(
const updatedUser = await this.usersService.UpdateUserRefreshToken(
refreshTokenHash,
userUid,
);
if (E.isLeft(updatedUser))
return E.left(<AuthErrorHandler>{
return E.left(<AuthError>{
message: updatedUser.left,
statusCode: HttpStatus.NOT_FOUND,
});
@@ -144,8 +122,8 @@ export class AuthService {
/**
* Generate access and refresh token pair
*
* @param {string} userUid User ID
* @returns {Promise<E.Left<AuthErrorHandler> | E.Right<AuthTokens>>} Either of generated AuthTokens
* @param userUid User ID
* @returns Either of generated AuthTokens
*/
async generateAuthTokens(userUid: string) {
const accessTokenPayload: AccessTokenPayload = {
@@ -155,11 +133,7 @@ export class AuthService {
};
const refreshToken = await this.generateRefreshToken(userUid);
if (E.isLeft(refreshToken))
return E.left(<AuthErrorHandler>{
message: refreshToken.left.message,
statusCode: refreshToken.left.statusCode,
});
if (E.isLeft(refreshToken)) return E.left(refreshToken.left);
return E.right(<AuthTokens>{
access_token: await this.jwtService.sign(accessTokenPayload, {
@@ -172,10 +146,10 @@ export class AuthService {
/**
* Deleted used PasswordlessVerification tokens
*
* @param {PasswordlessVerification} passwordlessTokens
* @returns {Promise<E.Right<PasswordlessVerification> | E.Left<"auth/passwordless_token_data_not_found">>} Either of deleted PasswordlessVerification token
* @param passwordlessTokens PasswordlessVerification entry to delete from DB
* @returns Either of deleted PasswordlessVerification token
*/
private async deletePasswordlessVerificationToken(
private async deleteMagicLinkVerificationTokens(
passwordlessTokens: PasswordlessVerification,
) {
try {
@@ -197,16 +171,16 @@ export class AuthService {
/**
* Verify if Provider account exists for User
*
* @param {User} user User Object
* @param profile Provider Account type (Magic,Google,Github,Microsoft)
* @returns {Promise<O.None | O.Some<Account>>} Either of existing user provider Account
* @param user User Object
* @param SSOUserData User data from SSO providers (Magic,Google,Github,Microsoft)
* @returns Either of existing user provider Account
*/
async checkIfProviderAccountExists(user: AuthUser, profile) {
async checkIfProviderAccountExists(user: AuthUser, SSOUserData) {
const provider = await this.prismaService.account.findUnique({
where: {
verifyProviderAccount: {
provider: profile.provider,
providerAccountId: profile.id,
provider: SSOUserData.provider,
providerAccountId: SSOUserData.id,
},
},
});
@@ -217,12 +191,12 @@ export class AuthService {
}
/**
* Send Magic-Link to provider User email
* Create User (if not already present) and send email to initiate Magic-Link auth
*
* @param {string} email User's email
* @returns {Promise<E.Left<AuthErrorHandler> | E.Right<DeviceIdentifierToken>>} Either containing DeviceIdentifierToken
* @param email User's email
* @returns Either containing DeviceIdentifierToken
*/
async signIn(email: string) {
async signInMagicLink(email: string) {
if (!validateEmail(email))
return E.left({
message: INVALID_EMAIL,
@@ -233,12 +207,12 @@ export class AuthService {
const queriedUser = await this.usersService.findUserByEmail(email);
if (O.isNone(queriedUser)) {
user = await this.usersService.createUserMagic(email);
user = await this.usersService.createUserViaMagicLink(email);
} else {
user = queriedUser.value;
}
const generatedTokens = await this.generatePasswordlessTokens(user);
const generatedTokens = await this.generateMagicLinkTokens(user);
await this.mailerService.sendAuthEmail(email, {
template: 'code-your-own',
@@ -256,13 +230,15 @@ export class AuthService {
/**
* Verify and authenticate user from received data for Magic-Link
*
* @param {verifyMagicDto} data
* @returns {Promise<E.Right<AuthTokens> | E.Left<AuthErrorHandler>>} Either of generated AuthTokens
* @param magicLinkIDTokens magic-link verification tokens from client
* @returns Either of generated AuthTokens
*/
async verifyPasswordlessTokens(
data: verifyMagicDto,
): Promise<E.Right<AuthTokens> | E.Left<AuthErrorHandler>> {
const passwordlessTokens = await this.validatePasswordlessTokens(data);
async verifyMagicLinkTokens(
magicLinkIDTokens: verifyMagicDto,
): Promise<E.Right<AuthTokens> | E.Left<AuthError>> {
const passwordlessTokens = await this.validatePasswordlessTokens(
magicLinkIDTokens,
);
if (O.isNone(passwordlessTokens))
return E.left({
message: INVALID_MAGIC_LINK_DATA,
@@ -317,7 +293,7 @@ export class AuthService {
});
const deletedPasswordlessToken =
await this.deletePasswordlessVerificationToken(passwordlessTokens.value);
await this.deleteMagicLinkVerificationTokens(passwordlessTokens.value);
if (E.isLeft(deletedPasswordlessToken))
return E.left({
message: deletedPasswordlessToken.left,
@@ -330,24 +306,30 @@ export class AuthService {
/**
* Refresh refresh and auth tokens
*
* @param {string} refresh_token Hashed refresh token received from client
* @param {AuthUser} user User Object
* @returns {Promise<E.Left<AuthErrorHandler> | E.Right<AuthTokens>>} Either of generated AuthTokens
* @param hashedRefreshToken Hashed refresh token received from client
* @param user User Object
* @returns Either of generated AuthTokens
*/
async refreshAuthTokens(refresh_token: string, user: AuthUser) {
async refreshAuthTokens(hashedRefreshToken: string, user: AuthUser) {
// Check to see user is valid
if (!user)
return E.left({
message: USER_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
});
const isMatched = await argon2.verify(user.refreshToken, refresh_token);
if (!isMatched)
// Check to see if the hashed refresh_token received from the client is the same as the refresh_token saved in the DB
const isTokenMatched = await argon2.verify(
user.refreshToken,
hashedRefreshToken,
);
if (!isTokenMatched)
return E.left({
message: INVALID_REFRESH_TOKEN,
statusCode: HttpStatus.NOT_FOUND,
});
// if tokens match, generate new pair of auth tokens
const generatedAuthTokens = await this.generateAuthTokens(user.uid);
if (E.isLeft(generatedAuthTokens))
return E.left({

View File

@@ -1,3 +0,0 @@
export class refreshTokensDto {
refresh_token: string;
}

View File

@@ -1,3 +1,4 @@
// Inputs to initiate Magic-Link auth flow
export class signInMagicDto {
email: string;
}

View File

@@ -1,3 +1,4 @@
// Inputs to verify and sign a user in via magic-link
export class verifyMagicDto {
deviceIdentifier: string;
token: string;