Merge pull request #9 from hoppscotch/feat/user-authentication
feat: introduce custom user authentication module
This commit is contained in:
37
packages/hoppscotch-backend/.env.example
Normal file
37
packages/hoppscotch-backend/.env.example
Normal file
@@ -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"
|
||||
@@ -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"
|
||||
},
|
||||
};
|
||||
|
||||
7
packages/hoppscotch-backend/.gitignore
vendored
7
packages/hoppscotch-backend/.gitignore
vendored
@@ -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
|
||||
!.vscode/extensions.json
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ApolloDriverConfig>({
|
||||
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,
|
||||
|
||||
135
packages/hoppscotch-backend/src/auth/auth.controller.ts
Normal file
135
packages/hoppscotch-backend/src/auth/auth.controller.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
35
packages/hoppscotch-backend/src/auth/auth.module.ts
Normal file
35
packages/hoppscotch-backend/src/auth/auth.module.ts
Normal file
@@ -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 {}
|
||||
366
packages/hoppscotch-backend/src/auth/auth.service.spec.ts
Normal file
366
packages/hoppscotch-backend/src/auth/auth.service.spec.ts
Normal file
@@ -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<PrismaService>();
|
||||
const mockUser = mockDeep<UserService>();
|
||||
const mockJWT = mockDeep<JwtService>();
|
||||
const mockMailer = mockDeep<MailerService>();
|
||||
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
342
packages/hoppscotch-backend/src/auth/auth.service.ts
Normal file
342
packages/hoppscotch-backend/src/auth/auth.service.ts
Normal file
@@ -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(<AuthError>{
|
||||
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(<AuthTokens>{
|
||||
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(<DeviceIdentifierToken>{
|
||||
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.Right<AuthTokens> | E.Left<AuthError>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Inputs to initiate Magic-Link auth flow
|
||||
export class SignInMagicDto {
|
||||
email: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Inputs to verify and sign a user in via magic-link
|
||||
export class VerifyMagicDto {
|
||||
deviceIdentifier: string;
|
||||
token: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class RTJwtAuthGuard extends AuthGuard('jwt-refresh') {}
|
||||
56
packages/hoppscotch-backend/src/auth/helper.ts
Normal file
56
packages/hoppscotch-backend/src/auth/helper.ts
Normal file
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<any, any, User>(
|
||||
(_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;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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'];
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
16
packages/hoppscotch-backend/src/mailer/MailDescriptions.ts
Normal file
16
packages/hoppscotch-backend/src/mailer/MailDescriptions.ts
Normal file
@@ -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;
|
||||
};
|
||||
};
|
||||
8
packages/hoppscotch-backend/src/mailer/mailer.module.ts
Normal file
8
packages/hoppscotch-backend/src/mailer/mailer.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MailerService } from './mailer.service';
|
||||
|
||||
@Module({
|
||||
providers: [MailerService],
|
||||
exports: [MailerService],
|
||||
})
|
||||
export class MailerModule {}
|
||||
60
packages/hoppscotch-backend/src/mailer/mailer.service.ts
Normal file
60
packages/hoppscotch-backend/src/mailer/mailer.service.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
10
packages/hoppscotch-backend/src/types/AuthError.ts
Normal file
10
packages/hoppscotch-backend/src/types/AuthError.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
** Custom interface to handle errors specific to Auth module
|
||||
** Since its REST we need to return HTTP status code along with error message
|
||||
*/
|
||||
export type AuthError = {
|
||||
message: string;
|
||||
statusCode: HttpStatus;
|
||||
};
|
||||
21
packages/hoppscotch-backend/src/types/AuthTokens.ts
Normal file
21
packages/hoppscotch-backend/src/types/AuthTokens.ts
Normal file
@@ -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;
|
||||
};
|
||||
8
packages/hoppscotch-backend/src/types/AuthUser.ts
Normal file
8
packages/hoppscotch-backend/src/types/AuthUser.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { User } from '@prisma/client';
|
||||
|
||||
export type AuthUser = User;
|
||||
|
||||
export interface SSOProviderProfile {
|
||||
provider: string;
|
||||
id: string;
|
||||
}
|
||||
17
packages/hoppscotch-backend/src/types/Email.ts
Normal file
17
packages/hoppscotch-backend/src/types/Email.ts
Normal file
@@ -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<string, EmailBrand> =>
|
||||
/^(([^<>()\[\]\\.,;:\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<typeof EmailCodec>;
|
||||
3
packages/hoppscotch-backend/src/types/Passwordless.ts
Normal file
3
packages/hoppscotch-backend/src/types/Passwordless.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type DeviceIdentifierToken = {
|
||||
deviceIdentifier: string;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<PrismaService>();
|
||||
const mockPubSub = mockDeep<PubSubService>();
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<PrismaService>();
|
||||
const mockPubSub = mockDeep<PubSubService>();
|
||||
|
||||
// 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),
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.Right<User> | E.Left<string>> {
|
||||
|
||||
@@ -112,6 +112,18 @@ export const taskEitherValidateArraySeq = <A, B>(
|
||||
);
|
||||
|
||||
/**
|
||||
* 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<T> | E.Left<"json_invalid">} An Either of the parsed JSON
|
||||
|
||||
323
pnpm-lock.yaml
generated
323
pnpm-lock.yaml
generated
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user