diff --git a/.eslintrc.json b/.eslintrc.json index 65765a8..537da74 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -12,6 +12,7 @@ "files": ["*.ts", "*.tsx"], "extends": ["love"], "rules": { + "@typescript-eslint/strict-boolean-expressions": "off", "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/consistent-type-imports": [ "warn", diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 3b92be5..3c30fa1 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,7 +3,6 @@ "esbenp.prettier-vscode", "firsttris.vscode-jest-runner", "vivaxy.vscode-conventional-commits", - "prisma.prisma", - "abians.prisma-generate-uml" + "prisma.prisma" ] } diff --git a/apps/api/.env.development b/apps/api/.development.env similarity index 81% rename from apps/api/.env.development rename to apps/api/.development.env index 9bd92e8..b2e911a 100644 --- a/apps/api/.env.development +++ b/apps/api/.development.env @@ -5,3 +5,6 @@ # See the documentation for all the connection string options: https://pris.ly/d/connection-strings DATABASE_URL="postgresql://api:admin@localhost:5432/postgres?schema=public" +OAUTH_GOOGLE_REDIRECT_URI="http://localhost:3000/api/auth/google/callback" +JWT_SECRET="secret" +JWT_EXPIRES_IN="1d" diff --git a/apps/api/.env.test b/apps/api/.env.test deleted file mode 100644 index cc340a4..0000000 --- a/apps/api/.env.test +++ /dev/null @@ -1 +0,0 @@ -DATABASE_URL="postgresql://test:test@localhost:5433/tests" diff --git a/apps/api/.env.example b/apps/api/.example.env similarity index 84% rename from apps/api/.env.example rename to apps/api/.example.env index ceaea2a..ae648e0 100644 --- a/apps/api/.env.example +++ b/apps/api/.example.env @@ -10,3 +10,10 @@ RAPID_API_KEY="" STREAMING_AVAILABILITY_API_HOST="https://streaming-availability.p.rapidapi.com" IMDB_API_HOST="https://imdb188.p.rapidapi.com" + +OAUTH_GOOGLE_CLIENT_ID= +OAUTH_GOOGLE_CLIENT_SECRET= +OAUTH_GOOGLE_REDIRECT_URI= + +JWT_SECRET="change-me" +JWT_EXPIRES_IN="1d" diff --git a/apps/api/.test.env b/apps/api/.test.env new file mode 100644 index 0000000..72089c5 --- /dev/null +++ b/apps/api/.test.env @@ -0,0 +1,4 @@ +DATABASE_URL="postgresql://test:test@localhost:5433/tests" + +OAUTH_GOOGLE_CLIENT_ID="google-client-id" +OAUTH_GOOGLE_CLIENT_SECRET="google-client-secret" diff --git a/apps/api/jest.config.ts b/apps/api/jest.config.ts index 1039a6d..2bdf7d0 100644 --- a/apps/api/jest.config.ts +++ b/apps/api/jest.config.ts @@ -20,6 +20,7 @@ export default { } ] }, + testPathIgnorePatterns: ['/node_modules/', '/dist/'], moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/apps/api', }; diff --git a/apps/api/package.json b/apps/api/package.json index 498e322..f7bc4e8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,7 +11,7 @@ "dev": "vite", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", - "console": "yarn start -- --watch --entryFile repl", + "console": "npm run start -- --entryFile repl", "db:deploy": "npx prisma migrate deploy", "db:migrate": "npx prisma migrate dev", "db:seed": "npx prisma db seed", @@ -21,7 +21,7 @@ "test": "jest", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "dotenv -e .env.test -- jest --config ./test/jest-e2e.json --runInBand", + "test:e2e": "dotenv -e .test.env -- jest --config ./test/jest-e2e.json --runInBand", "test:watch": "jest --watch" }, "lint-staged": { @@ -37,12 +37,19 @@ "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.2", "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.3.1", "@prisma/client": "^5.11.0", "axios": "^1.6.8", + "cookie-parser": "^1.4.6", "nest-winston": "^1.9.4", "nestjs-zod": "^3.0.0", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "winston": "^3.13.0", @@ -59,9 +66,13 @@ "@swc/cli": "^0.3.12", "@swc/core": "^1.4.12", "@swc/jest": "^0.2.36", + "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", + "@types/passport-google-oauth20": "^2.0.14", + "@types/passport-jwt": "^4.0.1", + "@types/passport-local": "^1.0.38", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", @@ -72,6 +83,7 @@ "eslint-plugin-prettier": "^5.0.0", "fishery": "^2.2.2", "jest": "^29.7.0", + "nock": "^13.5.4", "prettier": "^3.0.0", "prisma": "^5.11.0", "source-map-support": "^0.5.21", diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 5f08b97..9b41484 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -6,22 +6,25 @@ import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod' import { DatabaseModule } from '../database/database.module' import { MoviesModule } from '../movies/movies.module' import { SubscriptionsModule } from '../subscriptions/subscriptions.module' +import { AuthModule } from '../auth/auth.module' import { AppController } from './app.controller' import { AppService } from './app.service' import { validate } from './config/validate' -import { HttpExceptionFilter } from './http-exception.filter' +import { HttpExceptionFilter } from './filters/http-exception.filter' @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, validate, - envFilePath: ['.env', '.env.development'] + envFilePath: ['.env', '.development.env'] }), + DatabaseModule, MoviesModule, - SubscriptionsModule + SubscriptionsModule, + AuthModule ], controllers: [AppController], providers: [ diff --git a/apps/api/src/app/config/validate.ts b/apps/api/src/app/config/validate.ts index 433a275..a10d171 100644 --- a/apps/api/src/app/config/validate.ts +++ b/apps/api/src/app/config/validate.ts @@ -5,8 +5,11 @@ export function validate (config: Record) { DATABASE_URL: z.string().url(), STREAMING_AVAILABILITY_API_HOST: z.string().url().optional(), IMDB_API_HOST: z.string().url().optional(), - RAPID_API_KEY: z.string().optional() - + RAPID_API_KEY: z.string().optional(), + OAUTH_GOOGLE_CLIENT_ID: z.string().optional(), + OAUTH_GOOGLE_CLIENT_SECRET: z.string().optional(), + OAUTH_GOOGLE_REDIRECT_URI: z.string().optional(), + JWT_SECRET: z.string() }) schema.parse(config) diff --git a/apps/api/src/app/http-exception.filter.ts b/apps/api/src/app/filters/http-exception.filter.ts similarity index 100% rename from apps/api/src/app/http-exception.filter.ts rename to apps/api/src/app/filters/http-exception.filter.ts diff --git a/apps/api/src/auth/auth.controller.spec.ts b/apps/api/src/auth/auth.controller.spec.ts new file mode 100644 index 0000000..fd742c3 --- /dev/null +++ b/apps/api/src/auth/auth.controller.spec.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { TestBed } from '@automock/jest' +import { createMock } from '@golevelup/ts-jest' +import { Request, Response } from 'express' + +import { AuthController } from './auth.controller' +import { AuthService } from './auth.service' + +describe('AuthController', () => { + let controller: AuthController + let authService: jest.Mocked + + beforeEach(async () => { + const { unit, unitRef } = TestBed.create(AuthController).compile() + + controller = unit + authService = unitRef.get(AuthService) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + it('should set cookies on google login callback', async () => { + const req = createMock({ + user: { + email: 'test-email', + name: 'test-name', + picture: 'test-picture' + } + }) + + const res = createMock() + jest.spyOn(authService, 'signIn').mockResolvedValue('token') + + await controller.googleLoginCallback(req, res) + + expect(authService.signIn).toHaveBeenCalledWith({ + email: 'test-email', + name: 'test-name', + picture: 'test-picture' + }) + + expect(res.cookie).toHaveBeenCalledWith('access_token', 'token') + }) +}) diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..defcd7d --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Req, Get, UseGuards, Res } from '@nestjs/common' +import { Request, Response } from 'express' + +import { GoogleOauthGuard } from './guards/google.guard' +import { AuthService } from './auth.service' +import { Profile } from './interfaces/profile' + +@Controller('auth') +export class AuthController { + constructor (private readonly authService: AuthService) {} + @Get('google') + @UseGuards(GoogleOauthGuard) + async googleLogin () { + } + + @Get('google/callback') + @UseGuards(GoogleOauthGuard) + async googleLoginCallback (@Req() req: Request, @Res({ passthrough: true }) res: Response) { + const token = await this.authService.signIn(req.user as Profile) + + res.cookie('access_token', token) + + return null + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..2a4b581 --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common' +import { PassportModule } from '@nestjs/passport' +import { JwtModule } from '@nestjs/jwt' +import { ConfigService } from '@nestjs/config' + +import { UsersModule } from '../users/users.module' + +import { AuthService } from './auth.service' +import { AuthController } from './auth.controller' +import { GoogleStrategy } from './strategies/google.strategy' + +@Module({ + imports: [ + UsersModule, + PassportModule, + JwtModule.registerAsync({ + useFactory: (config: ConfigService) => ({ + global: true, + secret: config.get('JWT_SECRET'), + signOptions: { expiresIn: config.get('JWT_EXPIRES_IN') } + }), + inject: [ConfigService] + }) + ], + providers: [AuthService, GoogleStrategy], + controllers: [AuthController] +}) +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..a4a3b7c --- /dev/null +++ b/apps/api/src/auth/auth.service.spec.ts @@ -0,0 +1,60 @@ +import { TestBed } from '@automock/jest' +import { JwtService } from '@nestjs/jwt' + +import { UsersService } from '../users/users.service' + +import { AuthService } from './auth.service' + +describe('AuthService', () => { + let service: AuthService + + beforeEach(async () => { + const { unit } = TestBed.create(AuthService) + .mock(UsersService) + .using({ + findOrCreate: jest.fn().mockResolvedValue({ id: 1, email: 'test@email.com' }) + }) + .mock(JwtService) + .using({ + sign: jest.fn().mockReturnValue('jwt') + }) + .compile() + + service = unit + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should return a user and token', async () => { + const user = await service.signIn({ + email: 'test', + name: 'test', + picture: 'test' + }) + expect(user).toEqual('jwt') + }) + + it('should throw an error if no profile is provided', async () => { + try { + // @ts-expect-error: Testing OAuth provider does not return profile + await service.signIn(null) + } catch (e) { + expect(e.message).toEqual('Unauthenticated') + } + }) + + it('should throw an error if no email is provided', async () => { + try { + await service.signIn({ + // @ts-expect-error: Testing when OAuth provider does not return email + email: null, + name: 'test', + picture: 'test' + }) + } catch (e) { + expect(e.message).toEqual('Email not found') + } + }) +}) diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..51cf0cc --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -0,0 +1,38 @@ +import { BadRequestException, Injectable } from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' + +import { UsersService } from '../users/users.service' + +import { Profile } from './interfaces/profile' + +@Injectable() +export class AuthService { + constructor (private readonly usersService: UsersService, private readonly jwtService: JwtService) { + } + + generateJwt (payload: object) { + return this.jwtService.sign(payload) + } + + // TODO: Implement ProfileDTO for improved validation + async signIn (profile: Profile) { + if (!profile) { + throw new BadRequestException('Unauthenticated') + } + + if (!profile.email) { + throw new BadRequestException('Email not found') + } + + const user = await this.usersService.findOrCreate({ + email: profile.email, + name: profile.name, + picture: profile.picture + }) + + return this.generateJwt({ + sub: user.id, + email: user.email + }) + } +} diff --git a/apps/api/src/auth/guards/google.guard.ts b/apps/api/src/auth/guards/google.guard.ts new file mode 100644 index 0000000..978acac --- /dev/null +++ b/apps/api/src/auth/guards/google.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common' +import { AuthGuard } from '@nestjs/passport' + +@Injectable() +export class GoogleOauthGuard extends AuthGuard('google') {} diff --git a/apps/api/src/auth/guards/jwt.guard.ts b/apps/api/src/auth/guards/jwt.guard.ts new file mode 100644 index 0000000..aa859f7 --- /dev/null +++ b/apps/api/src/auth/guards/jwt.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common' +import { AuthGuard } from '@nestjs/passport' + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/apps/api/src/auth/interfaces/jwt-payload.ts b/apps/api/src/auth/interfaces/jwt-payload.ts new file mode 100644 index 0000000..d248115 --- /dev/null +++ b/apps/api/src/auth/interfaces/jwt-payload.ts @@ -0,0 +1,4 @@ +export interface JwtPayload { + sub: number + email: string +} diff --git a/apps/api/src/auth/interfaces/profile.ts b/apps/api/src/auth/interfaces/profile.ts new file mode 100644 index 0000000..6d055fd --- /dev/null +++ b/apps/api/src/auth/interfaces/profile.ts @@ -0,0 +1,6 @@ +export interface Profile { + name: string + email: string + picture?: string + provider?: string +} diff --git a/apps/api/src/auth/strategies/google.strategy.ts b/apps/api/src/auth/strategies/google.strategy.ts new file mode 100644 index 0000000..bd47325 --- /dev/null +++ b/apps/api/src/auth/strategies/google.strategy.ts @@ -0,0 +1,33 @@ +import { ConfigService } from '@nestjs/config' +import { PassportStrategy } from '@nestjs/passport' +import { Strategy, VerifyCallback } from 'passport-google-oauth20' + +export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { + constructor (private readonly config: ConfigService) { + super({ + clientID: process.env.OAUTH_GOOGLE_CLIENT_ID, + clientSecret: process.env.OAUTH_GOOGLE_CLIENT_SECRET, + callbackURL: process.env.OAUTH_GOOGLE_REDIRECT_URI, + scope: ['email', 'profile'] + }) + } + + async validate ( + _accessToken: string, + _refreshToken: string, + profile: any, + done: VerifyCallback + ): Promise { + const { id, name, emails, photos } = profile + + const user = { + provider: 'google', + providerId: id, + email: emails[0].value, + name: `${name.givenName} ${name.familyName}`, + picture: photos[0].value + } + + done(null, user) + } +} diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..1cd2439 --- /dev/null +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,41 @@ +import { ExtractJwt, Strategy } from 'passport-jwt' +import { PassportStrategy } from '@nestjs/passport' +import { Injectable, UnauthorizedException } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { Request } from 'express' + +import { UsersService } from '../../users/users.service' +import { JwtPayload } from '../interfaces/jwt-payload' + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor ( + private readonly config: ConfigService, + private readonly usersService: UsersService + ) { + const extractJwtFromCookie = (req: Request) => { + let token = null + if (req?.cookies) { + token = req.cookies.access_token + } + return token || ExtractJwt.fromAuthHeaderAsBearerToken()(req) + } + + super({ + ignoreExpiration: false, + secretOrKey: config.get('JWT_SECRET'), + jwtFromRequest: extractJwtFromCookie + }) + } + + async validate (payload: JwtPayload) { + const user = await this.usersService.findOne(payload.sub) + + if (!user) throw new UnauthorizedException('Please log in to continue') + + return { + id: payload.sub, + email: payload.email + } + } +} diff --git a/apps/api/src/database/factories/user.ts b/apps/api/src/database/factories/user.ts index 290c189..f30c56d 100644 --- a/apps/api/src/database/factories/user.ts +++ b/apps/api/src/database/factories/user.ts @@ -14,6 +14,7 @@ export const userFactory = Factory.define(({ sequence, onCreate }) // ID is auto-generated name: user.name, email: user.email, + picture: user.picture, createdAt: user.createdAt, updatedAt: user.updatedAt } @@ -23,6 +24,7 @@ export const userFactory = Factory.define(({ sequence, onCreate }) id: sequence, name: faker.person.fullName(), email: faker.internet.email(), + picture: faker.image.avatar(), createdAt: new Date(), updatedAt: new Date() } diff --git a/apps/api/src/database/migrations/20240427102638_add_unique_email_constraint_to_users_table/migration.sql b/apps/api/src/database/migrations/20240427102638_add_unique_email_constraint_to_users_table/migration.sql new file mode 100644 index 0000000..105071f --- /dev/null +++ b/apps/api/src/database/migrations/20240427102638_add_unique_email_constraint_to_users_table/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[email]` on the table `users` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); diff --git a/apps/api/src/database/migrations/20240427103509_add_picture_field_to_users_table/migration.sql b/apps/api/src/database/migrations/20240427103509_add_picture_field_to_users_table/migration.sql new file mode 100644 index 0000000..918d15b --- /dev/null +++ b/apps/api/src/database/migrations/20240427103509_add_picture_field_to_users_table/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "picture" TEXT; diff --git a/apps/api/src/database/schema.prisma b/apps/api/src/database/schema.prisma index a7e80c6..35e27d2 100644 --- a/apps/api/src/database/schema.prisma +++ b/apps/api/src/database/schema.prisma @@ -17,7 +17,8 @@ datasource db { model User { id Int @id @default(autoincrement()) name String - email String + email String @unique + picture String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index b2dd600..e355ccb 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -15,6 +15,7 @@ import { } from 'nest-winston' import * as winston from 'winston' import { patchNestJsSwagger } from 'nestjs-zod' +import cookieParser from 'cookie-parser' import { AppModule } from './app/app.module' @@ -63,6 +64,7 @@ export async function createApp ( logger: bootstrapLogger() }) + app.use(cookieParser()) app.setGlobalPrefix(API_PREFIX) bootstrapOpenAPI(app) diff --git a/apps/api/src/subscriptions/entities/subscription.entity.ts b/apps/api/src/subscriptions/entities/subscription.entity.ts index e7eb7c6..49ac251 100644 --- a/apps/api/src/subscriptions/entities/subscription.entity.ts +++ b/apps/api/src/subscriptions/entities/subscription.entity.ts @@ -1,7 +1,7 @@ import { createZodDto } from 'nestjs-zod' import { z } from 'nestjs-zod/z' -const SubscriptionResponseSchema = z.object({ +const SubscriptionSchema = z.object({ id: z.number(), userId: z.number(), companyId: z.number(), @@ -14,4 +14,4 @@ const SubscriptionResponseSchema = z.object({ updatedAt: z.date() }) -export class SubscriptionResponse extends createZodDto(SubscriptionResponseSchema) {} +export class Subscription extends createZodDto(SubscriptionSchema) {} diff --git a/apps/api/src/subscriptions/subscriptions.controller.ts b/apps/api/src/subscriptions/subscriptions.controller.ts index a4be9c1..9384458 100644 --- a/apps/api/src/subscriptions/subscriptions.controller.ts +++ b/apps/api/src/subscriptions/subscriptions.controller.ts @@ -5,9 +5,9 @@ import { ZodSerializerDto } from 'nestjs-zod' import { SubscriptionsService } from './subscriptions.service' import { CreateSubscriptionDto } from './dto/create-subscription.dto' import { UpdateSubscriptionDto } from './dto/update-subscription.dto' -import { SubscriptionResponse } from './entities/subscription.entity' +import { Subscription } from './entities/subscription.entity' -@ZodSerializerDto(SubscriptionResponse) +@ZodSerializerDto(Subscription) @ApiTags('subscriptions') @Controller('subscriptions') export class SubscriptionsController { diff --git a/apps/api/src/users/dto/create-user.dto.ts b/apps/api/src/users/dto/create-user.dto.ts new file mode 100644 index 0000000..29de514 --- /dev/null +++ b/apps/api/src/users/dto/create-user.dto.ts @@ -0,0 +1,10 @@ +import { createZodDto } from 'nestjs-zod' +import { z } from 'nestjs-zod/z' + +const UserSchema = z.object({ + name: z.string(), + email: z.string().email(), + picture: z.string().url().optional() +}) + +export class CreateUserDto extends createZodDto(UserSchema) {} diff --git a/apps/api/src/users/dto/update-user.dto.ts b/apps/api/src/users/dto/update-user.dto.ts new file mode 100644 index 0000000..f7f2851 --- /dev/null +++ b/apps/api/src/users/dto/update-user.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger' + +import { CreateUserDto } from './create-user.dto' + +export class UpdateUserDto extends PartialType(CreateUserDto) {} diff --git a/apps/api/src/users/entities/user.entity.ts b/apps/api/src/users/entities/user.entity.ts new file mode 100644 index 0000000..5a96892 --- /dev/null +++ b/apps/api/src/users/entities/user.entity.ts @@ -0,0 +1,11 @@ +import { createZodDto } from 'nestjs-zod' +import { z } from 'nestjs-zod/z' + +const UserSchema = z.object({ + id: z.number(), + email: z.string().email(), + createdAt: z.date(), + updatedAt: z.date() +}) + +export class User extends createZodDto(UserSchema) {} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts new file mode 100644 index 0000000..ea62caa --- /dev/null +++ b/apps/api/src/users/users.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' + +import { UsersService } from './users.service' + +@Module({ + providers: [UsersService], + exports: [UsersService] +}) +export class UsersModule {} diff --git a/apps/api/src/users/users.service.spec.ts b/apps/api/src/users/users.service.spec.ts new file mode 100644 index 0000000..84f6bce --- /dev/null +++ b/apps/api/src/users/users.service.spec.ts @@ -0,0 +1,98 @@ +import { TestBed } from '@automock/jest' + +import { userFactory } from '../database/factories/user' +import { PrismaService } from '../database/prisma.service' + +import { UsersService } from './users.service' + +const mockUser = userFactory.build({ + email: 'test-email', + name: 'test-name', + picture: 'test-picture' +}) + +describe('UsersService', () => { + let service: UsersService + const prisma = { + user: { + create: jest.fn().mockResolvedValue(mockUser), + upsert: jest.fn().mockResolvedValue(mockUser), + findMany: jest.fn().mockResolvedValue([mockUser]), + findUnique: jest.fn().mockResolvedValue(mockUser), + update: jest.fn().mockResolvedValue(mockUser), + delete: jest.fn().mockResolvedValue(mockUser) + } + } + + beforeEach(async () => { + const { unit } = TestBed.create(UsersService) + .mock(PrismaService) + .using(prisma) + .compile() + + service = unit + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should create a user', async () => { + const user = await service.create({ + email: 'test-email', + name: 'test-name', + picture: 'test-picture' + }) + + expect(user).toMatchObject({ id: 1, email: 'test-email', name: 'test-name', picture: 'test-picture' }) + }) + + it('should find or create a user', async () => { + const user = await service.findOrCreate({ + email: 'test-email', + name: 'test-name', + picture: 'test-picture' + }) + + expect(user).toEqual(mockUser) + }) + + it('should return an array of users', async () => { + const users = await service.findAll() + + expect(users).toEqual([mockUser]) + }) + + it('should return a user by id', async () => { + const user = await service.findOne(1) + + expect(user).toEqual(mockUser) + }) + + it('should update a user', async () => { + const user = await service.update(1, { + email: 'test-email', + name: 'test-name', + picture: 'test-picture' + }) + + expect(user).toEqual(mockUser) + }) + + it('should remove a user', async () => { + prisma.user.delete.mockResolvedValueOnce(mockUser) + const user = await service.remove(1) + + expect(user).toEqual(mockUser) + }) + + describe('when user does not exist', () => { + const error = new Error('Prisma error') + Object.assign(error, { code: 'P2025' }) + + it('should throw an error', async () => { + prisma.user.delete.mockRejectedValueOnce(error) + await expect(service.remove(2)).rejects.toThrow('User not found') + }) + }) +}) diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts new file mode 100644 index 0000000..a5c3871 --- /dev/null +++ b/apps/api/src/users/users.service.ts @@ -0,0 +1,52 @@ +import { Injectable, NotFoundException } from '@nestjs/common' + +import { PrismaService } from '../database/prisma.service' + +import { CreateUserDto } from './dto/create-user.dto' +import { UpdateUserDto } from './dto/update-user.dto' + +@Injectable() +export class UsersService { + constructor (private readonly db: PrismaService) {} + + async create (createUserDto: CreateUserDto) { + return await this.db.user.create({ data: createUserDto }) + } + + async findOrCreate (createUserDto: CreateUserDto) { + return await this.db.user.upsert({ + where: { email: createUserDto.email }, + update: {}, + create: createUserDto + }) + } + + async findAll () { + return await this.db.user.findMany() + } + + async findOne (id: number) { + return await this.db.user.findUnique({ + where: { id } + }) + } + + async update (id: number, data: UpdateUserDto) { + return await this.db.user.update({ + where: { id }, + data + }) + } + + async remove (id: number) { + try { + return await this.db.user.delete({ + where: { id } + }) + } catch (error) { + if (error.code === 'P2025') { + throw new NotFoundException('User not found') + } + } + } +} diff --git a/apps/api/test/auth.e2e-spec.ts b/apps/api/test/auth.e2e-spec.ts new file mode 100644 index 0000000..e4430d7 --- /dev/null +++ b/apps/api/test/auth.e2e-spec.ts @@ -0,0 +1,69 @@ +import { Server } from 'net' + +import { Test, TestingModule } from '@nestjs/testing' +import { INestApplication } from '@nestjs/common' +import request from 'supertest' +import nock from 'nock' +import { JwtService } from '@nestjs/jwt' + +import { AppModule } from '../src/app/app.module' + +describe('AuthController (e2e)', () => { + let app: INestApplication + + // Mock Google OAuth API + nock('https://www.googleapis.com') + .post('/oauth2/v4/token') + .reply(200, { + access_token: 'your_access_token' // Access token for API access + }) + + nock('https://www.googleapis.com') + .get('/oauth2/v3/userinfo') + .query({ access_token: 'your_access_token' }) + .reply(200, { + sub: '1234567890', + name: 'John Doe', + given_name: 'John', + family_name: 'Doe', + picture: 'image-url', + email: 'john.doe@example.com' + }) + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule] + }).compile() + + app = moduleFixture.createNestApplication() + await app.init() + }) + + afterAll(async () => { + await app.close() + }) + + it('/auth/google (GET)', async () => { + const response = await request(app.getHttpServer()).get('/auth/google') + + expect(response.status).toBe(302) + expect(response.headers.location).toContain('accounts.google.com') + }) + + it('/auth/google/callback (GET)', async () => { + const jwt = app.get(JwtService) + + const response = await request(app.getHttpServer()).get('/auth/google/callback?code=your_code') + const cookies = response.headers['set-cookie'] as unknown as string[] + + // Parse access token from cookie + const tokenCookie = cookies.find(cookie => cookie.startsWith('access_token=')) + const accessToken = tokenCookie?.split(';')[0].split('=')[1] + + if (!accessToken) { + throw new Error('Access token not found') + } + + expect(jwt.decode(accessToken).email).toBe('john.doe@example.com') + }) +}) diff --git a/apps/api/test/helpers/global-setup.ts b/apps/api/test/helpers/global-setup.ts index ac69735..a639f47 100644 --- a/apps/api/test/helpers/global-setup.ts +++ b/apps/api/test/helpers/global-setup.ts @@ -16,7 +16,7 @@ export default async () => { ) console.info('🟢 - Database is ready!') - execSync('dotenv -e .env.test -- yarn db:deploy', { stdio: 'inherit' }) + execSync('dotenv -e .test.env -- yarn db:deploy', { stdio: 'inherit' }) } catch (e) { console.dir(e) throw e diff --git a/package.json b/package.json index 498a938..6d14abd 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,7 @@ "typescript": "^5.4.5" }, "volta": { - "node": "21.7.2", - "yarn": "4.1.1" + "node": "21.7.2" }, "packageManager": "yarn@4.1.1" } diff --git a/yarn.lock b/yarn.lock index 5e82c38..1c4cd8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3952,6 +3952,18 @@ __metadata: languageName: node linkType: hard +"@nestjs/jwt@npm:^10.2.0": + version: 10.2.0 + resolution: "@nestjs/jwt@npm:10.2.0" + dependencies: + "@types/jsonwebtoken": "npm:9.0.5" + jsonwebtoken: "npm:9.0.2" + peerDependencies: + "@nestjs/common": ^8.0.0 || ^9.0.0 || ^10.0.0 + checksum: 10c0/81c5cbcb459122b175ad6b50dad83aab7d5dc3beb6122a56c7f985cc1c7838cd1c5eae9d630e95550b95a03e183502a183029e36ba51879c638bd0bad086c056 + languageName: node + linkType: hard + "@nestjs/mapped-types@npm:2.0.5": version: 2.0.5 resolution: "@nestjs/mapped-types@npm:2.0.5" @@ -3969,6 +3981,16 @@ __metadata: languageName: node linkType: hard +"@nestjs/passport@npm:^10.0.3": + version: 10.0.3 + resolution: "@nestjs/passport@npm:10.0.3" + peerDependencies: + "@nestjs/common": ^8.0.0 || ^9.0.0 || ^10.0.0 + passport: ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 + checksum: 10c0/9e8a6103407852951625e75d0abd82a0f9786d4f27fc7036731ccbac39cbdb4e597a7313e53a266bb1fe1ec36c5193365abeb3264f5d285ba0aaeb23ee8e3f1b + languageName: node + linkType: hard + "@nestjs/platform-express@npm:^10.0.0": version: 10.3.7 resolution: "@nestjs/platform-express@npm:10.3.7" @@ -5401,6 +5423,15 @@ __metadata: languageName: node linkType: hard +"@types/cookie-parser@npm:^1.4.7": + version: 1.4.7 + resolution: "@types/cookie-parser@npm:1.4.7" + dependencies: + "@types/express": "npm:*" + checksum: 10c0/af37fea5399950e59ceb2e1f25c633f3df360c4f17e8b3f26418e672fe5c926a20993b86f8e1df72cfe2c4dc8967d9a18d3d78b5c6a5f751a297d0418e5690fa + languageName: node + linkType: hard + "@types/cookiejar@npm:^2.1.5": version: 2.1.5 resolution: "@types/cookiejar@npm:2.1.5" @@ -5577,6 +5608,24 @@ __metadata: languageName: node linkType: hard +"@types/jsonwebtoken@npm:*": + version: 9.0.6 + resolution: "@types/jsonwebtoken@npm:9.0.6" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/9c29e3896e5fb6056e54d87514643e59e0cfb966ae25171a107776270195bba955f0373e98c8ed6450c145b18984f5df9cf0fcac360f382cec3c7c4d3510b202 + languageName: node + linkType: hard + +"@types/jsonwebtoken@npm:9.0.5": + version: 9.0.5 + resolution: "@types/jsonwebtoken@npm:9.0.5" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/c582b8420586f3b9550f7e34992cb32be300bc953636f3b087ed9c180ce7ea5c2e4b35090be2d57f0d3168cc3ca1074932907caa2afe09f4e9c84cf5c0daefa8 + languageName: node + linkType: hard + "@types/keyv@npm:^3.1.4": version: 3.1.4 resolution: "@types/keyv@npm:3.1.4" @@ -5627,6 +5676,15 @@ __metadata: languageName: node linkType: hard +"@types/oauth@npm:*": + version: 0.9.4 + resolution: "@types/oauth@npm:0.9.4" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/fcf8f5db7756a3e4d406ce3520abf78fecaf5e14963ad75fe0057425bb8b754e98d09dcb2b73a70e8fbe1e345b7d972d8090cf8cf08247e80d6eaac3acbe3fc7 + languageName: node + linkType: hard + "@types/parse-json@npm:^4.0.0": version: 4.0.2 resolution: "@types/parse-json@npm:4.0.2" @@ -5634,6 +5692,68 @@ __metadata: languageName: node linkType: hard +"@types/passport-google-oauth20@npm:^2.0.14": + version: 2.0.14 + resolution: "@types/passport-google-oauth20@npm:2.0.14" + dependencies: + "@types/express": "npm:*" + "@types/passport": "npm:*" + "@types/passport-oauth2": "npm:*" + checksum: 10c0/95881a56f8323c21b0c3b48c4788fc3fb0986b0cb2a7321c9182c0a7f09b30ddd15c1a2ff3fd90ce7b11cc8ba6f6fbd13438d7914e62f1731f4bed191e515a92 + languageName: node + linkType: hard + +"@types/passport-jwt@npm:^4.0.1": + version: 4.0.1 + resolution: "@types/passport-jwt@npm:4.0.1" + dependencies: + "@types/jsonwebtoken": "npm:*" + "@types/passport-strategy": "npm:*" + checksum: 10c0/0ced0eaa7bb379d674821108d9bc6758223f1a5f2b9790ec78d3eaaccce6a58a424cf8ed22b53d813740ec53d929e21d92cf794ef0fb30c732866750763c0d7a + languageName: node + linkType: hard + +"@types/passport-local@npm:^1.0.38": + version: 1.0.38 + resolution: "@types/passport-local@npm:1.0.38" + dependencies: + "@types/express": "npm:*" + "@types/passport": "npm:*" + "@types/passport-strategy": "npm:*" + checksum: 10c0/a8464df03f073a4bb9aef7fa7cc9e76a355f149a1148330da88346d0e9c600f845601e99ed40949a13287eacae0a7ad01cd0eb5ca00d8b81da263b1dfc3aee60 + languageName: node + linkType: hard + +"@types/passport-oauth2@npm:*": + version: 1.4.15 + resolution: "@types/passport-oauth2@npm:1.4.15" + dependencies: + "@types/express": "npm:*" + "@types/oauth": "npm:*" + "@types/passport": "npm:*" + checksum: 10c0/3f2b1c5c96ce0a1af9d55c07b18d68e443adccd9c5d429c0b08c6e35f0ca259bf77c29cecf2b94c1a035ce7c1c84087a2528e5ecc5ecfa0fba543e158bab7427 + languageName: node + linkType: hard + +"@types/passport-strategy@npm:*": + version: 0.2.38 + resolution: "@types/passport-strategy@npm:0.2.38" + dependencies: + "@types/express": "npm:*" + "@types/passport": "npm:*" + checksum: 10c0/d7d2b1782a0845bd8914250aa9213a23c8d9c2225db46d854b77f2bf0129a789f46d4a5e9ad336eca277fc7e0a051c0a2942da5c864e7c6710763f102d9d4295 + languageName: node + linkType: hard + +"@types/passport@npm:*": + version: 1.0.16 + resolution: "@types/passport@npm:1.0.16" + dependencies: + "@types/express": "npm:*" + checksum: 10c0/7120c1186c8c67e3818683b5b6a4439d102f67da93cc1c7d8f32484f7bf10e8438dd5de0bf571910b23d06caa43dd1ad501933b48618bfaf54e63219500993fe + languageName: node + linkType: hard + "@types/prettier@npm:^2.1.5": version: 2.7.3 resolution: "@types/prettier@npm:2.7.3" @@ -6875,6 +6995,8 @@ __metadata: "@nestjs/common": "npm:^10.0.0" "@nestjs/config": "npm:^3.2.2" "@nestjs/core": "npm:^10.0.0" + "@nestjs/jwt": "npm:^10.2.0" + "@nestjs/passport": "npm:^10.0.3" "@nestjs/platform-express": "npm:^10.0.0" "@nestjs/schematics": "npm:^10.0.0" "@nestjs/swagger": "npm:^7.3.1" @@ -6883,13 +7005,18 @@ __metadata: "@swc/cli": "npm:^0.3.12" "@swc/core": "npm:^1.4.12" "@swc/jest": "npm:^0.2.36" + "@types/cookie-parser": "npm:^1.4.7" "@types/express": "npm:^4.17.17" "@types/jest": "npm:^29.5.2" "@types/node": "npm:^20.3.1" + "@types/passport-google-oauth20": "npm:^2.0.14" + "@types/passport-jwt": "npm:^4.0.1" + "@types/passport-local": "npm:^1.0.38" "@types/supertest": "npm:^6.0.0" "@typescript-eslint/eslint-plugin": "npm:^6.0.0" "@typescript-eslint/parser": "npm:^6.0.0" axios: "npm:^1.6.8" + cookie-parser: "npm:^1.4.6" docker-compose: "npm:^0.24.8" dotenv-cli: "npm:^7.4.1" eslint: "npm:^8.42.0" @@ -6899,6 +7026,11 @@ __metadata: jest: "npm:^29.7.0" nest-winston: "npm:^1.9.4" nestjs-zod: "npm:^3.0.0" + nock: "npm:^13.5.4" + passport: "npm:^0.7.0" + passport-google-oauth20: "npm:^2.0.0" + passport-jwt: "npm:^4.0.1" + passport-local: "npm:^1.0.0" prettier: "npm:^3.0.0" prisma: "npm:^5.11.0" reflect-metadata: "npm:^0.2.0" @@ -7675,6 +7807,13 @@ __metadata: languageName: node linkType: hard +"base64url@npm:3.x.x": + version: 3.0.1 + resolution: "base64url@npm:3.0.1" + checksum: 10c0/5ca9d6064e9440a2a45749558dddd2549ca439a305793d4f14a900b7256b5f4438ef1b7a494e1addc66ced5d20f5c010716d353ed267e4b769e6c78074991241 + languageName: node + linkType: hard + "basic-ftp@npm:^5.0.2": version: 5.0.5 resolution: "basic-ftp@npm:5.0.5" @@ -7944,6 +8083,13 @@ __metadata: languageName: node linkType: hard +"buffer-equal-constant-time@npm:1.0.1": + version: 1.0.1 + resolution: "buffer-equal-constant-time@npm:1.0.1" + checksum: 10c0/fb2294e64d23c573d0dd1f1e7a466c3e978fe94a4e0f8183937912ca374619773bef8e2aceb854129d2efecbbc515bbd0cc78d2734a3e3031edb0888531bbc8e + languageName: node + linkType: hard + "buffer-fill@npm:^1.0.0": version: 1.0.0 resolution: "buffer-fill@npm:1.0.0" @@ -8844,6 +8990,16 @@ __metadata: languageName: node linkType: hard +"cookie-parser@npm:^1.4.6": + version: 1.4.6 + resolution: "cookie-parser@npm:1.4.6" + dependencies: + cookie: "npm:0.4.1" + cookie-signature: "npm:1.0.6" + checksum: 10c0/9c2ade5459290802cd472a2d2a6e46fbd7de3e8514e02bfed5edfde892d77733c7f89d9d2015f752a9087680429b416972d7aba748bf6824e21eb680c8556383 + languageName: node + linkType: hard + "cookie-signature@npm:1.0.6": version: 1.0.6 resolution: "cookie-signature@npm:1.0.6" @@ -8851,6 +9007,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:0.4.1": + version: 0.4.1 + resolution: "cookie@npm:0.4.1" + checksum: 10c0/4d7bc798df3d0f34035977949cd6b7d05bbab47d7dcb868667f460b578a550cd20dec923832b8a3a107ef35aba091a3975e14f79efacf6e39282dc0fed6db4a1 + languageName: node + linkType: hard + "cookie@npm:0.6.0": version: 0.6.0 resolution: "cookie@npm:0.6.0" @@ -9982,6 +10145,15 @@ __metadata: languageName: node linkType: hard +"ecdsa-sig-formatter@npm:1.0.11": + version: 1.0.11 + resolution: "ecdsa-sig-formatter@npm:1.0.11" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10c0/ebfbf19d4b8be938f4dd4a83b8788385da353d63307ede301a9252f9f7f88672e76f2191618fd8edfc2f24679236064176fab0b78131b161ee73daa37125408c + languageName: node + linkType: hard + "ee-first@npm:1.1.1": version: 1.1.1 resolution: "ee-first@npm:1.1.1" @@ -15304,6 +15476,13 @@ __metadata: languageName: node linkType: hard +"json-stringify-safe@npm:^5.0.1": + version: 5.0.1 + resolution: "json-stringify-safe@npm:5.0.1" + checksum: 10c0/7dbf35cd0411d1d648dceb6d59ce5857ec939e52e4afc37601aa3da611f0987d5cee5b38d58329ceddf3ed48bd7215229c8d52059ab01f2444a338bf24ed0f37 + languageName: node + linkType: hard + "json5@npm:^1.0.1, json5@npm:^1.0.2": version: 1.0.2 resolution: "json5@npm:1.0.2" @@ -15381,6 +15560,24 @@ __metadata: languageName: node linkType: hard +"jsonwebtoken@npm:9.0.2, jsonwebtoken@npm:^9.0.0": + version: 9.0.2 + resolution: "jsonwebtoken@npm:9.0.2" + dependencies: + jws: "npm:^3.2.2" + lodash.includes: "npm:^4.3.0" + lodash.isboolean: "npm:^3.0.3" + lodash.isinteger: "npm:^4.0.4" + lodash.isnumber: "npm:^3.0.3" + lodash.isplainobject: "npm:^4.0.6" + lodash.isstring: "npm:^4.0.1" + lodash.once: "npm:^4.0.0" + ms: "npm:^2.1.1" + semver: "npm:^7.5.4" + checksum: 10c0/d287a29814895e866db2e5a0209ce730cbc158441a0e5a70d5e940eb0d28ab7498c6bf45029cc8b479639bca94056e9a7f254e2cdb92a2f5750c7f358657a131 + languageName: node + linkType: hard + "jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.5": version: 3.3.5 resolution: "jsx-ast-utils@npm:3.3.5" @@ -15393,6 +15590,27 @@ __metadata: languageName: node linkType: hard +"jwa@npm:^1.4.1": + version: 1.4.1 + resolution: "jwa@npm:1.4.1" + dependencies: + buffer-equal-constant-time: "npm:1.0.1" + ecdsa-sig-formatter: "npm:1.0.11" + safe-buffer: "npm:^5.0.1" + checksum: 10c0/5c533540bf38702e73cf14765805a94027c66a0aa8b16bc3e89d8d905e61a4ce2791e87e21be97d1293a5ee9d4f3e5e47737e671768265ca4f25706db551d5e9 + languageName: node + linkType: hard + +"jws@npm:^3.2.2": + version: 3.2.2 + resolution: "jws@npm:3.2.2" + dependencies: + jwa: "npm:^1.4.1" + safe-buffer: "npm:^5.0.1" + checksum: 10c0/e770704533d92df358adad7d1261fdecad4d7b66fa153ba80d047e03ca0f1f73007ce5ed3fbc04d2eba09ba6e7e6e645f351e08e5ab51614df1b0aa4f384dfff + languageName: node + linkType: hard + "keyv@npm:^4.0.0, keyv@npm:^4.5.3, keyv@npm:^4.5.4": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -15678,6 +15896,20 @@ __metadata: languageName: node linkType: hard +"lodash.includes@npm:^4.3.0": + version: 4.3.0 + resolution: "lodash.includes@npm:4.3.0" + checksum: 10c0/7ca498b9b75bf602d04e48c0adb842dfc7d90f77bcb2a91a2b2be34a723ad24bc1c8b3683ec6b2552a90f216c723cdea530ddb11a3320e08fa38265703978f4b + languageName: node + linkType: hard + +"lodash.isboolean@npm:^3.0.3": + version: 3.0.3 + resolution: "lodash.isboolean@npm:3.0.3" + checksum: 10c0/0aac604c1ef7e72f9a6b798e5b676606042401dd58e49f051df3cc1e3adb497b3d7695635a5cbec4ae5f66456b951fdabe7d6b387055f13267cde521f10ec7f7 + languageName: node + linkType: hard + "lodash.isequal@npm:^4.5.0": version: 4.5.0 resolution: "lodash.isequal@npm:4.5.0" @@ -15685,6 +15917,34 @@ __metadata: languageName: node linkType: hard +"lodash.isinteger@npm:^4.0.4": + version: 4.0.4 + resolution: "lodash.isinteger@npm:4.0.4" + checksum: 10c0/4c3e023a2373bf65bf366d3b8605b97ec830bca702a926939bcaa53f8e02789b6a176e7f166b082f9365bfec4121bfeb52e86e9040cb8d450e64c858583f61b7 + languageName: node + linkType: hard + +"lodash.isnumber@npm:^3.0.3": + version: 3.0.3 + resolution: "lodash.isnumber@npm:3.0.3" + checksum: 10c0/2d01530513a1ee4f72dd79528444db4e6360588adcb0e2ff663db2b3f642d4bb3d687051ae1115751ca9082db4fdef675160071226ca6bbf5f0c123dbf0aa12d + languageName: node + linkType: hard + +"lodash.isplainobject@npm:^4.0.6": + version: 4.0.6 + resolution: "lodash.isplainobject@npm:4.0.6" + checksum: 10c0/afd70b5c450d1e09f32a737bed06ff85b873ecd3d3d3400458725283e3f2e0bb6bf48e67dbe7a309eb371a822b16a26cca4a63c8c52db3fc7dc9d5f9dd324cbb + languageName: node + linkType: hard + +"lodash.isstring@npm:^4.0.1": + version: 4.0.1 + resolution: "lodash.isstring@npm:4.0.1" + checksum: 10c0/09eaf980a283f9eef58ef95b30ec7fee61df4d6bf4aba3b5f096869cc58f24c9da17900febc8ffd67819b4e29de29793190e88dc96983db92d84c95fa85d1c92 + languageName: node + linkType: hard + "lodash.memoize@npm:4.x, lodash.memoize@npm:^4.1.2": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" @@ -15706,6 +15966,13 @@ __metadata: languageName: node linkType: hard +"lodash.once@npm:^4.0.0": + version: 4.1.1 + resolution: "lodash.once@npm:4.1.1" + checksum: 10c0/46a9a0a66c45dd812fcc016e46605d85ad599fe87d71a02f6736220554b52ffbe82e79a483ad40f52a8a95755b0d1077fba259da8bfb6694a7abbf4a48f1fc04 + languageName: node + linkType: hard + "lodash.sortby@npm:^4.7.0": version: 4.7.0 resolution: "lodash.sortby@npm:4.7.0" @@ -17134,6 +17401,17 @@ __metadata: languageName: node linkType: hard +"nock@npm:^13.5.4": + version: 13.5.4 + resolution: "nock@npm:13.5.4" + dependencies: + debug: "npm:^4.1.0" + json-stringify-safe: "npm:^5.0.1" + propagate: "npm:^2.0.0" + checksum: 10c0/9ca47d9d7e4b1f4adf871d7ca12722f8ef1dc7d2b9610b2568f5d9264eae9f424baa24fd9d91da9920b360d641b4243e89de198bd22c061813254a99cc6252af + languageName: node + linkType: hard + "node-abort-controller@npm:^3.0.1": version: 3.1.1 resolution: "node-abort-controller@npm:3.1.1" @@ -17351,6 +17629,13 @@ __metadata: languageName: node linkType: hard +"oauth@npm:0.10.x": + version: 0.10.0 + resolution: "oauth@npm:0.10.0" + checksum: 10c0/76f3e186cfd76cb33e5d5d442861c86680a5c3b71b2db1b854212087532c265a69de1a2ab9db683e6c6df733e17cfc67476527b81b224a19c1917de2bc3f75fa + languageName: node + linkType: hard + "ob1@npm:0.73.10": version: 0.73.10 resolution: "ob1@npm:0.73.10" @@ -17869,6 +18154,65 @@ __metadata: languageName: node linkType: hard +"passport-google-oauth20@npm:^2.0.0": + version: 2.0.0 + resolution: "passport-google-oauth20@npm:2.0.0" + dependencies: + passport-oauth2: "npm:1.x.x" + checksum: 10c0/158930bb97a48431aa0dcff453c3b698742ed51e2d590c362cb5d4ae7715cfb4fb1feae31b007aef0bc8435edc8ff678853c044b139da827756f3b5f3b597c7f + languageName: node + linkType: hard + +"passport-jwt@npm:^4.0.1": + version: 4.0.1 + resolution: "passport-jwt@npm:4.0.1" + dependencies: + jsonwebtoken: "npm:^9.0.0" + passport-strategy: "npm:^1.0.0" + checksum: 10c0/d7e2b472d399f596a1db31310f8e63d10777ab7468b9a378c964156e5f0a772598b007417356ead578cfdaf60dc2bba39a55f0033ca865186fdb2a2b198e2e7e + languageName: node + linkType: hard + +"passport-local@npm:^1.0.0": + version: 1.0.0 + resolution: "passport-local@npm:1.0.0" + dependencies: + passport-strategy: "npm:1.x.x" + checksum: 10c0/59becb988014921a5d6056470d9373c41db452fcf113323064f39d53baa6f184e72151bf269ca6770511f7f0260e13632dacc7b6afdbf60ebf63e90327e186d4 + languageName: node + linkType: hard + +"passport-oauth2@npm:1.x.x": + version: 1.8.0 + resolution: "passport-oauth2@npm:1.8.0" + dependencies: + base64url: "npm:3.x.x" + oauth: "npm:0.10.x" + passport-strategy: "npm:1.x.x" + uid2: "npm:0.0.x" + utils-merge: "npm:1.x.x" + checksum: 10c0/16b431bd856b84dfe0c9c913dcbea6ff54875befac1035171b0dce1c77f79072dc5e26d785b13c2e62c034c8174a1a47571751d1066bdbcdb9108de217c0b19b + languageName: node + linkType: hard + +"passport-strategy@npm:1.x.x, passport-strategy@npm:^1.0.0": + version: 1.0.0 + resolution: "passport-strategy@npm:1.0.0" + checksum: 10c0/cf4cd32e1bf2538a239651581292fbb91ccc83973cde47089f00d2014c24bed63d3e65af21da8ddef649a8896e089eb9c3ac9ca639f36c797654ae9ee4ed65e1 + languageName: node + linkType: hard + +"passport@npm:^0.7.0": + version: 0.7.0 + resolution: "passport@npm:0.7.0" + dependencies: + passport-strategy: "npm:1.x.x" + pause: "npm:0.0.1" + utils-merge: "npm:^1.0.1" + checksum: 10c0/08c940b86e4adbfe43e753f8097300a5a9d1ce9a3aa002d7b12d27770943a1a87202c54597c0f04dbfd4117d67de76303433577512fc19c7e364fec37b0d3fc5 + languageName: node + linkType: hard + "password-prompt@npm:^1.0.4": version: 1.1.3 resolution: "password-prompt@npm:1.1.3" @@ -17973,6 +18317,13 @@ __metadata: languageName: node linkType: hard +"pause@npm:0.0.1": + version: 0.0.1 + resolution: "pause@npm:0.0.1" + checksum: 10c0/f362655dfa7f44b946302c5a033148852ed5d05f744bd848b1c7eae6a543f743e79c7751ee896ba519fd802affdf239a358bb2ea5ca1b1c1e4e916279f83ab75 + languageName: node + linkType: hard + "peek-readable@npm:^5.0.0": version: 5.0.0 resolution: "peek-readable@npm:5.0.0" @@ -19161,6 +19512,13 @@ __metadata: languageName: node linkType: hard +"propagate@npm:^2.0.0": + version: 2.0.1 + resolution: "propagate@npm:2.0.1" + checksum: 10c0/01e1023b60ae4050d1a2783f976d7db702022dbdb70dba797cceedad8cfc01b3939c41e77032f8c32aa9d93192fe937ebba1345e8604e5ce61fd3b62ee3003b8 + languageName: node + linkType: hard + "proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" @@ -20505,7 +20863,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 @@ -22798,6 +23156,13 @@ __metadata: languageName: node linkType: hard +"uid2@npm:0.0.x": + version: 0.0.4 + resolution: "uid2@npm:0.0.4" + checksum: 10c0/c3ed69da75d117214891f4743a1d8521db823d7a2f57644c1a9ae8b3bf25f0ba666d893264bf7e22be3dbbaa292d35a23d71d06ce7283458a65e8dd137c5c362 + languageName: node + linkType: hard + "uid@npm:2.0.2": version: 2.0.2 resolution: "uid@npm:2.0.2" @@ -23068,7 +23433,7 @@ __metadata: languageName: node linkType: hard -"utils-merge@npm:1.0.1": +"utils-merge@npm:1.0.1, utils-merge@npm:1.x.x, utils-merge@npm:^1.0.1": version: 1.0.1 resolution: "utils-merge@npm:1.0.1" checksum: 10c0/02ba649de1b7ca8854bfe20a82f1dfbdda3fb57a22ab4a8972a63a34553cf7aa51bc9081cf7e001b035b88186d23689d69e71b510e610a09a4c66f68aa95b672