fix: fixed all issues raised in initial PR review
This commit is contained in:
@@ -8,6 +8,8 @@ POSTMARK_SENDER_EMAIL=************************************************"
|
||||
# Auth Tokens Config
|
||||
SIGNED_COOKIE_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
|
||||
ACCESS_TOKEN_VALIDITY="120000" # Default validity is 1 day
|
||||
|
||||
|
||||
@@ -112,4 +112,4 @@
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6421
packages/hoppscotch-backend/pnpm-lock.yaml
generated
Normal file
6421
packages/hoppscotch-backend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -85,7 +85,7 @@ model User {
|
||||
photoURL String?
|
||||
isAdmin Boolean @default(false)
|
||||
refreshToken String?
|
||||
accounts Account[]
|
||||
providerAccounts Account[]
|
||||
PasswordlessVerification PasswordlessVerification[]
|
||||
settings UserSettings?
|
||||
UserHistory UserHistory[]
|
||||
@@ -109,7 +109,7 @@ model Account {
|
||||
@@unique(fields: [provider, providerAccountId], name: "verifyProviderAccount")
|
||||
}
|
||||
|
||||
model PasswordlessVerification {
|
||||
model VerificationToken {
|
||||
deviceIdentifier String
|
||||
token String @unique @default(cuid())
|
||||
userUid String
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export class refreshTokensDto {
|
||||
refresh_token: string;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// Inputs to initiate Magic-Link auth flow
|
||||
export class signInMagicDto {
|
||||
email: string;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Inputs to verify and sign a user in via magic-link
|
||||
export class verifyMagicDto {
|
||||
deviceIdentifier: string;
|
||||
token: string;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
/**
|
||||
** Decorator to fetch refresh_token from cookie
|
||||
*/
|
||||
export const RTCookie = createParamDecorator(
|
||||
(data: unknown, context: ExecutionContext) => {
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
|
||||
@@ -129,6 +129,12 @@ export const TEAM_REQ_NOT_FOUND = 'team_req/not_found' as const;
|
||||
export const TEAM_REQ_INVALID_TARGET_COLL_ID =
|
||||
'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
|
||||
* (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
|
||||
* (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
|
||||
* (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
|
||||
|
||||
@@ -8,7 +8,7 @@ export type MailDescription = {
|
||||
};
|
||||
|
||||
export type UserMagicLinkMailDescription = {
|
||||
template: 'code-your-own'; //Alias of template in Postmark, change this to env variable
|
||||
template: 'code-your-own';
|
||||
variables: {
|
||||
inviteeEmail: string;
|
||||
magicLink: string;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import * as postmark from 'postmark';
|
||||
import { throwErr } from 'src/utils';
|
||||
import * as TE from 'fp-ts/TaskEither';
|
||||
import { EMAIL_FAILED } from 'src/errors';
|
||||
import { EMAIL_FAILED, SENDER_EMAIL_INVALID } from 'src/errors';
|
||||
|
||||
@Injectable()
|
||||
export class MailerService implements OnModuleInit {
|
||||
@@ -15,8 +15,7 @@ export class MailerService implements OnModuleInit {
|
||||
|
||||
onModuleInit() {
|
||||
this.client = new postmark.ServerClient(
|
||||
process.env.POSTMARK_SERVER_TOKEN ||
|
||||
throwErr('No Postmark Server Token defined'),
|
||||
process.env.POSTMARK_SERVER_TOKEN || throwErr(SENDER_EMAIL_INVALID),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,8 +28,7 @@ export class MailerService implements OnModuleInit {
|
||||
this.client.sendEmailWithTemplate({
|
||||
To: to,
|
||||
From:
|
||||
process.env.POSTMARK_SENDER_EMAIL ||
|
||||
throwErr('No Postmark Sender Email defined'),
|
||||
process.env.POSTMARK_SENDER_EMAIL || throwErr(SENDER_EMAIL_INVALID),
|
||||
TemplateAlias: mailDesc.template,
|
||||
TemplateModel: mailDesc.variables,
|
||||
}),
|
||||
@@ -40,9 +38,9 @@ export class MailerService implements OnModuleInit {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} to Receiver's email id
|
||||
* @param {UserMagicLinkMailDescription} mailDesc Details of email to be sent for Magic-Link auth
|
||||
* @returns {Promise<postmark.Models.MessageSendingResponse>} Response if email was send successfully or not
|
||||
* @param to Receiver's email id
|
||||
* @param mailDesc Details of email to be sent for Magic-Link auth
|
||||
* @returns Response if email was send successfully or not
|
||||
*/
|
||||
async sendAuthEmail(to: string, mailDesc: UserMagicLinkMailDescription) {
|
||||
try {
|
||||
|
||||
10
packages/hoppscotch-backend/src/types/AuthError.ts
Normal file
10
packages/hoppscotch-backend/src/types/AuthError.ts
Normal 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;
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
|
||||
export interface AuthErrorHandler {
|
||||
message: string;
|
||||
statusCode: HttpStatus;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export interface RefreshTokenPayload {
|
||||
iat?: number;
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
export type AuthTokens = {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export interface DeviceIdentifierToken {
|
||||
export type DeviceIdentifierToken = {
|
||||
deviceIdentifier: string;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,11 +74,11 @@ describe('findUserById', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUserMagic', () => {
|
||||
describe('createUserViaMagicLink', () => {
|
||||
test('should successfully create user and account for magic-link given valid inputs', async () => {
|
||||
mockPrisma.user.create.mockResolvedValueOnce(user);
|
||||
|
||||
const result = await userService.createUserMagic(
|
||||
const result = await userService.createUserViaMagicLink(
|
||||
'dwight@dundermifflin.com',
|
||||
);
|
||||
expect(result).toEqual(user);
|
||||
|
||||
@@ -19,8 +19,8 @@ export class UserService {
|
||||
/**
|
||||
* Find User with given email id
|
||||
*
|
||||
* @param {string} email User's email
|
||||
* @returns {Promise<O.None | O.Some<User>>} Option of found User
|
||||
* @param email User's email
|
||||
* @returns Option of found User
|
||||
*/
|
||||
async findUserByEmail(email: string) {
|
||||
try {
|
||||
@@ -38,8 +38,8 @@ export class UserService {
|
||||
/**
|
||||
* Find User with given ID
|
||||
*
|
||||
* @param {string} userUid User ID
|
||||
* @returns {Promise<O.None | O.Some<User>>} Option of found User
|
||||
* @param userUid User ID
|
||||
* @returns Option of found User
|
||||
*/
|
||||
async findUserById(userUid: string) {
|
||||
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
|
||||
*
|
||||
* @param {string} email User's Email
|
||||
* @returns {Promise<User>} Created User
|
||||
* @param email User's Email
|
||||
* @returns Created User
|
||||
*/
|
||||
async createUserMagic(email: string) {
|
||||
async createUserViaMagicLink(email: string) {
|
||||
const createdUser = await this.prisma.user.create({
|
||||
data: {
|
||||
email: email,
|
||||
@@ -79,23 +103,30 @@ export class UserService {
|
||||
/**
|
||||
* Create a new User when logged in via a SSO provider
|
||||
*
|
||||
* @param {string} accessToken User's access token generated by providers
|
||||
* @param {string} refreshToken User's refresh token generated by providers
|
||||
* @param {any} profile Data received from SSO provider on the users account
|
||||
* @returns {Promise<User>} Created User
|
||||
* @param accessTokenSSO User's access token generated by providers
|
||||
* @param refreshTokenSSO User's refresh token generated by providers
|
||||
* @param profile Data received from SSO provider on the users account
|
||||
* @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({
|
||||
data: {
|
||||
displayName: !profile.displayName ? null : profile.displayName,
|
||||
displayName: userDisplayName,
|
||||
email: profile.emails[0].value,
|
||||
photoURL: !profile.photos ? null : profile.photos[0].value,
|
||||
photoURL: userPhotoURL,
|
||||
accounts: {
|
||||
create: {
|
||||
provider: profile.provider,
|
||||
providerAccountId: profile.id,
|
||||
providerRefreshToken: refreshToken,
|
||||
providerAccessToken: accessToken,
|
||||
providerRefreshToken: refreshTokenSSO,
|
||||
providerAccessToken: accessTokenSSO,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -107,11 +138,11 @@ export class UserService {
|
||||
/**
|
||||
* Create a new Account for a given User
|
||||
*
|
||||
* @param {AuthUser} user User object
|
||||
* @param {string} accessToken User's access token generated by providers
|
||||
* @param {string} refreshToken User's refresh token generated by providers
|
||||
* @param {any} profile Data received from SSO provider on the users account
|
||||
* @returns {Promise<Account>} Created Account
|
||||
* @param user User object
|
||||
* @param accessToken User's access token generated by providers
|
||||
* @param refreshToken User's refresh token generated by providers
|
||||
* @param profile Data received from SSO provider on the users account
|
||||
* @returns Created Account
|
||||
*/
|
||||
async createProviderAccount(
|
||||
user: AuthUser,
|
||||
@@ -139,9 +170,9 @@ export class UserService {
|
||||
/**
|
||||
* Update User displayName and photoURL
|
||||
*
|
||||
* @param {AuthUser} user User object
|
||||
* @param {any} profile Data received from SSO provider on the users account
|
||||
* @returns {Promise<E.Right<User> | E.Left<"user/not_found">>} Updated user object
|
||||
* @param user User object
|
||||
* @param profile Data received from SSO provider on the users account
|
||||
* @returns Updated user object
|
||||
*/
|
||||
async updateUserDetails(user: AuthUser, profile) {
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as T from 'fp-ts/Task';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { User } from './user/user.model';
|
||||
import * as A from 'fp-ts/Array';
|
||||
import { AuthErrorHandler } from './types/AuthErrorHandler';
|
||||
import { AuthError } from './types/AuthError';
|
||||
import { AuthTokens } from './types/AuthTokens';
|
||||
import { Response } from 'express';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -27,7 +27,7 @@ export function throwErr(errMessage: string): never {
|
||||
* This function allows throw to be used as an expression
|
||||
* @param errMessage Message present in the error message
|
||||
*/
|
||||
export function throwHTTPErr(errorData: AuthErrorHandler): never {
|
||||
export function throwHTTPErr(errorData: AuthError): never {
|
||||
const { message, statusCode } = errorData;
|
||||
throw new HttpException(message, statusCode);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user