diff --git a/packages/hoppscotch-backend/.env.example b/packages/hoppscotch-backend/.env.example new file mode 100644 index 000000000..f376047db --- /dev/null +++ b/packages/hoppscotch-backend/.env.example @@ -0,0 +1,37 @@ +# Prisma Config +DATABASE_URL=postgresql://postgres:testpass@dev-db:5432/hoppscotch + +# Postmark Config +POSTMARK_SERVER_TOKEN=************************************************" +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 + +# Hoppscotch App Domain Config +APP_DOMAIN="************************************************"" +REDIRECT_URL="************************************************"" +WHITELISTED_ORIGINS="************************************************" + +# Google Auth Config +GOOGLE_CLIENT_ID="************************************************" +GOOGLE_CLIENT_SECRET="************************************************" +GOOGLE_CALLBACK_URL="************************************************" +GOOGLE_SCOPE="['email', 'profile']," + +# Github Auth Config +GITHUB_CLIENT_ID="************************************************" +GITHUB_CLIENT_SECRET="************************************************" +GITHUB_CALLBACK_URL="************************************************" +GITHUB_SCOPE="user:email" + +# Microsoft Auth Config +MICROSOFT_CLIENT_ID="************************************************" +MICROSOFT_CLIENT_SECRET="************************************************" +MICROSOFT_CALLBACK_URL="************************************************" +MICROSOFT_SCOPE="user.read" diff --git a/packages/hoppscotch-backend/.eslintrc.js b/packages/hoppscotch-backend/.eslintrc.js index 259de13c7..f8a28acdd 100644 --- a/packages/hoppscotch-backend/.eslintrc.js +++ b/packages/hoppscotch-backend/.eslintrc.js @@ -21,5 +21,7 @@ module.exports = { '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', + "no-empty-function": "off", + "@typescript-eslint/no-empty-function": "error" }, }; diff --git a/packages/hoppscotch-backend/.gitignore b/packages/hoppscotch-backend/.gitignore index 22f55adc5..3c0649e8e 100644 --- a/packages/hoppscotch-backend/.gitignore +++ b/packages/hoppscotch-backend/.gitignore @@ -2,6 +2,11 @@ /dist /node_modules +.vscode + +.env + + # Logs logs *.log @@ -32,4 +37,4 @@ lerna-debug.log* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json -!.vscode/extensions.json \ No newline at end of file +!.vscode/extensions.json diff --git a/packages/hoppscotch-backend/docker-compose.yml b/packages/hoppscotch-backend/docker-compose.yml index 1480d8735..a27bd2c7d 100644 --- a/packages/hoppscotch-backend/docker-compose.yml +++ b/packages/hoppscotch-backend/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.0' services: local: build: . - command: ["pnpm", "run", "start:dev"] + command: [ "pnpm", "run", "start:dev" ] environment: - PRODUCTION=false - DATABASE_URL=postgresql://postgres:testpass@dev-db:5432/hoppscotch?connect_timeout=300 @@ -23,5 +23,3 @@ services: environment: POSTGRES_PASSWORD: testpass POSTGRES_DB: hoppscotch - - diff --git a/packages/hoppscotch-backend/package.json b/packages/hoppscotch-backend/package.json index bc94a0706..5a1d87096 100644 --- a/packages/hoppscotch-backend/package.json +++ b/packages/hoppscotch-backend/package.json @@ -25,10 +25,15 @@ "@nestjs/common": "^9.2.1", "@nestjs/core": "^9.2.1", "@nestjs/graphql": "^10.1.6", + "@nestjs/jwt": "^10.0.1", + "@nestjs/passport": "^9.0.0", "@nestjs/platform-express": "^9.2.1", "@prisma/client": "^4.7.1", "apollo-server-express": "^3.11.1", "apollo-server-plugin-base": "^3.7.1", + "argon2": "^0.30.3", + "bcrypt": "^5.1.0", + "cookie-parser": "^1.4.6", "express": "^4.17.1", "fp-ts": "^2.13.1", "graphql": "^15.5.0", @@ -37,6 +42,14 @@ "graphql-subscriptions": "^2.0.0", "io-ts": "^2.2.16", "ioredis": "^5.2.4", + "luxon": "^3.2.1", + "passport": "^0.6.0", + "passport-github2": "^0.1.12", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", + "passport-microsoft": "^1.0.0", + "postmark": "^3.0.15", "prisma": "^4.7.1", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", @@ -47,9 +60,17 @@ "@nestjs/schematics": "^9.0.3", "@nestjs/testing": "^9.2.1", "@relmify/jest-fp-ts": "^2.0.2", + "@types/argon2": "^0.15.0", + "@types/bcrypt": "^5.0.0", + "@types/cookie-parser": "^1.4.3", "@types/express": "^4.17.14", + "@types/luxon": "^3.2.0", "@types/jest": "^29.4.0", "@types/node": "^18.11.10", + "@types/passport-github2": "^1.2.5", + "@types/passport-google-oauth20": "^2.0.11", + "@types/passport-jwt": "^3.0.8", + "@types/passport-microsoft": "^0.0.0", "@types/supertest": "^2.0.12", "@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/parser": "^5.45.0", @@ -58,6 +79,7 @@ "eslint-plugin-prettier": "^4.2.1", "jest": "^29.4.1", "jest-mock-extended": "^3.0.1", + "jwt": "link:@types/nestjs/jwt", "prettier": "^2.8.0", "source-map-support": "^0.5.21", "supertest": "^6.3.2", @@ -68,6 +90,14 @@ "typescript": "^4.9.3" }, "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "setupFilesAfterEnv": [ + "@relmify/jest-fp-ts" + ], "preset": "ts-jest", "clearMocks": true, "collectCoverage": true, diff --git a/packages/hoppscotch-backend/prisma/migrations/20230201061003_init/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20230201061003_init/migration.sql new file mode 100644 index 000000000..1f80820f0 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20230201061003_init/migration.sql @@ -0,0 +1,209 @@ +-- CreateEnum +CREATE TYPE "ReqType" AS ENUM ('REST', 'GQL'); + +-- CreateEnum +CREATE TYPE "TeamMemberRole" AS ENUM ('OWNER', 'VIEWER', 'EDITOR'); + +-- CreateTable +CREATE TABLE "Team" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "Team_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamMember" ( + "id" TEXT NOT NULL, + "role" "TeamMemberRole" NOT NULL, + "userUid" TEXT NOT NULL, + "teamID" TEXT NOT NULL, + + CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamInvitation" ( + "id" TEXT NOT NULL, + "teamID" TEXT NOT NULL, + "creatorUid" TEXT NOT NULL, + "inviteeEmail" TEXT NOT NULL, + "inviteeRole" "TeamMemberRole" NOT NULL, + + CONSTRAINT "TeamInvitation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamCollection" ( + "id" TEXT NOT NULL, + "parentID" TEXT, + "teamID" TEXT NOT NULL, + "title" TEXT NOT NULL, + + CONSTRAINT "TeamCollection_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamRequest" ( + "id" TEXT NOT NULL, + "collectionID" TEXT NOT NULL, + "teamID" TEXT NOT NULL, + "title" TEXT NOT NULL, + "request" JSONB NOT NULL, + + CONSTRAINT "TeamRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Shortcode" ( + "id" TEXT NOT NULL, + "request" JSONB NOT NULL, + "creatorUid" TEXT, + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Shortcode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamEnvironment" ( + "id" TEXT NOT NULL, + "teamID" TEXT NOT NULL, + "name" TEXT NOT NULL, + "variables" JSONB NOT NULL, + + CONSTRAINT "TeamEnvironment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "uid" TEXT NOT NULL, + "displayName" TEXT, + "email" TEXT, + "photoURL" TEXT, + "isAdmin" BOOLEAN NOT NULL DEFAULT false, + "refreshToken" TEXT, + "currentRESTSession" JSONB, + "currentGQLSession" JSONB, + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "User_pkey" PRIMARY KEY ("uid") +); + +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "providerRefreshToken" TEXT, + "providerAccessToken" TEXT, + "providerScope" TEXT, + "loggedIn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VerificationToken" ( + "deviceIdentifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "userUid" TEXT NOT NULL, + "expiresOn" TIMESTAMP(3) NOT NULL +); + +-- CreateTable +CREATE TABLE "UserSettings" ( + "id" TEXT NOT NULL, + "userUid" TEXT NOT NULL, + "properties" JSONB NOT NULL, + "updatedOn" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserSettings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserHistory" ( + "id" TEXT NOT NULL, + "userUid" TEXT NOT NULL, + "reqType" "ReqType" NOT NULL, + "request" JSONB NOT NULL, + "responseMetadata" JSONB NOT NULL, + "isStarred" BOOLEAN NOT NULL, + "executedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserHistory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserEnvironment" ( + "id" TEXT NOT NULL, + "userUid" TEXT NOT NULL, + "name" TEXT, + "variables" JSONB NOT NULL, + "isGlobal" BOOLEAN NOT NULL, + + CONSTRAINT "UserEnvironment_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMember_teamID_userUid_key" ON "TeamMember"("teamID", "userUid"); + +-- CreateIndex +CREATE INDEX "TeamInvitation_teamID_idx" ON "TeamInvitation"("teamID"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamInvitation_teamID_inviteeEmail_key" ON "TeamInvitation"("teamID", "inviteeEmail"); + +-- CreateIndex +CREATE UNIQUE INDEX "Shortcode_id_creatorUid_key" ON "Shortcode"("id", "creatorUid"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_deviceIdentifier_token_key" ON "VerificationToken"("deviceIdentifier", "token"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserSettings_userUid_key" ON "UserSettings"("userUid"); + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamInvitation" ADD CONSTRAINT "TeamInvitation_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_parentID_fkey" FOREIGN KEY ("parentID") REFERENCES "TeamCollection"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamRequest" ADD CONSTRAINT "TeamRequest_collectionID_fkey" FOREIGN KEY ("collectionID") REFERENCES "TeamCollection"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamRequest" ADD CONSTRAINT "TeamRequest_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamEnvironment" ADD CONSTRAINT "TeamEnvironment_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserSettings" ADD CONSTRAINT "UserSettings_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserHistory" ADD CONSTRAINT "UserHistory_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserEnvironment" ADD CONSTRAINT "UserEnvironment_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma index 28e11e010..4ab3a1e28 100644 --- a/packages/hoppscotch-backend/prisma/schema.prisma +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -79,15 +79,44 @@ model TeamEnvironment { } model User { - uid String @id @default(cuid()) + uid String @id @default(cuid()) displayName String? - email String? + email String? @unique photoURL String? - currentRESTSession Json? - currentGQLSession Json? + isAdmin Boolean @default(false) + refreshToken String? + providerAccounts Account[] + VerificationToken VerificationToken[] settings UserSettings? UserHistory UserHistory[] UserEnvironments UserEnvironment[] + currentRESTSession Json? + currentGQLSession Json? + createdOn DateTime @default(now()) @db.Timestamp(3) +} + +model Account { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [uid], onDelete: Cascade) + provider String + providerAccountId String + providerRefreshToken String? + providerAccessToken String? + providerScope String? + loggedIn DateTime @default(now()) @db.Timestamp(3) + + @@unique(fields: [provider, providerAccountId], name: "verifyProviderAccount") +} + +model VerificationToken { + deviceIdentifier String + token String @unique @default(cuid()) + userUid String + user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) + expiresOn DateTime @db.Timestamp(3) + + @@unique(fields: [deviceIdentifier, token], name: "passwordless_deviceIdentifier_tokens") } model UserSettings { diff --git a/packages/hoppscotch-backend/src/app.module.ts b/packages/hoppscotch-backend/src/app.module.ts index 80d1d83b4..cbb9cd49c 100644 --- a/packages/hoppscotch-backend/src/app.module.ts +++ b/packages/hoppscotch-backend/src/app.module.ts @@ -3,6 +3,7 @@ import { GraphQLModule } from '@nestjs/graphql'; import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; import { UserModule } from './user/user.module'; import { GQLComplexityPlugin } from './plugins/GQLComplexityPlugin'; +import { AuthModule } from './auth/auth.module'; import { UserSettingsModule } from './user-settings/user-settings.module'; import { UserEnvironmentsModule } from './user-environment/user-environments.module'; import { UserHistoryModule } from './user-history/user-history.module'; @@ -10,6 +11,10 @@ import { UserHistoryModule } from './user-history/user-history.module'; @Module({ imports: [ GraphQLModule.forRoot({ + cors: process.env.PRODUCTION !== 'true' && { + origin: process.env.WHITELISTED_ORIGINS.split(','), + credentials: true, + }, playground: process.env.PRODUCTION !== 'true', debug: process.env.PRODUCTION !== 'true', autoSchemaFile: true, @@ -47,6 +52,7 @@ import { UserHistoryModule } from './user-history/user-history.module'; driver: ApolloDriver, }), UserModule, + AuthModule, UserSettingsModule, UserEnvironmentsModule, UserHistoryModule, diff --git a/packages/hoppscotch-backend/src/auth/auth.controller.ts b/packages/hoppscotch-backend/src/auth/auth.controller.ts new file mode 100644 index 000000000..bde2fc906 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/auth.controller.ts @@ -0,0 +1,135 @@ +import { + Body, + Controller, + Get, + HttpException, + HttpStatus, + Post, + Request, + Res, + UseGuards, +} from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { SignInMagicDto } from './dto/signin-magic.dto'; +import { VerifyMagicDto } from './dto/verify-magic.dto'; +import { Response } from 'express'; +import * as E from 'fp-ts/Either'; +import { RTJwtAuthGuard } from './guards/rt-jwt-auth.guard'; +import { GqlUser } from 'src/decorators/gql-user.decorator'; +import { AuthUser } from 'src/types/AuthUser'; +import { RTCookie } from 'src/decorators/rt-cookie.decorator'; +import { AuthGuard } from '@nestjs/passport'; +import { authCookieHandler, throwHTTPErr } from './helper'; + +@Controller({ path: 'auth', version: '1' }) +export class AuthController { + constructor(private authService: AuthService) {} + + /** + ** Route to initiate magic-link auth for a users email + */ + @Post('signin') + 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.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( + @GqlUser() user: AuthUser, + @RTCookie() refresh_token: string, + @Res() res, + ) { + const newTokenPair = await this.authService.refreshAuthTokens( + refresh_token, + user, + ); + if (E.isLeft(newTokenPair)) throwHTTPErr(newTokenPair.left); + 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) { + const authTokens = await this.authService.generateAuthTokens(req.user.uid); + if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); + 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) { + const authTokens = await this.authService.generateAuthTokens(req.user.uid); + if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); + 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) { + const authTokens = await this.authService.generateAuthTokens(req.user.uid); + if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); + 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'); + res.clearCookie('refresh_token'); + return res.status(200).send(); + } +} diff --git a/packages/hoppscotch-backend/src/auth/auth.module.ts b/packages/hoppscotch-backend/src/auth/auth.module.ts new file mode 100644 index 000000000..4793c2b85 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/auth.module.ts @@ -0,0 +1,35 @@ +import { Module } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { UserModule } from 'src/user/user.module'; +import { MailerModule } from 'src/mailer/mailer.module'; +import { PrismaModule } from 'src/prisma/prisma.module'; +import { PassportModule } from '@nestjs/passport'; +import { JwtModule } from '@nestjs/jwt'; +import { JwtStrategy } from './strategies/jwt.strategy'; +import { RTJwtStrategy } from './strategies/rt-jwt.strategy'; +import { GoogleStrategy } from './strategies/google.strategy'; +import { GithubStrategy } from './strategies/github.strategy'; +import { MicrosoftStrategy } from './strategies/microsoft.strategy'; + +@Module({ + imports: [ + PrismaModule, + UserModule, + MailerModule, + PassportModule, + JwtModule.register({ + secret: process.env.JWT_SECRET, + }), + ], + providers: [ + AuthService, + JwtStrategy, + RTJwtStrategy, + GoogleStrategy, + GithubStrategy, + MicrosoftStrategy, + ], + controllers: [AuthController], +}) +export class AuthModule {} diff --git a/packages/hoppscotch-backend/src/auth/auth.service.spec.ts b/packages/hoppscotch-backend/src/auth/auth.service.spec.ts new file mode 100644 index 000000000..a445634f8 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/auth.service.spec.ts @@ -0,0 +1,366 @@ +import { HttpStatus } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Account, VerificationToken } from '@prisma/client'; +import { mockDeep, mockFn } from 'jest-mock-extended'; +import { + INVALID_EMAIL, + INVALID_MAGIC_LINK_DATA, + INVALID_REFRESH_TOKEN, + MAGIC_LINK_EXPIRED, + VERIFICATION_TOKEN_DATA_NOT_FOUND, + USER_NOT_FOUND, +} from 'src/errors'; +import { MailerService } from 'src/mailer/mailer.service'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { AuthUser } from 'src/types/AuthUser'; +import { UserService } from 'src/user/user.service'; +import { AuthService } from './auth.service'; +import * as O from 'fp-ts/Option'; +import { VerifyMagicDto } from './dto/verify-magic.dto'; +import { DateTime } from 'luxon'; +import * as argon2 from 'argon2'; +import * as E from 'fp-ts/Either'; + +const mockPrisma = mockDeep(); +const mockUser = mockDeep(); +const mockJWT = mockDeep(); +const mockMailer = mockDeep(); + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const authService = new AuthService(mockUser, mockPrisma, mockJWT, mockMailer); + +const currentTime = new Date(); + +const user: AuthUser = { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + createdOn: currentTime, + currentGQLSession: {}, + currentRESTSession: {}, +}; + +const passwordlessData: VerificationToken = { + deviceIdentifier: 'k23hb7u7gdcujhb', + token: 'jhhj24sdjvl', + userUid: user.uid, + expiresOn: new Date(), +}; + +const magicLinkVerify: VerifyMagicDto = { + deviceIdentifier: 'Dscdc', + token: 'SDcsdc', +}; + +const accountDetails: Account = { + id: '123dcdc', + userId: user.uid, + provider: 'email', + providerAccountId: user.uid, + providerRefreshToken: 'dscsdc', + providerAccessToken: 'sdcsdcsdc', + providerScope: 'user.email', + loggedIn: currentTime, +}; + +let nowPlus30 = new Date(); +nowPlus30.setMinutes(nowPlus30.getMinutes() + 30); +nowPlus30 = new Date(nowPlus30); + +const encodedRefreshToken = + '$argon2id$v=19$m=65536,t=3,p=4$JTP8yZ8YXMHdafb5pB9Rfg$tdZrILUxMb9dQbu0uuyeReLgKxsgYnyUNbc5ZxQmy5I'; + +describe('signInMagicLink', () => { + test('Should throw error if email is not in valid format', async () => { + const result = await authService.signInMagicLink('bbbgmail.com'); + expect(result).toEqualLeft({ + message: INVALID_EMAIL, + statusCode: HttpStatus.BAD_REQUEST, + }); + }); + + test('Should successfully create a new user account and return the passwordless details', async () => { + // check to see if user exists, return none + mockUser.findUserByEmail.mockResolvedValue(O.none); + // create new user + mockUser.createUserViaMagicLink.mockResolvedValue(user); + // create new entry in VerificationToken table + mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData); + + const result = await authService.signInMagicLink( + 'dwight@dundermifflin.com', + ); + expect(result).toEqualRight({ + deviceIdentifier: passwordlessData.deviceIdentifier, + }); + }); + + test('Should successfully return the passwordless details for a pre-existing user account', async () => { + // check to see if user exists, return error + mockUser.findUserByEmail.mockResolvedValueOnce(O.some(user)); + // create new entry in VerificationToken table + mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData); + + const result = await authService.signInMagicLink( + 'dwight@dundermifflin.com', + ); + expect(result).toEqualRight({ + deviceIdentifier: passwordlessData.deviceIdentifier, + }); + }); +}); + +describe('verifyMagicLinkTokens', () => { + test('Should throw INVALID_MAGIC_LINK_DATA if data is invalid', async () => { + mockPrisma.verificationToken.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await authService.verifyMagicLinkTokens(magicLinkVerify); + expect(result).toEqualLeft({ + message: INVALID_MAGIC_LINK_DATA, + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + test('Should throw USER_NOT_FOUND if user is invalid', async () => { + // validatePasswordlessTokens + mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce( + passwordlessData, + ); + // findUserById + mockUser.findUserById.mockResolvedValue(O.none); + + const result = await authService.verifyMagicLinkTokens(magicLinkVerify); + expect(result).toEqualLeft({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + test('Should successfully return auth token pair with provider account existing', async () => { + // validatePasswordlessTokens + mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({ + ...passwordlessData, + expiresOn: nowPlus30, + }); + // findUserById + mockUser.findUserById.mockResolvedValue(O.some(user)); + // checkIfProviderAccountExists + mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails); + // mockPrisma.account.findUnique.mockResolvedValueOnce(null); + // generateAuthTokens + mockJWT.sign.mockReturnValue(user.refreshToken); + // UpdateUserRefreshToken + mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user)); + // deletePasswordlessVerificationToken + mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData); + + const result = await authService.verifyMagicLinkTokens(magicLinkVerify); + expect(result).toEqualRight({ + access_token: user.refreshToken, + refresh_token: user.refreshToken, + }); + }); + + test('Should successfully return auth token pair with provider account not existing', async () => { + // validatePasswordlessTokens + mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({ + ...passwordlessData, + expiresOn: nowPlus30, + }); + // findUserById + mockUser.findUserById.mockResolvedValue(O.some(user)); + // checkIfProviderAccountExists + mockPrisma.account.findUnique.mockResolvedValueOnce(null); + mockUser.createUserSSO.mockResolvedValueOnce(user); + // generateAuthTokens + mockJWT.sign.mockReturnValue(user.refreshToken); + // UpdateUserRefreshToken + mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user)); + // deletePasswordlessVerificationToken + mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData); + + const result = await authService.verifyMagicLinkTokens(magicLinkVerify); + expect(result).toEqualRight({ + access_token: user.refreshToken, + refresh_token: user.refreshToken, + }); + }); + + test('Should throw MAGIC_LINK_EXPIRED if passwordless token is expired', async () => { + // validatePasswordlessTokens + mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce( + passwordlessData, + ); + // findUserById + mockUser.findUserById.mockResolvedValue(O.some(user)); + // checkIfProviderAccountExists + mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails); + + const result = await authService.verifyMagicLinkTokens(magicLinkVerify); + expect(result).toEqualLeft({ + message: MAGIC_LINK_EXPIRED, + statusCode: HttpStatus.UNAUTHORIZED, + }); + }); + + test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => { + // validatePasswordlessTokens + mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({ + ...passwordlessData, + expiresOn: nowPlus30, + }); + // findUserById + mockUser.findUserById.mockResolvedValue(O.some(user)); + // checkIfProviderAccountExists + mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails); + // mockPrisma.account.findUnique.mockResolvedValueOnce(null); + // generateAuthTokens + mockJWT.sign.mockReturnValue(user.refreshToken); + // UpdateUserRefreshToken + mockUser.UpdateUserRefreshToken.mockResolvedValueOnce( + E.left(USER_NOT_FOUND), + ); + + const result = await authService.verifyMagicLinkTokens(magicLinkVerify); + expect(result).toEqualLeft({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + test('Should throw PASSWORDLESS_DATA_NOT_FOUND when deleting passwordlessVerification entry from DB', async () => { + // validatePasswordlessTokens + mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({ + ...passwordlessData, + expiresOn: nowPlus30, + }); + // findUserById + mockUser.findUserById.mockResolvedValue(O.some(user)); + // checkIfProviderAccountExists + mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails); + // mockPrisma.account.findUnique.mockResolvedValueOnce(null); + // generateAuthTokens + mockJWT.sign.mockReturnValue(user.refreshToken); + // UpdateUserRefreshToken + mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user)); + // deletePasswordlessVerificationToken + mockPrisma.verificationToken.delete.mockRejectedValueOnce('RecordNotFound'); + + const result = await authService.verifyMagicLinkTokens(magicLinkVerify); + expect(result).toEqualLeft({ + message: VERIFICATION_TOKEN_DATA_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); +}); + +describe('generateAuthTokens', () => { + test('Should successfully generate tokens with valid inputs', async () => { + mockJWT.sign.mockReturnValue(user.refreshToken); + // UpdateUserRefreshToken + mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user)); + + const result = await authService.generateAuthTokens(user.uid); + expect(result).toEqualRight({ + access_token: 'hbfvdkhjbvkdvdfjvbnkhjb', + refresh_token: 'hbfvdkhjbvkdvdfjvbnkhjb', + }); + }); + + test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => { + mockJWT.sign.mockReturnValue(user.refreshToken); + // UpdateUserRefreshToken + mockUser.UpdateUserRefreshToken.mockResolvedValueOnce( + E.left(USER_NOT_FOUND), + ); + + const result = await authService.generateAuthTokens(user.uid); + expect(result).toEqualLeft({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); +}); + +jest.mock('argon2', () => { + return { + verify: jest.fn((x, y) => { + if (y === null) return false; + return true; + }), + hash: jest.fn(), + }; +}); + +describe('refreshAuthTokens', () => { + test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => { + // generateAuthTokens + mockJWT.sign.mockReturnValue(user.refreshToken); + // UpdateUserRefreshToken + mockUser.UpdateUserRefreshToken.mockResolvedValueOnce( + E.left(USER_NOT_FOUND), + ); + + const result = await authService.refreshAuthTokens( + '$argon2id$v=19$m=65536,t=3,p=4$MvVOam2clCOLtJFGEE26ZA$czvA5ez9hz+A/LML8QRgqgaFuWa5JcbwkH6r+imTQbs', + user, + ); + expect(result).toEqualLeft({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + test('Should throw USER_NOT_FOUND when user is invalid', async () => { + const result = await authService.refreshAuthTokens( + 'jshdcbjsdhcbshdbc', + null, + ); + expect(result).toEqualLeft({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + test('Should successfully refresh the tokens and generate a new auth token pair', async () => { + // generateAuthTokens + mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb'); + // UpdateUserRefreshToken + mockUser.UpdateUserRefreshToken.mockResolvedValueOnce( + E.right({ + ...user, + refreshToken: 'sdhjcbjsdhcbshjdcb', + }), + ); + + const result = await authService.refreshAuthTokens( + '$argon2id$v=19$m=65536,t=3,p=4$MvVOam2clCOLtJFGEE26ZA$czvA5ez9hz+A/LML8QRgqgaFuWa5JcbwkH6r+imTQbs', + user, + ); + expect(result).toEqualRight({ + access_token: 'sdhjcbjsdhcbshjdcb', + refresh_token: 'sdhjcbjsdhcbshjdcb', + }); + }); + + test('Should throw INVALID_REFRESH_TOKEN when the refresh token is invalid', async () => { + // generateAuthTokens + mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb'); + mockPrisma.user.update.mockResolvedValueOnce({ + ...user, + refreshToken: 'sdhjcbjsdhcbshjdcb', + }); + + const result = await authService.refreshAuthTokens(null, user); + expect(result).toEqualLeft({ + message: INVALID_REFRESH_TOKEN, + statusCode: HttpStatus.NOT_FOUND, + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/auth/auth.service.ts b/packages/hoppscotch-backend/src/auth/auth.service.ts new file mode 100644 index 000000000..44b3e6396 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/auth.service.ts @@ -0,0 +1,342 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { MailerService } from 'src/mailer/mailer.service'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { User } from 'src/user/user.model'; +import { UserService } from 'src/user/user.service'; +import { VerifyMagicDto } from './dto/verify-magic.dto'; +import { DateTime } from 'luxon'; +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 { DeviceIdentifierToken } from 'src/types/Passwordless'; +import { + INVALID_EMAIL, + INVALID_MAGIC_LINK_DATA, + VERIFICATION_TOKEN_DATA_NOT_FOUND, + MAGIC_LINK_EXPIRED, + USER_NOT_FOUND, + INVALID_REFRESH_TOKEN, +} from 'src/errors'; +import { validateEmail } from 'src/utils'; +import { + AccessTokenPayload, + AuthTokens, + RefreshTokenPayload, +} from 'src/types/AuthTokens'; +import { JwtService } from '@nestjs/jwt'; +import { AuthError } from 'src/types/AuthError'; +import { AuthUser } from 'src/types/AuthUser'; +import { VerificationToken } from '@prisma/client'; + +@Injectable() +export class AuthService { + constructor( + private usersService: UserService, + private prismaService: PrismaService, + private jwtService: JwtService, + private readonly mailerService: MailerService, + ) {} + + /** + * Generate Id and token for email Magic-Link auth + * + * @param user User Object + * @returns Created VerificationToken token + */ + 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.verificationToken.create({ + data: { + deviceIdentifier: salt, + userUid: user.uid, + expiresOn: expiresOn, + }, + }); + + return idToken; + } + + /** + * Check if VerificationToken exist or not + * + * @param magicLinkTokens Object containing deviceIdentifier and token + * @returns Option of VerificationToken token + */ + private async validatePasswordlessTokens(magicLinkTokens: VerifyMagicDto) { + try { + const tokens = + await this.prismaService.verificationToken.findUniqueOrThrow({ + where: { + passwordless_deviceIdentifier_tokens: { + deviceIdentifier: magicLinkTokens.deviceIdentifier, + token: magicLinkTokens.token, + }, + }, + }); + return O.some(tokens); + } catch (error) { + return O.none; + } + } + + /** + * Generate new refresh token for user + * + * @param userUid User Id + * @returns Generated refreshToken + */ + private async generateRefreshToken(userUid: string) { + const refreshTokenPayload: RefreshTokenPayload = { + iss: process.env.APP_DOMAIN, + sub: userUid, + aud: [process.env.APP_DOMAIN], + }; + + const refreshToken = await this.jwtService.sign(refreshTokenPayload, { + expiresIn: process.env.REFRESH_TOKEN_VALIDITY, //7 Days + }); + + const refreshTokenHash = await argon2.hash(refreshToken); + + const updatedUser = await this.usersService.UpdateUserRefreshToken( + refreshTokenHash, + userUid, + ); + if (E.isLeft(updatedUser)) + return E.left({ + message: updatedUser.left, + statusCode: HttpStatus.NOT_FOUND, + }); + + return E.right(refreshToken); + } + + /** + * Generate access and refresh token pair + * + * @param userUid User ID + * @returns Either of generated AuthTokens + */ + async generateAuthTokens(userUid: string) { + const accessTokenPayload: AccessTokenPayload = { + iss: process.env.APP_DOMAIN, + sub: userUid, + aud: [process.env.APP_DOMAIN], + }; + + const refreshToken = await this.generateRefreshToken(userUid); + if (E.isLeft(refreshToken)) return E.left(refreshToken.left); + + return E.right({ + access_token: await this.jwtService.sign(accessTokenPayload, { + expiresIn: process.env.ACCESS_TOKEN_VALIDITY, //1 Day + }), + refresh_token: refreshToken.right, + }); + } + + /** + * Deleted used VerificationToken tokens + * + * @param passwordlessTokens VerificationToken entry to delete from DB + * @returns Either of deleted VerificationToken token + */ + private async deleteMagicLinkVerificationTokens( + passwordlessTokens: VerificationToken, + ) { + try { + const deletedPasswordlessToken = + await this.prismaService.verificationToken.delete({ + where: { + passwordless_deviceIdentifier_tokens: { + deviceIdentifier: passwordlessTokens.deviceIdentifier, + token: passwordlessTokens.token, + }, + }, + }); + return E.right(deletedPasswordlessToken); + } catch (error) { + return E.left(VERIFICATION_TOKEN_DATA_NOT_FOUND); + } + } + + /** + * Verify if Provider account exists for User + * + * @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, SSOUserData) { + const provider = await this.prismaService.account.findUnique({ + where: { + verifyProviderAccount: { + provider: SSOUserData.provider, + providerAccountId: SSOUserData.id, + }, + }, + }); + + if (!provider) return O.none; + + return O.some(provider); + } + + /** + * Create User (if not already present) and send email to initiate Magic-Link auth + * + * @param email User's email + * @returns Either containing DeviceIdentifierToken + */ + async signInMagicLink(email: string) { + if (!validateEmail(email)) + return E.left({ + message: INVALID_EMAIL, + statusCode: HttpStatus.BAD_REQUEST, + }); + + let user: AuthUser; + const queriedUser = await this.usersService.findUserByEmail(email); + + if (O.isNone(queriedUser)) { + user = await this.usersService.createUserViaMagicLink(email); + } else { + user = queriedUser.value; + } + + const generatedTokens = await this.generateMagicLinkTokens(user); + + await this.mailerService.sendAuthEmail(email, { + template: 'code-your-own', + variables: { + inviteeEmail: email, + magicLink: `${process.env.APP_DOMAIN}/magic-link?token=${generatedTokens.token}`, + }, + }); + + return E.right({ + deviceIdentifier: generatedTokens.deviceIdentifier, + }); + } + + /** + * Verify and authenticate user from received data for Magic-Link + * + * @param magicLinkIDTokens magic-link verification tokens from client + * @returns Either of generated AuthTokens + */ + async verifyMagicLinkTokens( + magicLinkIDTokens: VerifyMagicDto, + ): Promise | E.Left> { + const passwordlessTokens = await this.validatePasswordlessTokens( + magicLinkIDTokens, + ); + if (O.isNone(passwordlessTokens)) + return E.left({ + message: INVALID_MAGIC_LINK_DATA, + statusCode: HttpStatus.NOT_FOUND, + }); + + const user = await this.usersService.findUserById( + passwordlessTokens.value.userUid, + ); + if (O.isNone(user)) + return E.left({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + + /** + * * Check to see if entry for Magic-Link is present in the Account table for user + * * If user was created with another provider findUserById may return true + */ + const profile = { + provider: 'magic', + id: user.value.email, + }; + const providerAccountExists = await this.checkIfProviderAccountExists( + user.value, + profile, + ); + + if (O.isNone(providerAccountExists)) { + await this.usersService.createProviderAccount( + user.value, + null, + null, + profile, + ); + } + + const currentTime = DateTime.now().toISO(); + if (currentTime > passwordlessTokens.value.expiresOn.toISOString()) + return E.left({ + message: MAGIC_LINK_EXPIRED, + statusCode: HttpStatus.UNAUTHORIZED, + }); + + const tokens = await this.generateAuthTokens( + passwordlessTokens.value.userUid, + ); + if (E.isLeft(tokens)) + return E.left({ + message: tokens.left.message, + statusCode: tokens.left.statusCode, + }); + + const deletedPasswordlessToken = + await this.deleteMagicLinkVerificationTokens(passwordlessTokens.value); + if (E.isLeft(deletedPasswordlessToken)) + return E.left({ + message: deletedPasswordlessToken.left, + statusCode: HttpStatus.NOT_FOUND, + }); + + return E.right(tokens.right); + } + + /** + * Refresh refresh and auth tokens + * + * @param hashedRefreshToken Hashed refresh token received from client + * @param user User Object + * @returns Either of generated AuthTokens + */ + 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, + }); + + // 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({ + message: generatedAuthTokens.left.message, + statusCode: generatedAuthTokens.left.statusCode, + }); + + return E.right(generatedAuthTokens.right); + } +} diff --git a/packages/hoppscotch-backend/src/auth/dto/signin-magic.dto.ts b/packages/hoppscotch-backend/src/auth/dto/signin-magic.dto.ts new file mode 100644 index 000000000..a3b1aa27c --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/dto/signin-magic.dto.ts @@ -0,0 +1,4 @@ +// Inputs to initiate Magic-Link auth flow +export class SignInMagicDto { + email: string; +} diff --git a/packages/hoppscotch-backend/src/auth/dto/verify-magic.dto.ts b/packages/hoppscotch-backend/src/auth/dto/verify-magic.dto.ts new file mode 100644 index 000000000..958729dd4 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/dto/verify-magic.dto.ts @@ -0,0 +1,5 @@ +// Inputs to verify and sign a user in via magic-link +export class VerifyMagicDto { + deviceIdentifier: string; + token: string; +} diff --git a/packages/hoppscotch-backend/src/auth/guards/jwt-auth.guard.ts b/packages/hoppscotch-backend/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 000000000..2155290ed --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/packages/hoppscotch-backend/src/auth/guards/rt-jwt-auth.guard.ts b/packages/hoppscotch-backend/src/auth/guards/rt-jwt-auth.guard.ts new file mode 100644 index 000000000..5b4ec6816 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/guards/rt-jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class RTJwtAuthGuard extends AuthGuard('jwt-refresh') {} diff --git a/packages/hoppscotch-backend/src/auth/helper.ts b/packages/hoppscotch-backend/src/auth/helper.ts new file mode 100644 index 000000000..43c3ab94d --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/helper.ts @@ -0,0 +1,56 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { DateTime } from 'luxon'; +import { AuthError } from 'src/types/AuthError'; +import { AuthTokens } from 'src/types/AuthTokens'; +import { Response } from 'express'; + +/** + * This function allows throw to be used as an expression + * @param errMessage Message present in the error message + */ +export function throwHTTPErr(errorData: AuthError): never { + const { message, statusCode } = errorData; + throw new HttpException(message, statusCode); +} + +/** + * 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, +) => { + const currentTime = DateTime.now(); + const accessTokenValidity = currentTime + .plus({ + milliseconds: parseInt(process.env.ACCESS_TOKEN_VALIDITY), + }) + .toMillis(); + const refreshTokenValidity = currentTime + .plus({ + milliseconds: parseInt(process.env.REFRESH_TOKEN_VALIDITY), + }) + .toMillis(); + + res.cookie('access_token', authTokens.access_token, { + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: accessTokenValidity, + signed: true, + }); + res.cookie('refresh_token', authTokens.refresh_token, { + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: refreshTokenValidity, + signed: true, + }); + if (redirect) { + res.status(HttpStatus.OK).redirect(process.env.REDIRECT_URL); + } else res.status(HttpStatus.OK).send(); +}; diff --git a/packages/hoppscotch-backend/src/auth/strategies/github.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/github.strategy.ts new file mode 100644 index 000000000..e01bc59a0 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/strategies/github.strategy.ts @@ -0,0 +1,67 @@ +import { Strategy } from 'passport-github2'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthService } from '../auth.service'; +import { UserService } from 'src/user/user.service'; +import * as O from 'fp-ts/Option'; +import * as E from 'fp-ts/Either'; + +@Injectable() +export class GithubStrategy extends PassportStrategy(Strategy) { + constructor( + private authService: AuthService, + private usersService: UserService, + ) { + super({ + clientID: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + callbackURL: process.env.GITHUB_CALLBACK_URL, + scope: [process.env.GITHUB_SCOPE], + }); + } + + async validate(accessToken, refreshToken, profile, done) { + const user = await this.usersService.findUserByEmail( + profile.emails[0].value, + ); + + if (O.isNone(user)) { + const createdUser = await this.usersService.createUserSSO( + accessToken, + refreshToken, + profile, + ); + return createdUser; + } + + /** + * * displayName and photoURL maybe null if user logged-in via magic-link before SSO + */ + if (!user.value.displayName || !user.value.photoURL) { + const updatedUser = await this.usersService.updateUserDetails( + user.value, + profile, + ); + if (E.isLeft(updatedUser)) { + throw new UnauthorizedException(updatedUser.left); + } + } + + /** + * * Check to see if entry for Github is present in the Account table for user + * * If user was created with another provider findUserByEmail may return true + */ + const providerAccountExists = + await this.authService.checkIfProviderAccountExists(user.value, profile); + + if (O.isNone(providerAccountExists)) + await this.usersService.createProviderAccount( + user.value, + accessToken, + refreshToken, + profile, + ); + + return user.value; + } +} diff --git a/packages/hoppscotch-backend/src/auth/strategies/google.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/google.strategy.ts new file mode 100644 index 000000000..4420dd919 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/strategies/google.strategy.ts @@ -0,0 +1,67 @@ +import { Strategy, VerifyCallback } from 'passport-google-oauth20'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { UserService } from 'src/user/user.service'; +import * as O from 'fp-ts/Option'; +import { AuthService } from '../auth.service'; +import * as E from 'fp-ts/Either'; + +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy) { + constructor( + private usersService: UserService, + private authService: AuthService, + ) { + super({ + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: process.env.GOOGLE_CALLBACK_URL, + scope: process.env.GOOGLE_SCOPE.split(','), + }); + } + + async validate(accessToken, refreshToken, profile, done: VerifyCallback) { + const user = await this.usersService.findUserByEmail( + profile.emails[0].value, + ); + + if (O.isNone(user)) { + const createdUser = await this.usersService.createUserSSO( + accessToken, + refreshToken, + profile, + ); + return createdUser; + } + + /** + * * displayName and photoURL maybe null if user logged-in via magic-link before SSO + */ + if (!user.value.displayName || !user.value.photoURL) { + const updatedUser = await this.usersService.updateUserDetails( + user.value, + profile, + ); + if (E.isLeft(updatedUser)) { + throw new UnauthorizedException(updatedUser.left); + } + } + + /** + * * Check to see if entry for Google is present in the Account table for user + * * If user was created with another provider findUserByEmail may return true + */ + const providerAccountExists = + await this.authService.checkIfProviderAccountExists(user.value, profile); + + if (O.isNone(providerAccountExists)) + await this.usersService.createProviderAccount( + user.value, + accessToken, + refreshToken, + profile, + ); + + return user.value; + } +} diff --git a/packages/hoppscotch-backend/src/auth/strategies/jwt.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 000000000..82c2ca735 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,46 @@ +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { + Injectable, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; +import { AccessTokenPayload } from 'src/types/AuthTokens'; +import { UserService } from 'src/user/user.service'; +import { AuthService } from '../auth.service'; +import { Request } from 'express'; +import * as O from 'fp-ts/Option'; +import { + COOKIES_NOT_FOUND, + INVALID_ACCESS_TOKEN, + USER_NOT_FOUND, +} from 'src/errors'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor(private usersService: UserService) { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([ + (request: Request) => { + const ATCookie = request.signedCookies['access_token']; + if (!ATCookie) { + throw new ForbiddenException(COOKIES_NOT_FOUND); + } + return ATCookie; + }, + ]), + secretOrKey: process.env.JWT_SECRET, + }); + } + + async validate(payload: AccessTokenPayload) { + if (!payload) throw new ForbiddenException(INVALID_ACCESS_TOKEN); + + const user = await this.usersService.findUserById(payload.sub); + if (O.isNone(user)) { + throw new UnauthorizedException(USER_NOT_FOUND); + } + + return user.value; + } +} diff --git a/packages/hoppscotch-backend/src/auth/strategies/microsoft.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/microsoft.strategy.ts new file mode 100644 index 000000000..cdffce8e6 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/strategies/microsoft.strategy.ts @@ -0,0 +1,67 @@ +import { Strategy } from 'passport-microsoft'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthService } from '../auth.service'; +import { UserService } from 'src/user/user.service'; +import * as O from 'fp-ts/Option'; +import * as E from 'fp-ts/Either'; + +@Injectable() +export class MicrosoftStrategy extends PassportStrategy(Strategy) { + constructor( + private authService: AuthService, + private usersService: UserService, + ) { + super({ + clientID: process.env.MICROSOFT_CLIENT_ID, + clientSecret: process.env.MICROSOFT_CLIENT_SECRET, + callbackURL: process.env.MICROSOFT_CALLBACK_URL, + scope: [process.env.MICROSOFT_SCOPE], + }); + } + + async validate(accessToken: string, refreshToken: string, profile, done) { + const user = await this.usersService.findUserByEmail( + profile.emails[0].value, + ); + + if (O.isNone(user)) { + const createdUser = await this.usersService.createUserSSO( + accessToken, + refreshToken, + profile, + ); + return createdUser; + } + + /** + * * displayName and photoURL maybe null if user logged-in via magic-link before SSO + */ + if (!user.value.displayName || !user.value.photoURL) { + const updatedUser = await this.usersService.updateUserDetails( + user.value, + profile, + ); + if (E.isLeft(updatedUser)) { + throw new UnauthorizedException(updatedUser.left); + } + } + + /** + * * Check to see if entry for Microsoft is present in the Account table for user + * * If user was created with another provider findUserByEmail may return true + */ + const providerAccountExists = + await this.authService.checkIfProviderAccountExists(user.value, profile); + + if (O.isNone(providerAccountExists)) + await this.usersService.createProviderAccount( + user.value, + accessToken, + refreshToken, + profile, + ); + + return user.value; + } +} diff --git a/packages/hoppscotch-backend/src/auth/strategies/rt-jwt.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/rt-jwt.strategy.ts new file mode 100644 index 000000000..ae6e96c3c --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/strategies/rt-jwt.strategy.ts @@ -0,0 +1,45 @@ +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { + Injectable, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; +import { UserService } from 'src/user/user.service'; +import { Request } from 'express'; +import { RefreshTokenPayload } from 'src/types/AuthTokens'; +import { + COOKIES_NOT_FOUND, + INVALID_REFRESH_TOKEN, + USER_NOT_FOUND, +} from 'src/errors'; +import * as O from 'fp-ts/Option'; + +@Injectable() +export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { + constructor(private usersService: UserService) { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([ + (request: Request) => { + const RTCookie = request.signedCookies['refresh_token']; + if (!RTCookie) { + throw new ForbiddenException(COOKIES_NOT_FOUND); + } + return RTCookie; + }, + ]), + secretOrKey: process.env.JWT_SECRET, + }); + } + + async validate(payload: RefreshTokenPayload) { + if (!payload) throw new ForbiddenException(INVALID_REFRESH_TOKEN); + + const user = await this.usersService.findUserById(payload.sub); + if (O.isNone(user)) { + throw new UnauthorizedException(USER_NOT_FOUND); + } + + return user.value; + } +} diff --git a/packages/hoppscotch-backend/src/decorators/gql-user.decorator.ts b/packages/hoppscotch-backend/src/decorators/gql-user.decorator.ts index 2c4da3b52..83d71e56b 100644 --- a/packages/hoppscotch-backend/src/decorators/gql-user.decorator.ts +++ b/packages/hoppscotch-backend/src/decorators/gql-user.decorator.ts @@ -1,17 +1,9 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import { User } from '../user/user.model'; import { GqlExecutionContext } from '@nestjs/graphql'; -export const GqlUser = createParamDecorator( - (_data: any, context: ExecutionContext) => { - const { user } = GqlExecutionContext.create(context).getContext<{ - user: User; - }>(); - if (!user) - throw new Error( - '@GqlUser decorator use with null user. Make sure the resolve has the @GqlAuthGuard present.', - ); - - return user; +export const GqlUser = createParamDecorator( + (data: unknown, context: ExecutionContext) => { + const ctx = GqlExecutionContext.create(context); + return ctx.getContext().req.user; }, ); diff --git a/packages/hoppscotch-backend/src/decorators/rt-cookie.decorator.ts b/packages/hoppscotch-backend/src/decorators/rt-cookie.decorator.ts new file mode 100644 index 000000000..97f503f71 --- /dev/null +++ b/packages/hoppscotch-backend/src/decorators/rt-cookie.decorator.ts @@ -0,0 +1,12 @@ +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); + return ctx.getContext().req.signedCookies['refresh_token']; + }, +); diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index c78df062a..f53711e84 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -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 @@ -309,3 +317,46 @@ export const BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES = */ export const BUG_TEAM_ENV_GUARD_NO_ENV_ID = 'bug/team_env/guard_no_env_id' as const; + +/** + * The data sent to the verify route are invalid + * (AuthService) + */ +export const INVALID_MAGIC_LINK_DATA = 'auth/magic_link_invalid_data' as const; + +/** + * Could not find VerificationToken entry in the db + * (AuthService) + */ +export const VERIFICATION_TOKEN_DATA_NOT_FOUND = + 'auth/verification_token_data_not_found' as const; + +/** + * Auth Tokens expired + * (AuthService) + */ +export const TOKEN_EXPIRED = 'auth/token_expired' as const; + +/** + * VerificationToken Tokens expired i.e. magic-link expired + * (AuthService) + */ +export const MAGIC_LINK_EXPIRED = 'auth/magic_link_expired' as const; + +/** + * No cookies were found in the auth request + * (AuthService) + */ +export const COOKIES_NOT_FOUND = 'auth/cookies_not_found' as const; + +/** + * Access Token is malformed or invalid + * (AuthService) + */ +export const INVALID_ACCESS_TOKEN = 'auth/invalid_access_token' as const; + +/** + * Refresh Token is malformed or invalid + * (AuthService) + */ +export const INVALID_REFRESH_TOKEN = 'auth/invalid_refresh_token' as const; diff --git a/packages/hoppscotch-backend/src/guards/gql-auth.guard.ts b/packages/hoppscotch-backend/src/guards/gql-auth.guard.ts index aabc77b10..3ae7ade72 100644 --- a/packages/hoppscotch-backend/src/guards/gql-auth.guard.ts +++ b/packages/hoppscotch-backend/src/guards/gql-auth.guard.ts @@ -1,42 +1,11 @@ -import { CanActivate, Injectable, ExecutionContext } from '@nestjs/common'; +import { Injectable, ExecutionContext } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; -import { User } from '../user/user.model'; -import { IncomingHttpHeaders } from 'http2'; -import { AUTH_FAIL } from 'src/errors'; +import { AuthGuard } from '@nestjs/passport'; @Injectable() -export class GqlAuthGuard implements CanActivate { - // eslint-disable-next-line @typescript-eslint/no-empty-function - constructor() {} - - async canActivate(context: ExecutionContext): Promise { - try { - const ctx = GqlExecutionContext.create(context).getContext<{ - reqHeaders: IncomingHttpHeaders; - user: User | null; - }>(); - - if ( - ctx.reqHeaders.authorization && - ctx.reqHeaders.authorization.startsWith('Bearer ') - ) { - const idToken = ctx.reqHeaders.authorization.split(' ')[1]; - - const authUser: User = { - uid: 'aabb22ccdd', - displayName: 'exampleUser', - photoURL: 'http://example.com/avatar', - email: 'me@example.com', - }; - - ctx.user = authUser; - - return true; - } else { - return false; - } - } catch (e) { - throw new Error(AUTH_FAIL); - } +export class GqlAuthGuard extends AuthGuard('jwt') { + getRequest(context: ExecutionContext) { + const ctx = GqlExecutionContext.create(context); + return ctx.getContext().req; } } diff --git a/packages/hoppscotch-backend/src/mailer/MailDescriptions.ts b/packages/hoppscotch-backend/src/mailer/MailDescriptions.ts new file mode 100644 index 000000000..4178bb82a --- /dev/null +++ b/packages/hoppscotch-backend/src/mailer/MailDescriptions.ts @@ -0,0 +1,16 @@ +export type MailDescription = { + template: 'team-invitation'; + variables: { + invitee: string; + invite_team_name: string; + action_url: string; + }; +}; + +export type UserMagicLinkMailDescription = { + template: 'code-your-own'; + variables: { + inviteeEmail: string; + magicLink: string; + }; +}; diff --git a/packages/hoppscotch-backend/src/mailer/mailer.module.ts b/packages/hoppscotch-backend/src/mailer/mailer.module.ts new file mode 100644 index 000000000..eba8e9c93 --- /dev/null +++ b/packages/hoppscotch-backend/src/mailer/mailer.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { MailerService } from './mailer.service'; + +@Module({ + providers: [MailerService], + exports: [MailerService], +}) +export class MailerModule {} diff --git a/packages/hoppscotch-backend/src/mailer/mailer.service.ts b/packages/hoppscotch-backend/src/mailer/mailer.service.ts new file mode 100644 index 000000000..64ee53e0e --- /dev/null +++ b/packages/hoppscotch-backend/src/mailer/mailer.service.ts @@ -0,0 +1,60 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { Email } from 'src/types/Email'; +import { + MailDescription, + UserMagicLinkMailDescription, +} from './MailDescriptions'; +import * as postmark from 'postmark'; +import { throwErr } from 'src/utils'; +import * as TE from 'fp-ts/TaskEither'; +import { EMAIL_FAILED, SENDER_EMAIL_INVALID } from 'src/errors'; + +@Injectable() +export class MailerService implements OnModuleInit { + client: postmark.ServerClient; + + onModuleInit() { + this.client = new postmark.ServerClient( + process.env.POSTMARK_SERVER_TOKEN || throwErr(SENDER_EMAIL_INVALID), + ); + } + + sendMail( + to: string, + mailDesc: MailDescription | UserMagicLinkMailDescription, + ) { + return TE.tryCatch( + () => + this.client.sendEmailWithTemplate({ + To: to, + From: + process.env.POSTMARK_SENDER_EMAIL || throwErr(SENDER_EMAIL_INVALID), + TemplateAlias: mailDesc.template, + TemplateModel: mailDesc.variables, + }), + () => EMAIL_FAILED, + ); + } + + /** + * + * @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 { + const res = await this.client.sendEmailWithTemplate({ + To: to, + From: + process.env.POSTMARK_SENDER_EMAIL || + throwErr('No Postmark Sender Email defined'), + TemplateAlias: mailDesc.template, + TemplateModel: mailDesc.variables, + }); + return res; + } catch (error) { + return throwErr(EMAIL_FAILED); + } + } +} diff --git a/packages/hoppscotch-backend/src/main.ts b/packages/hoppscotch-backend/src/main.ts index 98a2ff179..6a3f84071 100644 --- a/packages/hoppscotch-backend/src/main.ts +++ b/packages/hoppscotch-backend/src/main.ts @@ -1,6 +1,8 @@ import { NestFactory } from '@nestjs/core'; import { json } from 'express'; import { AppModule } from './app.module'; +import * as cookieParser from 'cookie-parser'; +import { VersioningType } from '@nestjs/common'; async function bootstrap() { console.log(`Running in production: ${process.env.PRODUCTION}`); @@ -18,8 +20,10 @@ async function bootstrap() { if (process.env.PRODUCTION === 'false') { console.log('Enabling CORS with development settings'); + app.enableCors({ - origin: true, + origin: process.env.WHITELISTED_ORIGINS.split(','), + credentials: true, }); } else { console.log('Enabling CORS with production settings'); @@ -28,6 +32,10 @@ async function bootstrap() { origin: true, }); } + app.enableVersioning({ + type: VersioningType.URI, + }); + app.use(cookieParser(process.env.SIGNED_COOKIE_SECRET)); await app.listen(process.env.PORT || 3170); } bootstrap(); diff --git a/packages/hoppscotch-backend/src/types/AuthError.ts b/packages/hoppscotch-backend/src/types/AuthError.ts new file mode 100644 index 000000000..81e8c68c6 --- /dev/null +++ b/packages/hoppscotch-backend/src/types/AuthError.ts @@ -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; +}; diff --git a/packages/hoppscotch-backend/src/types/AuthTokens.ts b/packages/hoppscotch-backend/src/types/AuthTokens.ts new file mode 100644 index 000000000..cbb02a9fd --- /dev/null +++ b/packages/hoppscotch-backend/src/types/AuthTokens.ts @@ -0,0 +1,21 @@ +/** + * @see https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims#registered-claims + **/ +export interface AccessTokenPayload { + iss: string; // iss:issuer + sub: string; // sub:subject + aud: [string]; // aud:audience + iat?: number; // iat:issued at time +} + +export interface RefreshTokenPayload { + iss: string; + sub: string; + aud: [string]; + iat?: number; +} + +export type AuthTokens = { + access_token: string; + refresh_token: string; +}; diff --git a/packages/hoppscotch-backend/src/types/AuthUser.ts b/packages/hoppscotch-backend/src/types/AuthUser.ts new file mode 100644 index 000000000..ee67c4fb8 --- /dev/null +++ b/packages/hoppscotch-backend/src/types/AuthUser.ts @@ -0,0 +1,8 @@ +import { User } from '@prisma/client'; + +export type AuthUser = User; + +export interface SSOProviderProfile { + provider: string; + id: string; +} diff --git a/packages/hoppscotch-backend/src/types/Email.ts b/packages/hoppscotch-backend/src/types/Email.ts new file mode 100644 index 000000000..fd4144a8e --- /dev/null +++ b/packages/hoppscotch-backend/src/types/Email.ts @@ -0,0 +1,17 @@ +import * as t from 'io-ts'; + +interface EmailBrand { + readonly Email: unique symbol; +} + +// The validation branded type for an email +export const EmailCodec = t.brand( + t.string, + (x): x is t.Branded => + /^(([^<>()\[\]\\.,;:\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( + x, + ), + 'Email', +); + +export type Email = t.TypeOf; diff --git a/packages/hoppscotch-backend/src/types/Passwordless.ts b/packages/hoppscotch-backend/src/types/Passwordless.ts new file mode 100644 index 000000000..47117d91e --- /dev/null +++ b/packages/hoppscotch-backend/src/types/Passwordless.ts @@ -0,0 +1,3 @@ +export type DeviceIdentifierToken = { + deviceIdentifier: string; +}; diff --git a/packages/hoppscotch-backend/src/user-history/user-history.service.spec.ts b/packages/hoppscotch-backend/src/user-history/user-history.service.spec.ts index 40e2343db..c8ca3bb38 100644 --- a/packages/hoppscotch-backend/src/user-history/user-history.service.spec.ts +++ b/packages/hoppscotch-backend/src/user-history/user-history.service.spec.ts @@ -278,13 +278,14 @@ describe('UserHistoryService', () => { }); describe('toggleHistoryStarStatus', () => { test('Should resolve right and star/unstar a request in the history', async () => { + const createdOnDate = new Date() mockPrisma.userHistory.findFirst.mockResolvedValueOnce({ userUid: 'abc', id: '1', request: [{}], responseMetadata: [{}], reqType: ReqType.REST, - executedOn: new Date(), + executedOn: createdOnDate, isStarred: false, }); @@ -294,7 +295,7 @@ describe('UserHistoryService', () => { request: [{}], responseMetadata: [{}], reqType: ReqType.REST, - executedOn: new Date(), + executedOn: createdOnDate, isStarred: true, }); @@ -304,7 +305,7 @@ describe('UserHistoryService', () => { request: JSON.stringify([{}]), responseMetadata: JSON.stringify([{}]), reqType: ReqType.REST, - executedOn: new Date(), + executedOn: createdOnDate, isStarred: true, }; diff --git a/packages/hoppscotch-backend/src/user-settings/user-settings.resolver.ts b/packages/hoppscotch-backend/src/user-settings/user-settings.resolver.ts index 74e103c70..772ddd797 100644 --- a/packages/hoppscotch-backend/src/user-settings/user-settings.resolver.ts +++ b/packages/hoppscotch-backend/src/user-settings/user-settings.resolver.ts @@ -8,6 +8,7 @@ import { throwErr } from 'src/utils'; import { UserSettings } from './user-settings.model'; import { UserSettingsService } from './user-settings.service'; import { PubSubService } from 'src/pubsub/pubsub.service'; +import { AuthUser } from 'src/types/AuthUser'; @Resolver() export class UserSettingsResolver { @@ -23,7 +24,7 @@ export class UserSettingsResolver { }) @UseGuards(GqlAuthGuard) async createUserSettings( - @GqlUser() user: User, + @GqlUser() user: AuthUser, @Args({ name: 'properties', description: 'Stringified JSON settings object', @@ -42,7 +43,7 @@ export class UserSettingsResolver { }) @UseGuards(GqlAuthGuard) async updateUserSettings( - @GqlUser() user: User, + @GqlUser() user: AuthUser, @Args({ name: 'properties', description: 'Stringified JSON settings object', diff --git a/packages/hoppscotch-backend/src/user-settings/user-settings.service.spec.ts b/packages/hoppscotch-backend/src/user-settings/user-settings.service.spec.ts index c8bc9705e..37d0eb781 100644 --- a/packages/hoppscotch-backend/src/user-settings/user-settings.service.spec.ts +++ b/packages/hoppscotch-backend/src/user-settings/user-settings.service.spec.ts @@ -5,6 +5,7 @@ import { UserSettingsService } from './user-settings.service'; import { JSON_INVALID, USER_SETTINGS_NULL_SETTINGS } from 'src/errors'; import { UserSettings } from './user-settings.model'; import { User } from 'src/user/user.model'; +import { AuthUser } from 'src/types/AuthUser'; const mockPrisma = mockDeep(); const mockPubSub = mockDeep(); @@ -16,12 +17,20 @@ const userSettingsService = new UserSettingsService( mockPubSub as any, ); -const user: User = { +const currentTime = new Date(); + +const user: AuthUser = { uid: 'aabb22ccdd', displayName: 'user-display-name', email: 'user-email', photoURL: 'user-photo-url', + isAdmin: false, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + currentGQLSession: {}, + currentRESTSession: {}, + createdOn: currentTime, }; + const settings: UserSettings = { id: '1', userUid: user.uid, diff --git a/packages/hoppscotch-backend/src/user-settings/user-settings.service.ts b/packages/hoppscotch-backend/src/user-settings/user-settings.service.ts index 40b630297..ed1be3ceb 100644 --- a/packages/hoppscotch-backend/src/user-settings/user-settings.service.ts +++ b/packages/hoppscotch-backend/src/user-settings/user-settings.service.ts @@ -10,6 +10,7 @@ import { USER_SETTINGS_NULL_SETTINGS, USER_SETTINGS_NOT_FOUND, } from 'src/errors'; +import { AuthUser } from 'src/types/AuthUser'; @Injectable() export class UserSettingsService { @@ -46,7 +47,7 @@ export class UserSettingsService { * @param properties stringified user settings properties * @returns an Either of `UserSettings` or error */ - async createUserSettings(user: User, properties: string) { + async createUserSettings(user: AuthUser, properties: string) { if (!properties) return E.left(USER_SETTINGS_NULL_SETTINGS); const jsonProperties = stringToJson(properties); @@ -80,7 +81,7 @@ export class UserSettingsService { * @param properties stringified user settings * @returns Promise of an Either of `UserSettings` or error */ - async updateUserSettings(user: User, properties: string) { + async updateUserSettings(user: AuthUser, properties: string) { if (!properties) return E.left(USER_SETTINGS_NULL_SETTINGS); const jsonProperties = stringToJson(properties); diff --git a/packages/hoppscotch-backend/src/user/user.model.ts b/packages/hoppscotch-backend/src/user/user.model.ts index d06319358..f4997fb17 100644 --- a/packages/hoppscotch-backend/src/user/user.model.ts +++ b/packages/hoppscotch-backend/src/user/user.model.ts @@ -15,7 +15,7 @@ export class User { @Field({ nullable: true, - description: 'Displayed name of the user', + description: 'Name of the user (if fetched)', }) displayName?: string; @@ -27,10 +27,20 @@ export class User { @Field({ nullable: true, - description: 'URL to the profile photo of the user', + description: 'URL to the profile photo of the user (if fetched)', }) photoURL?: string; + @Field({ + description: 'Flag to determine if user is an Admin or not', + }) + isAdmin: boolean; + + @Field({ + description: 'Date when the user account was created', + }) + createdOn: Date; + @Field({ nullable: true, description: 'Stringified current REST session for logged-in User', diff --git a/packages/hoppscotch-backend/src/user/user.module.ts b/packages/hoppscotch-backend/src/user/user.module.ts index a5f9eda17..d02ec68ce 100644 --- a/packages/hoppscotch-backend/src/user/user.module.ts +++ b/packages/hoppscotch-backend/src/user/user.module.ts @@ -1,12 +1,12 @@ import { Module } from '@nestjs/common'; import { UserResolver } from './user.resolver'; import { PubSubModule } from 'src/pubsub/pubsub.module'; -import { PrismaModule } from 'src/prisma/prisma.module'; import { UserService } from './user.service'; +import { PrismaModule } from 'src/prisma/prisma.module'; @Module({ imports: [PubSubModule, PrismaModule], providers: [UserResolver, UserService], - exports: [], + exports: [UserService], }) export class UserModule {} diff --git a/packages/hoppscotch-backend/src/user/user.resolver.ts b/packages/hoppscotch-backend/src/user/user.resolver.ts index a3c88fe2a..803377d41 100644 --- a/packages/hoppscotch-backend/src/user/user.resolver.ts +++ b/packages/hoppscotch-backend/src/user/user.resolver.ts @@ -7,6 +7,7 @@ import { UserService } from './user.service'; import { throwErr } from 'src/utils'; import * as E from 'fp-ts/lib/Either'; import { PubSubService } from 'src/pubsub/pubsub.service'; +import { AuthUser } from 'src/types/AuthUser'; @Resolver(() => User) export class UserResolver { @@ -20,16 +21,7 @@ export class UserResolver { "Gives details of the user executing this query (pass Authorization 'Bearer' header)", }) @UseGuards(GqlAuthGuard) - me(@GqlUser() user: User): User { - return user; - } - - @Query(() => User, { - description: - "Gives details of the user executing this query (pass Authorization 'Bearer' header)", - }) - @UseGuards(GqlAuthGuard) - me2(@GqlUser() user: User): User { + me(@GqlUser() user) { return user; } @@ -40,7 +32,7 @@ export class UserResolver { }) @UseGuards(GqlAuthGuard) async updateUserSessions( - @GqlUser() user: User, + @GqlUser() user: AuthUser, @Args({ name: 'currentSession', description: 'JSON string of the saved REST/GQL session', diff --git a/packages/hoppscotch-backend/src/user/user.service.spec.ts b/packages/hoppscotch-backend/src/user/user.service.spec.ts index 4561a6f61..a85830094 100644 --- a/packages/hoppscotch-backend/src/user/user.service.spec.ts +++ b/packages/hoppscotch-backend/src/user/user.service.spec.ts @@ -1,22 +1,38 @@ -import { mockDeep, mockReset } from 'jest-mock-extended'; import { JSON_INVALID } from 'src/errors'; +import { mockDeep, mockReset } from 'jest-mock-extended'; import { PrismaService } from 'src/prisma/prisma.service'; -import { PubSubService } from 'src/pubsub/pubsub.service'; +import { AuthUser } from 'src/types/AuthUser'; +import { User } from './user.model'; import { UserService } from './user.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; const mockPrisma = mockDeep(); const mockPubSub = mockDeep(); +// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const userService = new UserService(mockPrisma, mockPubSub as any); -const user = { - uid: '123', - displayName: 'John Doe', - email: 'test@hoppscotch.io', - photoURL: 'https://example.com/avatar.png', - currentRESTSession: JSON.stringify({}), - currentGQLSession: JSON.stringify({}), +const currentTime = new Date(); + +const user: AuthUser = { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + currentRESTSession: {}, + currentGQLSession: {}, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + createdOn: currentTime, +}; + +const exampleSSOProfileData = { + id: '123rfedvd', + emails: [{ value: 'dwight@dundermifflin.com' }], + displayName: 'Dwight Schrute', + provider: 'google', + photos: 'https://en.wikipedia.org/wiki/Dwight_Schrute', }; beforeEach(() => { @@ -25,24 +41,209 @@ beforeEach(() => { }); describe('UserService', () => { + describe('findUserByEmail', () => { + test('should successfully return a valid user given a valid email', async () => { + mockPrisma.user.findUniqueOrThrow.mockResolvedValueOnce(user); + + const result = await userService.findUserByEmail( + 'dwight@dundermifflin.com', + ); + expect(result).toEqualSome(user); + }); + + test('should return a null user given a invalid email', async () => { + mockPrisma.user.findUniqueOrThrow.mockRejectedValueOnce('NotFoundError'); + + const result = await userService.findUserByEmail('jim@dundermifflin.com'); + expect(result).resolves.toBeNone; + }); + }); + + describe('findUserById', () => { + test('should successfully return a valid user given a valid user uid', async () => { + mockPrisma.user.findUniqueOrThrow.mockResolvedValueOnce(user); + + const result = await userService.findUserById('123344'); + expect(result).toEqualSome(user); + }); + + test('should return a null user given a invalid user uid', async () => { + mockPrisma.user.findUniqueOrThrow.mockRejectedValueOnce('NotFoundError'); + + const result = await userService.findUserById('sdcvbdbr'); + expect(result).resolves.toBeNone; + }); + }); + + 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.createUserViaMagicLink( + 'dwight@dundermifflin.com', + ); + expect(result).toEqual(user); + }); + }); + + describe('createUserSSO', () => { + test('should successfully create user and account for SSO provider given valid inputs ', async () => { + mockPrisma.user.create.mockResolvedValueOnce(user); + + const result = await userService.createUserSSO( + 'sdcsdcsdc', + 'dscsdc', + exampleSSOProfileData, + ); + expect(result).toEqual(user); + }); + + test('should successfully create user and account for SSO provider given no displayName ', async () => { + mockPrisma.user.create.mockResolvedValueOnce({ + ...user, + displayName: null, + }); + + const result = await userService.createUserSSO('sdcsdcsdc', 'dscsdc', { + ...exampleSSOProfileData, + displayName: null, + }); + + expect(result).toEqual({ + ...user, + displayName: null, + }); + }); + + test('should successfully create user and account for SSO provider given no photoURL ', async () => { + mockPrisma.user.create.mockResolvedValueOnce({ + ...user, + photoURL: null, + }); + + const result = await userService.createUserSSO('sdcsdcsdc', 'dscsdc', { + ...exampleSSOProfileData, + photoURL: null, + }); + + expect(result).toEqual({ + ...user, + photoURL: null, + }); + }); + }); + + describe('createProviderAccount', () => { + test('should successfully create ProviderAccount for user given valid inputs ', async () => { + mockPrisma.account.create.mockResolvedValueOnce({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: 'dscsdc', + providerAccessToken: 'sdcsdcsdc', + providerScope: 'user.email', + loggedIn: currentTime, + }); + + const result = await userService.createProviderAccount( + user, + 'sdcsdcsdc', + 'dscsdc', + exampleSSOProfileData, + ); + expect(result).toEqual({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: 'dscsdc', + providerAccessToken: 'sdcsdcsdc', + providerScope: 'user.email', + loggedIn: currentTime, + }); + }); + + test('should successfully create ProviderAccount for user given no accessToken ', async () => { + mockPrisma.account.create.mockResolvedValueOnce({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: 'dscsdc', + providerAccessToken: null, + providerScope: 'user.email', + loggedIn: currentTime, + }); + + const result = await userService.createProviderAccount( + user, + 'sdcsdcsdc', + 'dscsdc', + exampleSSOProfileData, + ); + expect(result).toEqual({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: 'dscsdc', + providerAccessToken: null, + providerScope: 'user.email', + loggedIn: currentTime, + }); + }); + + test('should successfully create ProviderAccount for user given no refreshToken', async () => { + mockPrisma.account.create.mockResolvedValueOnce({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: null, + providerAccessToken: 'sdcsdcsdc', + providerScope: 'user.email', + loggedIn: currentTime, + }); + + const result = await userService.createProviderAccount( + user, + 'sdcsdcsdc', + 'dscsdc', + exampleSSOProfileData, + ); + expect(result).toEqual({ + id: '123dcdc', + userId: user.uid, + provider: exampleSSOProfileData.provider, + providerAccountId: exampleSSOProfileData.id, + providerRefreshToken: null, + providerAccessToken: 'sdcsdcsdc', + providerScope: 'user.email', + loggedIn: currentTime, + }); + }); + }); + describe('updateUserSessions', () => { test('Should resolve right and update users GQL session', async () => { const sessionData = user.currentGQLSession; + mockPrisma.user.update.mockResolvedValue({ ...user, - currentGQLSession: JSON.parse(sessionData), + currentGQLSession: sessionData, currentRESTSession: null, }); const result = await userService.updateUserSessions( user, - sessionData, + JSON.stringify(sessionData), 'GQL', ); expect(result).toEqualRight({ ...user, - currentGQLSession: sessionData, + currentGQLSession: JSON.stringify(sessionData), currentRESTSession: null, }); }); @@ -51,19 +252,19 @@ describe('UserService', () => { mockPrisma.user.update.mockResolvedValue({ ...user, currentGQLSession: null, - currentRESTSession: JSON.parse(sessionData), + currentRESTSession: sessionData, }); const result = await userService.updateUserSessions( user, - sessionData, + JSON.stringify(sessionData), 'REST', ); expect(result).toEqualRight({ ...user, currentGQLSession: null, - currentRESTSession: sessionData, + currentRESTSession: JSON.stringify(sessionData), }); }); test('Should reject left and update user for invalid GQL session', async () => { @@ -92,16 +293,22 @@ describe('UserService', () => { test('Should publish pubsub message on user update sessions', async () => { mockPrisma.user.update.mockResolvedValue({ ...user, - currentGQLSession: JSON.parse(user.currentGQLSession), - currentRESTSession: JSON.parse(user.currentRESTSession), }); - await userService.updateUserSessions(user, user.currentGQLSession, 'GQL'); + await userService.updateUserSessions( + user, + JSON.stringify(user.currentGQLSession), + 'GQL', + ); expect(mockPubSub.publish).toHaveBeenCalledTimes(1); expect(mockPubSub.publish).toHaveBeenCalledWith( `user/${user.uid}/updated`, - user, + { + ...user, + currentGQLSession: JSON.stringify(user.currentGQLSession), + currentRESTSession: JSON.stringify(user.currentRESTSession), + }, ); }); }); diff --git a/packages/hoppscotch-backend/src/user/user.service.ts b/packages/hoppscotch-backend/src/user/user.service.ts index 9b0d6be1c..a712f3ac4 100644 --- a/packages/hoppscotch-backend/src/user/user.service.ts +++ b/packages/hoppscotch-backend/src/user/user.service.ts @@ -1,7 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../prisma/prisma.service'; +import { PrismaService } from 'src/prisma/prisma.service'; +import * as O from 'fp-ts/Option'; +import * as E from 'fp-ts/Either'; +import { AuthUser } from 'src/types/AuthUser'; +import { USER_NOT_FOUND } from 'src/errors'; import { SessionType, User } from './user.model'; -import * as E from 'fp-ts/lib/Either'; import { USER_UPDATE_FAILED } from 'src/errors'; import { PubSubService } from 'src/pubsub/pubsub.service'; import { stringToJson } from 'src/utils'; @@ -9,10 +12,185 @@ import { stringToJson } from 'src/utils'; @Injectable() export class UserService { constructor( - private readonly prisma: PrismaService, + private prisma: PrismaService, private readonly pubsub: PubSubService, ) {} + /** + * Find User with given email id + * + * @param email User's email + * @returns Option of found User + */ + async findUserByEmail(email: string) { + try { + const user = await this.prisma.user.findUniqueOrThrow({ + where: { + email: email, + }, + }); + return O.some(user); + } catch (error) { + return O.none; + } + } + + /** + * Find User with given ID + * + * @param userUid User ID + * @returns Option of found User + */ + async findUserById(userUid: string) { + try { + const user = await this.prisma.user.findUniqueOrThrow({ + where: { + uid: userUid, + }, + }); + return O.some(user); + } catch (error) { + return O.none; + } + } + + /** + * 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 email User's Email + * @returns Created User + */ + async createUserViaMagicLink(email: string) { + const createdUser = await this.prisma.user.create({ + data: { + email: email, + providerAccounts: { + create: { + provider: 'magic', + providerAccountId: email, + }, + }, + }, + }); + + return createdUser; + } + + /** + * Create a new User when logged in via a SSO provider + * + * @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( + 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: userDisplayName, + email: profile.emails[0].value, + photoURL: userPhotoURL, + providerAccounts: { + create: { + provider: profile.provider, + providerAccountId: profile.id, + providerRefreshToken: refreshTokenSSO, + providerAccessToken: accessTokenSSO, + }, + }, + }, + }); + + return createdUser; + } + + /** + * Create a new Account for a given User + * + * @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, + accessToken: string, + refreshToken: string, + profile, + ) { + const createdProvider = await this.prisma.account.create({ + data: { + provider: profile.provider, + providerAccountId: profile.id, + providerRefreshToken: refreshToken ? refreshToken : null, + providerAccessToken: accessToken ? accessToken : null, + user: { + connect: { + uid: user.uid, + }, + }, + }, + }); + + return createdProvider; + } + + /** + * Update User displayName and photoURL + * + * @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 { + const updatedUser = await this.prisma.user.update({ + where: { + uid: user.uid, + }, + data: { + displayName: !profile.displayName ? null : profile.displayName, + photoURL: !profile.photos ? null : profile.photos[0].value, + }, + }); + return E.right(updatedUser); + } catch (error) { + return E.left(USER_NOT_FOUND); + } + } + /** * Update a user's sessions * @param user User object @@ -21,7 +199,7 @@ export class UserService { * @returns a Either of User or error */ async updateUserSessions( - user: User, + user: AuthUser, currentSession: string, sessionType: string, ): Promise | E.Left> { diff --git a/packages/hoppscotch-backend/src/utils.ts b/packages/hoppscotch-backend/src/utils.ts index 570313640..7deafd76b 100644 --- a/packages/hoppscotch-backend/src/utils.ts +++ b/packages/hoppscotch-backend/src/utils.ts @@ -112,6 +112,18 @@ export const taskEitherValidateArraySeq = ( ); /** + * Checks to see if the email is valid or not + * @param email The email + * @see https://emailregex.com/ for information on email regex + * @returns A Boolean depending on the format of the email + */ +export const validateEmail = (email: string) => { + return new RegExp( + /^(([^<>()\[\]\\.,;:\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); +}; + +/* * String to JSON parser * @param {str} str The string to parse * @returns {E.Right | E.Left<"json_invalid">} An Either of the parsed JSON diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb6fdd8d5..59ab6c556 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,19 +49,25 @@ importers: '@nestjs/common': ^9.2.1 '@nestjs/core': ^9.2.1 '@nestjs/graphql': ^10.1.6 + '@nestjs/jwt': ^10.0.1 + '@nestjs/passport': ^9.0.0 '@nestjs/platform-express': ^9.2.1 '@nestjs/schematics': ^9.0.3 '@nestjs/testing': ^9.2.1 '@prisma/client': ^4.7.1 '@relmify/jest-fp-ts': ^2.0.2 + '@types/bcrypt': ^5.0.0 '@types/express': ^4.17.14 '@types/jest': ^29.4.0 '@types/node': ^18.11.10 + '@types/passport-jwt': ^3.0.8 '@types/supertest': ^2.0.12 '@typescript-eslint/eslint-plugin': ^5.45.0 '@typescript-eslint/parser': ^5.45.0 apollo-server-express: ^3.11.1 apollo-server-plugin-base: ^3.7.1 + argon2: ^0.30.3 + bcrypt: ^5.1.0 eslint: ^8.29.0 eslint-config-prettier: ^8.5.0 eslint-plugin-prettier: ^4.2.1 @@ -73,6 +79,11 @@ importers: graphql-subscriptions: ^2.0.0 io-ts: ^2.2.16 ioredis: ^5.2.4 + jest-mock-extended: ^3.0.1 + luxon: ^3.2.1 + passport: ^0.6.0 + passport-jwt: ^4.0.1 + postmark: ^3.0.15 jest: ^29.4.1 jest-mock-extended: ^3.0.1 prettier: ^2.8.0 @@ -92,10 +103,15 @@ importers: '@nestjs/common': 9.2.1_whg6pvy6vwu66ypq7idiq2suxq '@nestjs/core': 9.2.1_ajc4cvdydchgvxyi4xnoij5t4i '@nestjs/graphql': 10.1.6_2khsk5ahlt4vlkrgib4soffmwu + '@nestjs/jwt': 10.0.1_@nestjs+common@9.2.1 + '@nestjs/passport': 9.0.0_6o47igfla2pj7yzh7agpvpttka '@nestjs/platform-express': 9.2.1_hjcqpoaebdr7gdo5hgc22hthbe - '@prisma/client': 4.9.0_prisma@4.9.0 + '@types/bcrypt': 5.0.0 apollo-server-express: 3.11.1_4mq2c443wwzwcb6dpxnwkfvrzm apollo-server-plugin-base: 3.7.1_graphql@15.8.0 + argon2: 0.30.3 + bcrypt: 5.1.0 + '@prisma/client': 4.9.0_prisma@4.9.0 express: 4.18.2 fp-ts: 2.13.1 graphql: 15.8.0 @@ -103,7 +119,11 @@ importers: graphql-redis-subscriptions: 2.5.0_nsv4zbviaf54hk5nfhl4slig7i graphql-subscriptions: 2.0.0_graphql@15.8.0 io-ts: 2.2.16_fp-ts@2.13.1 - ioredis: 5.2.4 + ioredis: 5.2. + luxon: 3.2.1 + passport: 0.6.0 + passport-jwt: 4.0.1 + postmark: 3.0.15 prisma: 4.9.0 reflect-metadata: 0.1.13 rimraf: 3.0.2 @@ -116,6 +136,7 @@ importers: '@types/express': 4.17.14 '@types/jest': 29.4.0 '@types/node': 18.11.10 + '@types/passport-jwt': 3.0.8 '@types/supertest': 2.0.12 '@typescript-eslint/eslint-plugin': 5.45.0_yjegg5cyoezm3fzsmuszzhetym '@typescript-eslint/parser': 5.45.0_s5ps7njkmjlaqajutnox5ntcla @@ -4429,7 +4450,7 @@ packages: '@jest/schemas': 28.1.3 '@types/istanbul-lib-coverage': 2.0.4 '@types/istanbul-reports': 3.0.1 - '@types/node': 17.0.45 + '@types/node': 18.11.10 '@types/yargs': 17.0.10 chalk: 4.1.2 dev: true @@ -4559,6 +4580,24 @@ packages: '@lezer/lr': 1.2.0 dev: false + /@mapbox/node-pre-gyp/1.0.10: + resolution: {integrity: sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA==} + hasBin: true + dependencies: + detect-libc: 2.0.1 + https-proxy-agent: 5.0.1 + make-dir: 3.1.0 + node-fetch: 2.6.7 + nopt: 5.0.0 + npmlog: 5.0.1 + rimraf: 3.0.2 + semver: 7.3.8 + tar: 6.1.13 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + /@mdn/browser-compat-data/4.2.1: resolution: {integrity: sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA==} dev: true @@ -4730,6 +4769,16 @@ packages: - utf-8-validate dev: false + /@nestjs/jwt/10.0.1_@nestjs+common@9.2.1: + resolution: {integrity: sha512-LwXBKVYHnFeX6GH/Wt0WDjsWCmNDC6tEdLlwNMAvJgYp+TkiCpEmQLkgRpifdUE29mvYSbjSnVs2kW2ob935NA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 + dependencies: + '@nestjs/common': 9.2.1_whg6pvy6vwu66ypq7idiq2suxq + '@types/jsonwebtoken': 8.5.9 + jsonwebtoken: 9.0.0 + dev: false + /@nestjs/mapped-types/1.2.0_n3ccvzwmui2f6jwh4xeqysew6i: resolution: {integrity: sha512-NTFwPZkQWsArQH8QSyFWGZvJ08gR+R4TofglqZoihn/vU+ktHEJjMqsIsADwb7XD97DhiD+TVv5ac+jG33BHrg==} peerDependencies: @@ -4747,6 +4796,16 @@ packages: reflect-metadata: 0.1.13 dev: false + /@nestjs/passport/9.0.0_6o47igfla2pj7yzh7agpvpttka: + resolution: {integrity: sha512-Gnh8n1wzFPOLSS/94X1sUP4IRAoXTgG4odl7/AO5h+uwscEGXxJFercrZfqdAwkWhqkKWbsntM3j5mRy/6ZQDA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 + passport: ^0.4.0 || ^0.5.0 || ^0.6.0 + dependencies: + '@nestjs/common': 9.2.1_whg6pvy6vwu66ypq7idiq2suxq + passport: 0.6.0 + dev: false + /@nestjs/platform-express/9.2.1_hjcqpoaebdr7gdo5hgc22hthbe: resolution: {integrity: sha512-7PecaXt8lrdS1p6Vb1X/am3GGv+EO1VahyDzaEGOK6C0zwhc0VPfLtwihkjjfhS6BjpRIXXgviwEjONUvxVZnA==} peerDependencies: @@ -4841,6 +4900,11 @@ packages: transitivePeerDependencies: - encoding + /@phc/format/1.0.0: + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + dev: false + /@polka/url/1.0.0-next.21: resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==} dev: true @@ -4848,7 +4912,6 @@ packages: /@popperjs/core/2.11.5: resolution: {integrity: sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==} dev: false - /@prisma/client/4.9.0_prisma@4.9.0: resolution: {integrity: sha512-bz6QARw54sWcbyR1lLnF2QHvRW5R/Jxnbbmwh3u+969vUKXtBkXgSgjDA85nji31ZBlf7+FrHDy5x+5ydGyQDg==} engines: {node: '>=14.17'} @@ -5347,6 +5410,12 @@ packages: '@babel/types': 7.18.7 dev: true + /@types/bcrypt/5.0.0: + resolution: {integrity: sha512-agtcFKaruL8TmcvqbndlqHPSJgsolhf/qPWchFlgnW1gECTN/nKbFcoFnvKAQRFfKbh+BO6A3SWdJu9t+xF3Lw==} + dependencies: + '@types/node': 18.11.10 + dev: false + /@types/body-parser/1.19.2: resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==} dependencies: @@ -5488,6 +5557,11 @@ packages: '@types/node': 17.0.45 dev: true + /@types/jsonwebtoken/8.5.9: + resolution: {integrity: sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==} + dependencies: + '@types/node': 18.11.10 + /@types/linkify-it/3.0.2: resolution: {integrity: sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==} dev: true @@ -5565,6 +5639,27 @@ packages: /@types/parse-json/4.0.0: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} + /@types/passport-jwt/3.0.8: + resolution: {integrity: sha512-VKJZDJUAHFhPHHYvxdqFcc5vlDht8Q2pL1/ePvKAgqRThDaCc84lSYOTQmnx3+JIkDlN+2KfhFhXIzlcVT+Pcw==} + dependencies: + '@types/express': 4.17.14 + '@types/jsonwebtoken': 8.5.9 + '@types/passport-strategy': 0.2.35 + dev: true + + /@types/passport-strategy/0.2.35: + resolution: {integrity: sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==} + dependencies: + '@types/express': 4.17.14 + '@types/passport': 1.0.11 + dev: true + + /@types/passport/1.0.11: + resolution: {integrity: sha512-pz1cx9ptZvozyGKKKIPLcVDVHwae4hrH5d6g5J+DkMRRjR3cVETb4jMabhXAUbg3Ov7T22nFHEgaK2jj+5CBpw==} + dependencies: + '@types/express': 4.17.14 + dev: true + /@types/postman-collection/3.5.7: resolution: {integrity: sha512-wqZ/MlGEYP+RoiofnAnKDJAHxRzmMK97CeFLoHPNoHdHX0uyBLCDl+uZV9x4xuPVRjkeM4xcarIaUaUk47c7SQ==} dependencies: @@ -6886,6 +6981,10 @@ packages: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} dev: true + /abbrev/1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + dev: false + /abort-controller/3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -6991,7 +7090,6 @@ packages: debug: 4.3.4 transitivePeerDependencies: - supports-color - dev: true /aggregate-error/3.1.0: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} @@ -7257,9 +7355,34 @@ packages: /append-field/1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + /aproba/2.0.0: + resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} + dev: false + + /are-we-there-yet/2.0.0: + resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} + engines: {node: '>=10'} + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.0 + dev: false + /arg/4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + /argon2/0.30.3: + resolution: {integrity: sha512-DoH/kv8c9127ueJSBxAVJXinW9+EuPA3EMUxoV2sAY1qDE5H9BjTyVF/aD2XyHqbqUWabgBkIfcP3ZZuGhbJdg==} + engines: {node: '>=14.0.0'} + requiresBuild: true + dependencies: + '@mapbox/node-pre-gyp': 1.0.10 + '@phc/format': 1.0.0 + node-addon-api: 5.0.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + /argparse/1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} dependencies: @@ -7336,6 +7459,14 @@ packages: transitivePeerDependencies: - debug + /axios/0.25.0: + resolution: {integrity: sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==} + dependencies: + follow-redirects: 1.15.1 + transitivePeerDependencies: + - debug + dev: false + /babel-jest/27.5.1_@babel+core@7.18.6: resolution: {integrity: sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -7553,6 +7684,18 @@ packages: safe-buffer: 5.1.2 dev: true + /bcrypt/5.1.0: + resolution: {integrity: sha512-RHBS7HI5N5tEnGTmtR/pppX0mmDSBpQ4aCBsj7CEQfYXDcO74A8sIBYcJMuCsis2E81zDxeENYhv66oZwLiA+Q==} + engines: {node: '>= 10.0.0'} + requiresBuild: true + dependencies: + '@mapbox/node-pre-gyp': 1.0.10 + node-addon-api: 5.0.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + /big.js/5.2.2: resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} dev: true @@ -7684,7 +7827,6 @@ packages: /buffer-equal-constant-time/1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - dev: true /buffer-from/1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -7873,6 +8015,11 @@ packages: optionalDependencies: fsevents: 2.3.2 + /chownr/2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + dev: false + /chrome-trace-event/1.0.3: resolution: {integrity: sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==} engines: {node: '>=6.0'} @@ -7981,6 +8128,11 @@ packages: /color-name/1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + /color-support/1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + dev: false + /colorette/2.0.19: resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==} @@ -8072,6 +8224,10 @@ packages: /consola/2.15.3: resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==} + /console-control-strings/1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + dev: false + /constant-case/3.0.4: resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} dependencies: @@ -8483,6 +8639,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + /delegates/1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + dev: false + /denque/2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -8506,6 +8666,11 @@ packages: engines: {node: '>=8'} dev: true + /detect-libc/2.0.1: + resolution: {integrity: sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==} + engines: {node: '>=8'} + dev: false + /detect-newline/3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -8606,7 +8771,6 @@ packages: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} dependencies: safe-buffer: 5.2.1 - dev: true /ee-first/1.1.1: resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} @@ -10410,6 +10574,13 @@ packages: universalify: 2.0.0 dev: true + /fs-minipass/2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + dependencies: + minipass: 3.3.6 + dev: false + /fs-monkey/1.0.3: resolution: {integrity: sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==} dev: true @@ -10464,6 +10635,21 @@ packages: engines: {node: '>=10'} dev: false + /gauge/3.0.2: + resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} + engines: {node: '>=10'} + dependencies: + aproba: 2.0.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + object-assign: 4.1.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + dev: false + /gensync/1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -10854,6 +11040,10 @@ packages: dependencies: has-symbols: 1.0.3 + /has-unicode/2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + dev: false + /has/1.0.3: resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} engines: {node: '>= 0.4.0'} @@ -11047,7 +11237,6 @@ packages: debug: 4.3.4 transitivePeerDependencies: - supports-color - dev: true /httpsnippet/2.0.0: resolution: {integrity: sha512-Hb2ttfB5OhasYxwChZ8QKpYX3v4plNvwMaMulUIC7M3RHRDf1Op6EMp47LfaU2sgQgfvo5spWK4xRAirMEisrg==} @@ -12120,7 +12309,7 @@ packages: peerDependencies: jest: ^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0 typescript: ^3.0.0 || ^4.0.0 - dependencies: + jest: 29.4.1_j5wyyouvf5aixckc7ltaxrydha ts-essentials: 7.0.3_typescript@4.9.3 typescript: 4.9.3 @@ -12820,6 +13009,16 @@ packages: semver: 5.7.1 dev: true + /jsonwebtoken/9.0.0: + resolution: {integrity: sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==} + engines: {node: '>=12', npm: '>=6'} + dependencies: + jws: 3.2.2 + lodash: 4.17.21 + ms: 2.1.3 + semver: 7.3.8 + dev: false + /jszip/3.10.0: resolution: {integrity: sha512-LDfVtOLtOxb9RXkYOwPyNBTQDL4eUbqahtoY6x07GiDJHwSYvn8sHHIw8wINImV3MqbMNve2gSuM1DDqEKk09Q==} dependencies: @@ -12835,14 +13034,12 @@ packages: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 - dev: true /jws/3.2.2: resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} dependencies: jwa: 1.4.1 safe-buffer: 5.2.1 - dev: true /kind-of/6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} @@ -13129,6 +13326,11 @@ packages: engines: {node: '>=12'} dev: false + /luxon/3.2.1: + resolution: {integrity: sha512-QrwPArQCNLAKGO/C+ZIilgIuDnEnKx5QYODdDtbFaxzsbZcc/a7WFq7MhsVYgRlwawLtvOUESTlfJ+hc/USqPg==} + engines: {node: '>=12'} + dev: false + /macos-release/2.5.0: resolution: {integrity: sha512-EIgv+QZ9r+814gjJj0Bt5vSLJLzswGmSUbUpbi9AIr/fsN2IWFBl2NucV9PAiek+U1STK468tEkxmVYUtuAN3g==} engines: {node: '>=6'} @@ -13157,7 +13359,6 @@ packages: engines: {node: '>=8'} dependencies: semver: 6.3.0 - dev: true /make-error/1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} @@ -13361,6 +13562,28 @@ packages: /minimist/1.2.6: resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==} + /minipass/3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + dependencies: + yallist: 4.0.0 + dev: false + + /minipass/4.0.0: + resolution: {integrity: sha512-g2Uuh2jEKoht+zvO6vJqXmYpflPqzRBT+Th2h01DKh5z7wbY/AZ2gCQ78cP70YoHPyFdY30YBV5WxgLOEwOykw==} + engines: {node: '>=8'} + dependencies: + yallist: 4.0.0 + dev: false + + /minizlib/2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + dev: false + /mkdirp-promise/1.1.0: resolution: {integrity: sha512-xzB0UZFcW1UGS2xkXeDh39jzTP282lb3Vwp4QzCQYmkTn4ysaV5dBdbkOXmhkcE1TQlZebQlgTceaWvDr3oFgw==} engines: {node: '>=4'} @@ -13379,7 +13602,6 @@ packages: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} hasBin: true - dev: true /mlly/1.1.0: resolution: {integrity: sha512-cwzBrBfwGC1gYJyfcy8TcZU1f+dbH/T+TuOhtYP2wLv/Fb51/uV7HJQfBPtEupZ2ORLRU1EKFS/QfS3eo9+kBQ==} @@ -13506,6 +13728,10 @@ packages: /node-abort-controller/3.0.1: resolution: {integrity: sha512-/ujIVxthRs+7q6hsdjHMaj8hRG9NuWmwrz+JdRwZ14jdFoKSkm+vDsCbF9PLpnSqjaWQJuTmVtcWHNLr+vrOFw==} + /node-addon-api/5.0.0: + resolution: {integrity: sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA==} + dev: false + /node-domexception/1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -13535,6 +13761,14 @@ packages: resolution: {integrity: sha512-U9h1NLROZTq9uE1SNffn6WuPDg8icmi3ns4rEl/oTfIle4iLjTliCzgTsbaIFMq/Xn078/lfY/BL0GWZ+psK4Q==} dev: true + /nopt/5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + dependencies: + abbrev: 1.1.1 + dev: false + /normalize-package-data/2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} dependencies: @@ -13586,6 +13820,15 @@ packages: dependencies: path-key: 3.1.1 + /npmlog/5.0.1: + resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + dependencies: + are-we-there-yet: 2.0.0 + console-control-strings: 1.1.0 + gauge: 3.0.2 + set-blocking: 2.0.0 + dev: false + /nprogress/0.2.0: resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==} dev: false @@ -13865,6 +14108,27 @@ packages: no-case: 3.0.4 tslib: 2.4.0 + /passport-jwt/4.0.1: + resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + dependencies: + jsonwebtoken: 9.0.0 + passport-strategy: 1.0.0 + dev: false + + /passport-strategy/1.0.0: + resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} + engines: {node: '>= 0.4.0'} + dev: false + + /passport/0.6.0: + resolution: {integrity: sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==} + engines: {node: '>= 0.4.0'} + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + dev: false + /path-case/3.0.4: resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} dependencies: @@ -13941,6 +14205,10 @@ packages: through: 2.3.8 dev: false + /pause/0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + dev: false + /pend/1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} dev: false @@ -14079,6 +14347,14 @@ packages: punycode: 2.1.1 dev: false + /postmark/3.0.15: + resolution: {integrity: sha512-s5m1qMb0ECEHW+Kv+By+2CAzfNXjUiGkTSU1avWPeh/QitdLzWwK8Q0aBB249KqYN2mS2JGJsxxfeaYIqnhhHA==} + dependencies: + axios: 0.25.0 + transitivePeerDependencies: + - debug + dev: false + /prelude-ls/1.1.2: resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==} engines: {node: '>= 0.8.0'} @@ -14784,7 +15060,6 @@ packages: /semver/6.3.0: resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==} hasBin: true - dev: true /semver/7.0.0: resolution: {integrity: sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==} @@ -14804,7 +15079,6 @@ packages: hasBin: true dependencies: lru-cache: 6.0.0 - dev: true /send/0.18.0: resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} @@ -14858,7 +15132,6 @@ packages: /set-blocking/2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - dev: true /setimmediate/1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} @@ -15484,6 +15757,18 @@ packages: engines: {node: '>=6'} dev: true + /tar/6.1.13: + resolution: {integrity: sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==} + engines: {node: '>=10'} + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 4.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + dev: false + /temp-dir/2.0.0: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} engines: {node: '>=8'} @@ -17576,6 +17861,12 @@ packages: dependencies: isexe: 2.0.0 + /wide-align/1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + dependencies: + string-width: 4.2.3 + dev: false + /windicss/3.5.6: resolution: {integrity: sha512-P1mzPEjgFMZLX0ZqfFht4fhV/FX8DTG7ERG1fBLiWvd34pTLVReS5CVsewKn9PApSgXnVfPWwvq+qUsRwpnwFA==} engines: {node: '>= 12'}