Merge pull request #9 from hoppscotch/feat/user-authentication

feat: introduce custom user authentication module
This commit is contained in:
Balu Babu
2023-02-02 19:09:47 +05:30
committed by GitHub
46 changed files with 2560 additions and 122 deletions

View 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"

View File

@@ -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"
},
};

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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,

View 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();
}
}

View 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 {}

View 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,
});
});
});

View 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);
}
}

View File

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

View File

@@ -0,0 +1,5 @@
// Inputs to verify and sign a user in via magic-link
export class VerifyMagicDto {
deviceIdentifier: string;
token: string;
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class RTJwtAuthGuard extends AuthGuard('jwt-refresh') {}

View 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();
};

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
},
);

View File

@@ -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'];
},
);

View File

@@ -129,6 +129,12 @@ export const TEAM_REQ_NOT_FOUND = 'team_req/not_found' as const;
export const TEAM_REQ_INVALID_TARGET_COLL_ID =
'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;

View File

@@ -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;
}
}

View 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;
};
};

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MailerService } from './mailer.service';
@Module({
providers: [MailerService],
exports: [MailerService],
})
export class MailerModule {}

View 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);
}
}
}

View File

@@ -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();

View File

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

View File

@@ -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;
};

View File

@@ -0,0 +1,8 @@
import { User } from '@prisma/client';
export type AuthUser = User;
export interface SSOProviderProfile {
provider: string;
id: string;
}

View 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>;

View File

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

View File

@@ -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,
};

View File

@@ -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',

View File

@@ -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,

View File

@@ -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);

View File

@@ -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',

View File

@@ -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 {}

View File

@@ -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',

View File

@@ -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),
},
);
});
});

View File

@@ -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>> {

View File

@@ -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
View File

@@ -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'}