Skip to content

Commit

Permalink
Merge pull request #21 from mathiasberggren/feature/auth-passport
Browse files Browse the repository at this point in the history
[Backend] Implement Google OAuth
  • Loading branch information
rasouza authored Apr 30, 2024
2 parents b523966 + 055a2eb commit 2c057f4
Show file tree
Hide file tree
Showing 39 changed files with 976 additions and 20 deletions.
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",
"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 @@ -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": {
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
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
File renamed without changes.
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

0 comments on commit 2c057f4

Please sign in to comment.