Skip to content

Commit

Permalink
feat(auth): Add Google OAuth (#156)
Browse files Browse the repository at this point in the history
  • Loading branch information
risv1 authored Mar 3, 2024
1 parent f37569a commit cf387ea
Show file tree
Hide file tree
Showing 10 changed files with 190 additions and 3 deletions.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_CALLBACK_URL=

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=

SENTRY_DSN=
SENTRY_ORG=
SENTRY_PROJECT=
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"moment": "^2.30.1",
"nodemailer": "^6.9.9",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"prisma": "^5.10.1",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { JwtModule } from '@nestjs/jwt'
import { UserModule } from '../user/user.module'
import { GithubStrategy } from '../config/oauth-strategy/github/github.strategy'
import { GithubOAuthStrategyFactory } from '../config/factory/github/github-strategy.factory'
import { GoogleOAuthStrategyFactory } from '../config/factory/google/google-strategy.factory'
import { GoogleStrategy } from '../config/oauth-strategy/google/google.strategy'

@Module({
imports: [
Expand All @@ -28,6 +30,14 @@ import { GithubOAuthStrategyFactory } from '../config/factory/github/github-stra
githubOAuthStrategyFactory.createOAuthStrategy()
},
inject: [GithubOAuthStrategyFactory]
},
GoogleOAuthStrategyFactory,
{
provide: GoogleStrategy,
useFactory: (googleOAuthStrategyFactory: GoogleOAuthStrategyFactory) => {
googleOAuthStrategyFactory.createOAuthStrategy()
},
inject: [GoogleOAuthStrategyFactory]
}
],
controllers: [AuthController]
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/auth/controller/auth.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AuthController } from './auth.controller'
import { mockDeep } from 'jest-mock-extended'
import { ConfigService } from '@nestjs/config'
import { GithubOAuthStrategyFactory } from '../../config/factory/github/github-strategy.factory'
import { GoogleOAuthStrategyFactory } from '../../config/factory/google/google-strategy.factory'

describe('AuthController', () => {
let controller: AuthController
Expand All @@ -18,6 +19,7 @@ describe('AuthController', () => {
providers: [
AuthService,
GithubOAuthStrategyFactory,
GoogleOAuthStrategyFactory,
ConfigService,
{ provide: MAIL_SERVICE, useClass: MockMailService },
JwtService,
Expand Down
52 changes: 50 additions & 2 deletions apps/api/src/auth/controller/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ import { Public } from '../../decorators/public.decorator'
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'
import { AuthGuard } from '@nestjs/passport'
import { GithubOAuthStrategyFactory } from '../../config/factory/github/github-strategy.factory'
import { GoogleOAuthStrategyFactory } from '../../config/factory/google/google-strategy.factory'

@ApiTags('Auth Controller')
@Controller('auth')
export class AuthController {
constructor(
private authService: AuthService,
private githubOAuthStrategyFactory: GithubOAuthStrategyFactory
private githubOAuthStrategyFactory: GithubOAuthStrategyFactory,
private googleOAuthStrategyFactory: GoogleOAuthStrategyFactory
) {}

@Public()
Expand Down Expand Up @@ -130,7 +132,53 @@ export class AuthController {
const { emails, displayName: name, photos } = req.user
const email = emails[0].value
const profilePictureUrl = photos[0].value
return await this.authService.handleGithubOAuth(
return await this.authService.handleOAuthLogin(
email,
name,
profilePictureUrl
)
}

/* istanbul ignore next */
@Public()
@Get('google')
@ApiOperation({
summary: 'Google OAuth',
description: 'Initiates Google OAuth'
})
async googleOAuthLogin(@Res() res) {
if (!this.googleOAuthStrategyFactory.isOAuthEnabled()) {
throw new HttpException(
'Google Auth is not enabled in this environment. Refer to the https://docs.keyshade.xyz/contributing-to-keyshade/environment-variables if you would like to set it up.',
HttpStatus.BAD_REQUEST
)
}

res.status(302).redirect('/api/auth/google/callback')
}

/* istanbul ignore next */
@Public()
@Get('google/callback')
@UseGuards(AuthGuard('google'))
@ApiOperation({
summary: 'Google OAuth Callback',
description: 'Handles Google OAuth callback'
})
@ApiParam({
name: 'code',
description: 'Code for the Callback',
required: true
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Logged in successfully'
})
async googleOAuthCallback(@Req() req) {
const { emails, displayName: name, photos } = req.user
const email = emails[0].value
const profilePictureUrl = photos[0].value
return await this.authService.handleOAuthLogin(
email,
name,
profilePictureUrl
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/auth/service/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export class AuthService {
}

/* istanbul ignore next */
async handleGithubOAuth(
async handleOAuthLogin(
email: string,
name: string,
profilePictureUrl: string
Expand Down
46 changes: 46 additions & 0 deletions apps/api/src/config/factory/google/google-strategy.factory.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Test, TestingModule } from '@nestjs/testing'
import { ConfigService } from '@nestjs/config'
import { GoogleStrategy } from '../../oauth-strategy/google/google.strategy'
import { GoogleOAuthStrategyFactory } from './google-strategy.factory'

describe('GoogleOAuthStrategyFactory', () => {
let factory: GoogleOAuthStrategyFactory
let configService: ConfigService

beforeEach(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [{ provide: ConfigService, useValue: { get: jest.fn() } }]
}).compile()
configService = moduleRef.get<ConfigService>(ConfigService)
})

it('disable when credentials are not present', () => {
jest.spyOn(configService, 'get').mockReturnValue('')
factory = new GoogleOAuthStrategyFactory(configService)
expect(factory.isOAuthEnabled()).toBe(false)
})

it('return null when OAuth disabled', () => {
const strategy = factory.createOAuthStrategy()
expect(strategy).toBeNull()
})

it('enable OAuth when credentials present', () => {
jest
.spyOn(configService, 'get')
.mockImplementation((key) =>
key === 'GOOGLE_CLIENT_ID' ||
key === 'GOOGLE_CLIENT_SECRET' ||
key === 'GOOGLE_CALLBACK_URL'
? 'test'
: ''
)
factory = new GoogleOAuthStrategyFactory(configService)
expect(factory.isOAuthEnabled()).toBe(true)
})

it('create OAuth strategy when enabled', () => {
const strategy = factory.createOAuthStrategy()
expect(strategy).toBeInstanceOf(GoogleStrategy)
})
})
36 changes: 36 additions & 0 deletions apps/api/src/config/factory/google/google-strategy.factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { OAuthStrategyFactory } from '../oauth-strategy.factory'
import { GoogleStrategy } from '../../oauth-strategy/google/google.strategy'

@Injectable()
export class GoogleOAuthStrategyFactory implements OAuthStrategyFactory {
private readonly clientID: string
private readonly clientSecret: string
private readonly callbackURL: string

constructor(private readonly configService: ConfigService) {
this.clientID = this.configService.get<string>('GOOGLE_CLIENT_ID')
this.clientSecret = this.configService.get<string>('GOOGLE_CLIENT_SECRET')
this.callbackURL = this.configService.get<string>('GOOGLE_CALLBACK_URL')
}

public isOAuthEnabled(): boolean {
return Boolean(this.clientID && this.clientSecret && this.callbackURL)
}

public createOAuthStrategy<GoogleStrategy>(): GoogleStrategy | null {
if (this.isOAuthEnabled()) {
return new GoogleStrategy(
this.clientID,
this.clientSecret,
this.callbackURL
) as GoogleStrategy
} else {
Logger.warn(
'Google Auth is not enabled in this environment. Refer to the https://docs.keyshade.xyz/contributing-to-keyshade/environment-variables if you would like to set it up.'
)
return null
}
}
}
17 changes: 17 additions & 0 deletions apps/api/src/config/oauth-strategy/google/google.strategy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { GoogleStrategy } from './google.strategy'

describe('GoogleStrategy', () => {
let strategy: GoogleStrategy

beforeEach(() => {
strategy = new GoogleStrategy('clientID', 'clientSecret', 'callbackURL')
})

it('should be defined', () => {
expect(strategy).toBeDefined()
})

it('should have a validate method', () => {
expect(strategy.validate).toBeDefined()
})
})
23 changes: 23 additions & 0 deletions apps/api/src/config/oauth-strategy/google/google.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { Strategy, Profile } from 'passport-google-oauth20'

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(clientID: string, clientSecret: string, callbackURL: string) {
super({
clientID,
clientSecret,
callbackURL,
scope: ['profile', 'email']
})
}

async validate(
accessToken: string,
refreshToken: string,
profile: Profile
): Promise<Profile> {
return profile
}
}

0 comments on commit cf387ea

Please sign in to comment.