Skip to content

Commit

Permalink
feat(api): Add OAuth redirection and polished authentication (#212)
Browse files Browse the repository at this point in the history
  • Loading branch information
rajdip-b authored May 12, 2024
1 parent 8762354 commit d2968bc
Show file tree
Hide file tree
Showing 11 changed files with 169 additions and 29 deletions.
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ FROM_EMAIL="your-name <[email protected]>"

JWT_SECRET=secret

WEB_FRONTEND_URL=https://keyshade.xyz
WORKSPACE_FRONTEND_URL=https://app.keyshade.xyz
WEB_FRONTEND_URL=http://localhost:3000
PLATFORM_FRONTEND_URL=http://localhost:3100
PLATFORM_OAUTH_SUCCESS_REDIRECT_PATH=/oauth/signin
PLATFORM_OAUTH_FAILURE_REDIRECT_PATH=/oauth/failure

DOMAIN=localhost
91 changes: 75 additions & 16 deletions apps/api/src/auth/controller/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import {
Get,
HttpException,
HttpStatus,
Logger,
Param,
Post,
Query,
Req,
Res,
UnprocessableEntityException,
UseGuards
} from '@nestjs/common'
import { AuthService } from '../service/auth.service'
Expand All @@ -24,10 +26,18 @@ import { GithubOAuthStrategyFactory } from '../../config/factory/github/github-s
import { GoogleOAuthStrategyFactory } from '../../config/factory/google/google-strategy.factory'
import { GitlabOAuthStrategyFactory } from '../../config/factory/gitlab/gitlab-strategy.factory'
import { Response } from 'express'
import { AuthProvider } from '@prisma/client'
import setCookie from '../../common/set-cookie'
import {
sendOAuthFailureRedirect,
sendOAuthSuccessRedirect
} from '../../common/redirect'

@ApiTags('Auth Controller')
@Controller('auth')
export class AuthController {
private readonly logger = new Logger(AuthController.name)

constructor(
private authService: AuthService,
private githubOAuthStrategyFactory: GithubOAuthStrategyFactory,
Expand Down Expand Up @@ -97,12 +107,7 @@ export class AuthController {
@Query('otp') otp: string,
@Res({ passthrough: true }) response: Response
) {
const { token, ...user } = await this.authService.validateOtp(email, otp)
response.cookie('token', `Bearer ${token}`, {
domain: process.env.DOMAIN ?? 'localhost',
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) // 7 days,
})
return user
return setCookie(response, await this.authService.validateOtp(email, otp))
}

/* istanbul ignore next */
Expand Down Expand Up @@ -144,12 +149,20 @@ export class AuthController {
})
async githubOAuthCallback(@Req() req: any) {
const { emails, displayName: name, photos } = req.user

if (!emails.length) {
throw new UnprocessableEntityException(
'Email information is missing from the OAuth provider data.'
)
}
const email = emails[0].value
const profilePictureUrl = photos[0].value
return await this.authService.handleOAuthLogin(
const profilePictureUrl = photos[0]?.value

return this.authService.handleOAuthLogin(
email,
name,
profilePictureUrl
profilePictureUrl,
AuthProvider.GITHUB
)
}

Expand Down Expand Up @@ -190,13 +203,22 @@ export class AuthController {
status: HttpStatus.OK,
description: 'Logged in successfully'
})
async gitlabOAuthCallback(@Req() req: any) {
async gitlabOAuthCallback(@Req() req: any, @Res() res: Response) {
const { emails, displayName: name, avatarUrl: profilePictureUrl } = req.user

if (!emails.length) {
throw new UnprocessableEntityException(
'Email information is missing from the OAuth provider data.'
)
}
const email = emails[0].value
return await this.authService.handleOAuthLogin(

this.handleOAuthProcess(
email,
name,
profilePictureUrl
profilePictureUrl,
AuthProvider.GITLAB,
res
)
}

Expand Down Expand Up @@ -235,14 +257,51 @@ export class AuthController {
status: HttpStatus.OK,
description: 'Logged in successfully'
})
async googleOAuthCallback(@Req() req: any) {
async googleOAuthCallback(@Req() req: any, @Res() res: Response) {
const { emails, displayName: name, photos } = req.user

if (!emails.length) {
throw new UnprocessableEntityException(
'Email information is missing from the OAuth provider data.'
)
}
const email = emails[0].value
const profilePictureUrl = photos[0].value
return await this.authService.handleOAuthLogin(
const profilePictureUrl = photos[0]?.value

this.handleOAuthProcess(
email,
name,
profilePictureUrl
profilePictureUrl,
AuthProvider.GOOGLE,
res
)
}

/* istanbul ignore next */
private async handleOAuthProcess(
email: string,
name: string,
profilePictureUrl: string,
oauthProvider: AuthProvider,
response: Response
) {
try {
const data = await this.authService.handleOAuthLogin(
email,
name,
profilePictureUrl,
oauthProvider
)
const user = setCookie(response, data)
sendOAuthSuccessRedirect(response, user)
} catch (error) {
this.logger.warn(
'User attempted to log in with a different OAuth provider'
)
sendOAuthFailureRedirect(
response,
'User attempted to log in with a different OAuth provider'
)
}
}
}
23 changes: 19 additions & 4 deletions apps/api/src/auth/service/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '../../mail/services/interface.service'
import { PrismaService } from '../../prisma/prisma.service'
import createUser from '../../common/create-user'
import { AuthProvider } from '@prisma/client'

@Injectable()
export class AuthService {
Expand All @@ -37,7 +38,7 @@ export class AuthService {
throw new BadRequestException('Please enter a valid email address')
}

const user = await this.createUserIfNotExists(email)
const user = await this.createUserIfNotExists(email, AuthProvider.EMAIL_OTP)

const otp = await this.prisma.otp.upsert({
where: {
Expand Down Expand Up @@ -114,11 +115,13 @@ export class AuthService {
async handleOAuthLogin(
email: string,
name: string,
profilePictureUrl: string
) {
profilePictureUrl: string,
oauthProvider: AuthProvider
): Promise<UserAuthenticatedResponse> {
// We need to create the user if it doesn't exist yet
const user = await this.createUserIfNotExists(
email,
oauthProvider,
name,
profilePictureUrl
)
Expand Down Expand Up @@ -151,21 +154,33 @@ export class AuthService {

private async createUserIfNotExists(
email: string,
authProvider: AuthProvider,
name?: string,
profilePictureUrl?: string
) {
let user = await this.findUserByEmail(email)

// We need to create the user if it doesn't exist yet
if (!user) {
user = await createUser(
{
email,
name,
profilePictureUrl
profilePictureUrl,
authProvider
},
this.prisma
)
}

// If the user has used OAuth to log in, we need to check if the OAuth provider
// used in the current login is different from the one stored in the database
if (user.authProvider !== authProvider) {
throw new UnauthorizedException(
'The user has signed up with a different authentication provider.'
)
}

return user
}

Expand Down
7 changes: 4 additions & 3 deletions apps/api/src/common/create-user.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { User, Workspace } from '@prisma/client'
import { AuthProvider, User, Workspace } from '@prisma/client'
import { PrismaService } from '../prisma/prisma.service'
import { CreateUserDto } from '../user/dto/create.user/create.user'
import createWorkspace from './create-workspace'
import { Logger } from '@nestjs/common'

const createUser = async (
dto: Partial<CreateUserDto>,
dto: Partial<CreateUserDto> & { authProvider: AuthProvider },
prisma: PrismaService
): Promise<
User & {
Expand All @@ -22,7 +22,8 @@ const createUser = async (
profilePictureUrl: dto.profilePictureUrl,
isActive: dto.isActive ?? true,
isAdmin: dto.isAdmin ?? false,
isOnboardingFinished: dto.isOnboardingFinished ?? false
isOnboardingFinished: dto.isOnboardingFinished ?? false,
authProvider: dto.authProvider
}
})

Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/common/env/env.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ const generalSchema = z.object({
JWT_SECRET: z.string(),

WEB_FRONTEND_URL: z.string().url(),
WORKSPACE_FRONTEND_URL: z.string().url()
PLATFORM_FRONTEND_URL: z.string().url(),
PLATFORM_OAUTH_SUCCESS_REDIRECT_PATH: z.string(),
PLATFORM_OAUTH_FAILURE_REDIRECT_PATH: z.string()
})

export type EnvSchemaType = z.infer<typeof generalSchema>
Expand Down
28 changes: 28 additions & 0 deletions apps/api/src/common/redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { User } from '@prisma/client'
import { Response } from 'express'

const platformFrontendUrl = process.env.PLATFORM_FRONTEND_URL
const platformOAuthSuccessRedirectPath =
process.env.PLATFORM_OAUTH_SUCCESS_REDIRECT_PATH
const platformOAuthFailureRedirectPath =
process.env.PLATFORM_OAUTH_FAILURE_REDIRECT_PATH
const platformOAuthSuccessRedirectUrl = `${platformFrontendUrl}${platformOAuthSuccessRedirectPath}`
const platformOAuthFailureRedirectUrl = `${platformFrontendUrl}${platformOAuthFailureRedirectPath}`

/* istanbul ignore next */
export function sendOAuthFailureRedirect(response: Response, reason: string) {
response
.status(302)
.redirect(`${platformOAuthSuccessRedirectUrl}?reason=${reason}`)
}

/* istanbul ignore next */
export function sendOAuthSuccessRedirect(response: Response, user: User) {
response
.status(302)
.redirect(
`${platformOAuthFailureRedirectUrl}?data=${encodeURIComponent(
JSON.stringify(user)
)}`
)
}
16 changes: 16 additions & 0 deletions apps/api/src/common/set-cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { User } from '@prisma/client'
import { Response } from 'express'
import { UserAuthenticatedResponse } from '../auth/auth.types'

/* istanbul ignore next */
export default function setCookie(
response: Response,
data: UserAuthenticatedResponse
): User {
const { token, ...user } = data
response.cookie('token', `Bearer ${token}`, {
domain: process.env.DOMAIN ?? 'localhost',
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) // 7 days,
})
return user
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "AuthProvider" AS ENUM ('GOOGLE', 'GITHUB', 'GITLAB', 'EMAIL_OTP');

-- AlterTable
ALTER TABLE "User" ADD COLUMN "authProvider" "AuthProvider";
8 changes: 8 additions & 0 deletions apps/api/src/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ enum IntegrationType {
GITLAB
}

enum AuthProvider {
GOOGLE
GITHUB
GITLAB
EMAIL_OTP
}

model Event {
id String @id @default(cuid())
source EventSource
Expand Down Expand Up @@ -202,6 +209,7 @@ model User {
isActive Boolean @default(true)
isOnboardingFinished Boolean @default(false)
isAdmin Boolean @default(false)
authProvider AuthProvider?
subscription Subscription?
workspaceMembers WorkspaceMember[]
Expand Down
7 changes: 5 additions & 2 deletions apps/api/src/user/service/user.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ConflictException, Inject, Injectable, Logger } from '@nestjs/common'
import { UpdateUserDto } from '../dto/update.user/update.user'
import { User } from '@prisma/client'
import { AuthProvider, User } from '@prisma/client'
import { PrismaService } from '../../prisma/prisma.service'
import { CreateUserDto } from '../dto/create.user/create.user'
import {
Expand Down Expand Up @@ -142,7 +142,10 @@ export class UserService {
}

// Create the user's default workspace
const newUser = await createUser(user, this.prisma)
const newUser = await createUser(
{ authProvider: AuthProvider.EMAIL_OTP, ...user },
this.prisma
)
this.log.log(`Created user with email ${user.email}`)

await this.mailService.accountLoginEmail(newUser.email)
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/user/user.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Test } from '@nestjs/testing'
import { UserModule } from './user.module'
import { PrismaService } from '../prisma/prisma.service'
import { AppModule } from '../app/app.module'
import { User } from '@prisma/client'
import { AuthProvider, User } from '@prisma/client'
import { MAIL_SERVICE } from '../mail/services/interface.service'
import { MockMailService } from '../mail/services/mock.service'
import { UserService } from './service/user.service'
Expand Down Expand Up @@ -305,6 +305,7 @@ describe('User Controller Tests', () => {
...payload,
id: expect.any(String),
profilePictureUrl: null,
authProvider: AuthProvider.EMAIL_OTP,
defaultWorkspace: expect.any(Object)
})
})
Expand Down

0 comments on commit d2968bc

Please sign in to comment.