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

feat(auth): Add Google OAuth #156

Merged
merged 5 commits into from
Mar 3, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
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 'src/config/factory/google/google-strategy.factory'
import { GoogleStrategy } from 'src/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
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
}
}
}
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
}
}
Loading