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

@@ -8,6 +8,8 @@ POSTMARK_SENDER_EMAIL=************************************************"
# Auth Tokens Config # Auth Tokens Config
SIGNED_COOKIE_SECRET='add some secret here' SIGNED_COOKIE_SECRET='add some secret here'
JWT_SECRET='add some secret here' JWT_SECRET='add some secret here'
TOKEN_SALT_COMPLEXITY=10
MAGIC_LINK_TOKEN_VALIDITY=3
REFRESH_TOKEN_VALIDITY="604800000" # Default validity is 7 days REFRESH_TOKEN_VALIDITY="604800000" # Default validity is 7 days
ACCESS_TOKEN_VALIDITY="120000" # Default validity is 1 day ACCESS_TOKEN_VALIDITY="120000" # Default validity is 1 day

View File

@@ -112,4 +112,4 @@
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node"
} }
} }

6421
packages/hoppscotch-backend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -85,7 +85,7 @@ model User {
photoURL String? photoURL String?
isAdmin Boolean @default(false) isAdmin Boolean @default(false)
refreshToken String? refreshToken String?
accounts Account[] providerAccounts Account[]
PasswordlessVerification PasswordlessVerification[] PasswordlessVerification PasswordlessVerification[]
settings UserSettings? settings UserSettings?
UserHistory UserHistory[] UserHistory UserHistory[]
@@ -109,7 +109,7 @@ model Account {
@@unique(fields: [provider, providerAccountId], name: "verifyProviderAccount") @@unique(fields: [provider, providerAccountId], name: "verifyProviderAccount")
} }
model PasswordlessVerification { model VerificationToken {
deviceIdentifier String deviceIdentifier String
token String @unique @default(cuid()) token String @unique @default(cuid())
userUid String userUid String

View File

@@ -25,20 +25,32 @@ import { AuthGuard } from '@nestjs/passport';
export class AuthController { export class AuthController {
constructor(private authService: AuthService) {} constructor(private authService: AuthService) {}
/**
** Route to initiate magic-link auth for a users email
*/
@Post('signin') @Post('signin')
async signIn(@Body() authData: signInMagicDto) { async signInMagicLink(@Body() authData: signInMagicDto) {
const data = await this.authService.signIn(authData.email); const deviceIdToken = await this.authService.signInMagicLink(
if (E.isLeft(data)) throwHTTPErr(data.left); authData.email,
return data.right; );
if (E.isLeft(deviceIdToken)) throwHTTPErr(deviceIdToken.left);
return deviceIdToken.right;
} }
/**
** Route to verify and sign in a valid user via magic-link
*/
@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.verifyPasswordlessTokens(data); const authTokens = await this.authService.verifyMagicLinkTokens(data);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
authCookieHandler(res, authTokens.right, false); 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') @Get('refresh')
@UseGuards(RTJwtAuthGuard) @UseGuards(RTJwtAuthGuard)
async refresh( async refresh(
@@ -54,10 +66,17 @@ export class AuthController {
authCookieHandler(res, newTokenPair.right, false); authCookieHandler(res, newTokenPair.right, false);
} }
/**
** Route to initiate SSO auth via Google
*/
@Get('google') @Get('google')
@UseGuards(AuthGuard('google')) @UseGuards(AuthGuard('google'))
async googleAuth(@Request() req) {} 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') @Get('google/callback')
@UseGuards(AuthGuard('google')) @UseGuards(AuthGuard('google'))
async googleAuthRedirect(@Request() req, @Res() res) { async googleAuthRedirect(@Request() req, @Res() res) {
@@ -66,10 +85,17 @@ export class AuthController {
authCookieHandler(res, authTokens.right, true); authCookieHandler(res, authTokens.right, true);
} }
/**
** Route to initiate SSO auth via Github
*/
@Get('github') @Get('github')
@UseGuards(AuthGuard('github')) @UseGuards(AuthGuard('github'))
async githubAuth(@Request() req) {} 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') @Get('github/callback')
@UseGuards(AuthGuard('github')) @UseGuards(AuthGuard('github'))
async githubAuthRedirect(@Request() req, @Res() res) { async githubAuthRedirect(@Request() req, @Res() res) {
@@ -78,10 +104,17 @@ export class AuthController {
authCookieHandler(res, authTokens.right, true); authCookieHandler(res, authTokens.right, true);
} }
/**
** Route to initiate SSO auth via Microsoft
*/
@Get('microsoft') @Get('microsoft')
@UseGuards(AuthGuard('microsoft')) @UseGuards(AuthGuard('microsoft'))
async microsoftAuth(@Request() req) {} 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') @Get('microsoft/callback')
@UseGuards(AuthGuard('microsoft')) @UseGuards(AuthGuard('microsoft'))
async microsoftAuthRedirect(@Request() req, @Res() res) { async microsoftAuthRedirect(@Request() req, @Res() res) {
@@ -90,6 +123,9 @@ export class AuthController {
authCookieHandler(res, authTokens.right, true); authCookieHandler(res, authTokens.right, true);
} }
/**
** Log user out by clearing cookies containing auth tokens
*/
@Get('logout') @Get('logout')
async logout(@Res() res: Response) { async logout(@Res() res: Response) {
res.clearCookie('access_token'); res.clearCookie('access_token');

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
/**
** Decorator to fetch refresh_token from cookie
*/
export const RTCookie = createParamDecorator( export const RTCookie = createParamDecorator(
(data: unknown, context: ExecutionContext) => { (data: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context); const ctx = GqlExecutionContext.create(context);

View File

@@ -129,6 +129,12 @@ export const TEAM_REQ_NOT_FOUND = 'team_req/not_found' as const;
export const TEAM_REQ_INVALID_TARGET_COLL_ID = export const TEAM_REQ_INVALID_TARGET_COLL_ID =
'team_req/invalid_target_id' as const; 'team_req/invalid_target_id' as const;
/**
* No Postmark Sender Email defined
* (AuthService)
*/
export const SENDER_EMAIL_INVALID = 'mailer/sender_email_invalid' as const;
/** /**
* Tried to perform action on a request when the user is not even member of the team * Tried to perform action on a request when the user is not even member of the team
* (GqlRequestTeamMemberGuard, GqlCollectionTeamMemberGuard) * (GqlRequestTeamMemberGuard, GqlCollectionTeamMemberGuard)
@@ -177,13 +183,15 @@ export const USER_SETTINGS_NOT_FOUND = 'user_settings/not_found' as const;
* User setting already exists for a user * User setting already exists for a user
* (UserSettingsService) * (UserSettingsService)
*/ */
export const USER_SETTINGS_ALREADY_EXISTS = 'user_settings/settings_already_exists' as const; export const USER_SETTINGS_ALREADY_EXISTS =
'user_settings/settings_already_exists' as const;
/** /**
* User setting invalid (null) settings * User setting invalid (null) settings
* (UserSettingsService) * (UserSettingsService)
*/ */
export const USER_SETTINGS_NULL_SETTINGS = 'user_settings/null_settings' as const; export const USER_SETTINGS_NULL_SETTINGS =
'user_settings/null_settings' as const;
/* /*
* Global environment doesnt exists for the user * Global environment doesnt exists for the user

View File

@@ -8,7 +8,7 @@ export type MailDescription = {
}; };
export type UserMagicLinkMailDescription = { export type UserMagicLinkMailDescription = {
template: 'code-your-own'; //Alias of template in Postmark, change this to env variable template: 'code-your-own';
variables: { variables: {
inviteeEmail: string; inviteeEmail: string;
magicLink: string; magicLink: string;

View File

@@ -7,7 +7,7 @@ import {
import * as postmark from 'postmark'; import * as postmark from 'postmark';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import * as TE from 'fp-ts/TaskEither'; import * as TE from 'fp-ts/TaskEither';
import { EMAIL_FAILED } from 'src/errors'; import { EMAIL_FAILED, SENDER_EMAIL_INVALID } from 'src/errors';
@Injectable() @Injectable()
export class MailerService implements OnModuleInit { export class MailerService implements OnModuleInit {
@@ -15,8 +15,7 @@ export class MailerService implements OnModuleInit {
onModuleInit() { onModuleInit() {
this.client = new postmark.ServerClient( this.client = new postmark.ServerClient(
process.env.POSTMARK_SERVER_TOKEN || process.env.POSTMARK_SERVER_TOKEN || throwErr(SENDER_EMAIL_INVALID),
throwErr('No Postmark Server Token defined'),
); );
} }
@@ -29,8 +28,7 @@ export class MailerService implements OnModuleInit {
this.client.sendEmailWithTemplate({ this.client.sendEmailWithTemplate({
To: to, To: to,
From: From:
process.env.POSTMARK_SENDER_EMAIL || process.env.POSTMARK_SENDER_EMAIL || throwErr(SENDER_EMAIL_INVALID),
throwErr('No Postmark Sender Email defined'),
TemplateAlias: mailDesc.template, TemplateAlias: mailDesc.template,
TemplateModel: mailDesc.variables, TemplateModel: mailDesc.variables,
}), }),
@@ -40,9 +38,9 @@ export class MailerService implements OnModuleInit {
/** /**
* *
* @param {string} to Receiver's email id * @param to Receiver's email id
* @param {UserMagicLinkMailDescription} mailDesc Details of email to be sent for Magic-Link auth * @param mailDesc Details of email to be sent for Magic-Link auth
* @returns {Promise<postmark.Models.MessageSendingResponse>} Response if email was send successfully or not * @returns Response if email was send successfully or not
*/ */
async sendAuthEmail(to: string, mailDesc: UserMagicLinkMailDescription) { async sendAuthEmail(to: string, mailDesc: UserMagicLinkMailDescription) {
try { try {

View File

@@ -0,0 +1,10 @@
import { HttpStatus } from '@nestjs/common';
/**
** Custom interface to handle errors specific to Auth module
** Since its REST we need to return HTTP status code along with error message
*/
export type AuthError = {
message: string;
statusCode: HttpStatus;
};

View File

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

View File

@@ -15,7 +15,7 @@ export interface RefreshTokenPayload {
iat?: number; iat?: number;
} }
export interface AuthTokens { export type AuthTokens = {
access_token: string; access_token: string;
refresh_token: string; refresh_token: string;
} };

View File

@@ -1,3 +1,3 @@
export interface DeviceIdentifierToken { export type DeviceIdentifierToken = {
deviceIdentifier: string; deviceIdentifier: string;
} };

View File

@@ -74,11 +74,11 @@ describe('findUserById', () => {
}); });
}); });
describe('createUserMagic', () => { describe('createUserViaMagicLink', () => {
test('should successfully create user and account for magic-link given valid inputs', async () => { test('should successfully create user and account for magic-link given valid inputs', async () => {
mockPrisma.user.create.mockResolvedValueOnce(user); mockPrisma.user.create.mockResolvedValueOnce(user);
const result = await userService.createUserMagic( const result = await userService.createUserViaMagicLink(
'dwight@dundermifflin.com', 'dwight@dundermifflin.com',
); );
expect(result).toEqual(user); expect(result).toEqual(user);

View File

@@ -19,8 +19,8 @@ export class UserService {
/** /**
* Find User with given email id * Find User with given email id
* *
* @param {string} email User's email * @param email User's email
* @returns {Promise<O.None | O.Some<User>>} Option of found User * @returns Option of found User
*/ */
async findUserByEmail(email: string) { async findUserByEmail(email: string) {
try { try {
@@ -38,8 +38,8 @@ export class UserService {
/** /**
* Find User with given ID * Find User with given ID
* *
* @param {string} userUid User ID * @param userUid User ID
* @returns {Promise<O.None | O.Some<User>>} Option of found User * @returns Option of found User
*/ */
async findUserById(userUid: string) { async findUserById(userUid: string) {
try { try {
@@ -54,13 +54,37 @@ export class UserService {
} }
} }
/**
* Update User with new generated hashed refresh token
*
* @param refreshTokenHash Hash of newly generated refresh token
* @param userUid User uid
* @returns Either of User with updated refreshToken
*/
async UpdateUserRefreshToken(refreshTokenHash: string, userUid: string) {
try {
const user = await this.prisma.user.update({
where: {
uid: userUid,
},
data: {
refreshToken: refreshTokenHash,
},
});
return E.right(user);
} catch (error) {
return E.left(USER_NOT_FOUND);
}
}
/** /**
* Create a new User when logged in via a Magic Link * Create a new User when logged in via a Magic Link
* *
* @param {string} email User's Email * @param email User's Email
* @returns {Promise<User>} Created User * @returns Created User
*/ */
async createUserMagic(email: string) { async createUserViaMagicLink(email: string) {
const createdUser = await this.prisma.user.create({ const createdUser = await this.prisma.user.create({
data: { data: {
email: email, email: email,
@@ -79,23 +103,30 @@ export class UserService {
/** /**
* Create a new User when logged in via a SSO provider * Create a new User when logged in via a SSO provider
* *
* @param {string} accessToken User's access token generated by providers * @param accessTokenSSO User's access token generated by providers
* @param {string} refreshToken User's refresh token generated by providers * @param refreshTokenSSO User's refresh token generated by providers
* @param {any} profile Data received from SSO provider on the users account * @param profile Data received from SSO provider on the users account
* @returns {Promise<User>} Created User * @returns Created User
*/ */
async createUserSSO(accessToken: string, refreshToken: string, profile) { async createUserSSO(
accessTokenSSO: string,
refreshTokenSSO: string,
profile,
) {
const userDisplayName = !profile.displayName ? null : profile.displayName;
const userPhotoURL = !profile.photos ? null : profile.photos[0].value;
const createdUser = await this.prisma.user.create({ const createdUser = await this.prisma.user.create({
data: { data: {
displayName: !profile.displayName ? null : profile.displayName, displayName: userDisplayName,
email: profile.emails[0].value, email: profile.emails[0].value,
photoURL: !profile.photos ? null : profile.photos[0].value, photoURL: userPhotoURL,
accounts: { accounts: {
create: { create: {
provider: profile.provider, provider: profile.provider,
providerAccountId: profile.id, providerAccountId: profile.id,
providerRefreshToken: refreshToken, providerRefreshToken: refreshTokenSSO,
providerAccessToken: accessToken, providerAccessToken: accessTokenSSO,
}, },
}, },
}, },
@@ -107,11 +138,11 @@ export class UserService {
/** /**
* Create a new Account for a given User * Create a new Account for a given User
* *
* @param {AuthUser} user User object * @param user User object
* @param {string} accessToken User's access token generated by providers * @param accessToken User's access token generated by providers
* @param {string} refreshToken User's refresh token generated by providers * @param refreshToken User's refresh token generated by providers
* @param {any} profile Data received from SSO provider on the users account * @param profile Data received from SSO provider on the users account
* @returns {Promise<Account>} Created Account * @returns Created Account
*/ */
async createProviderAccount( async createProviderAccount(
user: AuthUser, user: AuthUser,
@@ -139,9 +170,9 @@ export class UserService {
/** /**
* Update User displayName and photoURL * Update User displayName and photoURL
* *
* @param {AuthUser} user User object * @param user User object
* @param {any} profile Data received from SSO provider on the users account * @param profile Data received from SSO provider on the users account
* @returns {Promise<E.Right<User> | E.Left<"user/not_found">>} Updated user object * @returns Updated user object
*/ */
async updateUserDetails(user: AuthUser, profile) { async updateUserDetails(user: AuthUser, profile) {
try { try {

View File

@@ -7,7 +7,7 @@ import * as T from 'fp-ts/Task';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
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 { AuthErrorHandler } from './types/AuthErrorHandler'; import { AuthError } from './types/AuthError';
import { AuthTokens } from './types/AuthTokens'; import { AuthTokens } from './types/AuthTokens';
import { Response } from 'express'; import { Response } from 'express';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
@@ -27,7 +27,7 @@ export function throwErr(errMessage: string): never {
* This function allows throw to be used as an expression * This function allows throw to be used as an expression
* @param errMessage Message present in the error message * @param errMessage Message present in the error message
*/ */
export function throwHTTPErr(errorData: AuthErrorHandler): never { export function throwHTTPErr(errorData: AuthError): never {
const { message, statusCode } = errorData; const { message, statusCode } = errorData;
throw new HttpException(message, statusCode); throw new HttpException(message, statusCode);
} }