Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Backend] Implement Google OAuth #21

Merged
merged 13 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 1 addition & 2 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"esbenp.prettier-vscode",
"firsttris.vscode-jest-runner",
"vivaxy.vscode-conventional-commits",
"prisma.prisma",
"abians.prisma-generate-uml"
"prisma.prisma"
]
}
3 changes: 3 additions & 0 deletions apps/api/.env.development → apps/api/.development.env
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 0 additions & 1 deletion apps/api/.env.test

This file was deleted.

7 changes: 7 additions & 0 deletions apps/api/.env.example → apps/api/.example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 4 additions & 0 deletions apps/api/.test.env
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions apps/api/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default {
}
]
},
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api',
};
16 changes: 14 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"dev": "vite",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"console": "yarn start -- --watch --entryFile repl",
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like I'm swapping between yarn and npm, but I guess we're using npm now? 😃

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hahaha sorry I should have left a note here.. yarn was failing to capture the terminal session when using the REPL. For some reason, running this command specifically with npm works and I didn't want to put effort to investigate why and fix it.

We're definitely using yarn and you should still run yarn console

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep a proper ticket here :D #23

"console": "npm run start -- --entryFile repl",
"db:deploy": "npx prisma migrate deploy",
"db:migrate": "npx prisma migrate dev",
"db:seed": "npx prisma db seed",
Expand All @@ -20,7 +20,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",
"test:e2e": "dotenv -e .test.env -- jest --config ./test/jest-e2e.json",
"test:watch": "jest --watch"
},
"lint-staged": {
Expand All @@ -36,12 +36,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",
Expand All @@ -58,9 +65,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",
Expand All @@ -71,6 +82,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",
Expand Down
9 changes: 6 additions & 3 deletions apps/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
7 changes: 5 additions & 2 deletions apps/api/src/app/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ export function validate (config: Record<string, unknown>) {
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)
Expand Down
46 changes: 46 additions & 0 deletions apps/api/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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<AuthService>

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<Request>({
user: {
email: 'test-email',
name: 'test-name',
picture: 'test-picture'
}
})

const res = createMock<Response>()
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')
})
})
25 changes: 25 additions & 0 deletions apps/api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
28 changes: 28 additions & 0 deletions apps/api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -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<string>('JWT_SECRET'),
signOptions: { expiresIn: config.get<string>('JWT_EXPIRES_IN') }
}),
inject: [ConfigService]
})
],
providers: [AuthService, GoogleStrategy],
controllers: [AuthController]
})
export class AuthModule {}
60 changes: 60 additions & 0 deletions apps/api/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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: '[email protected]' })
})
.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')
}
})
})
38 changes: 38 additions & 0 deletions apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -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
})
}
}
5 changes: 5 additions & 0 deletions apps/api/src/auth/guards/google.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'

@Injectable()
export class GoogleOauthGuard extends AuthGuard('google') {}
5 changes: 5 additions & 0 deletions apps/api/src/auth/guards/jwt.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
4 changes: 4 additions & 0 deletions apps/api/src/auth/interfaces/jwt-payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface JwtPayload {
sub: number
email: string
}
6 changes: 6 additions & 0 deletions apps/api/src/auth/interfaces/profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface Profile {
name: string
email: string
picture?: string
provider?: string
}
Loading