diff --git a/packages/hoppscotch-backend/src/auth/auth.controller.ts b/packages/hoppscotch-backend/src/auth/auth.controller.ts index 3a5638369..d4ed1d6cb 100644 --- a/packages/hoppscotch-backend/src/auth/auth.controller.ts +++ b/packages/hoppscotch-backend/src/auth/auth.controller.ts @@ -5,7 +5,9 @@ import { HttpException, HttpStatus, Post, + Request, Res, + UseGuards, } from '@nestjs/common'; import { AuthService } from './auth.service'; import { signInMagicDto } from './dto/signin-magic.dto'; @@ -13,6 +15,10 @@ import { verifyMagicDto } from './dto/verify-magic.dto'; import { Response } from 'express'; import * as E from 'fp-ts/Either'; import { authCookieHandler, throwHTTPErr } from 'src/utils'; +import { RTJwtAuthGuard } from './guards/rt-jwt-auth.guard'; +import { GqlUser } from 'src/decorators/gql-user.decorator'; +import { AuthUser } from 'src/types/AuthUser'; +import { RTCookie } from 'src/decorators/rt-cookie.decorator'; @Controller('auth') export class AuthController { @@ -27,8 +33,23 @@ export class AuthController { @Post('verify') async verify(@Body() data: verifyMagicDto, @Res() res: Response) { - const authTokens = await this.authService.verify(data); + const authTokens = await this.authService.verifyPasswordlessTokens(data); if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); authCookieHandler(res, authTokens.right, false); } + + @Get('refresh') + @UseGuards(RTJwtAuthGuard) + async refresh( + @GqlUser() user: AuthUser, + @RTCookie() refresh_token: string, + @Res() res, + ) { + const newTokenPair = await this.authService.refreshAuthTokens( + refresh_token, + user, + ); + if (E.isLeft(newTokenPair)) throwHTTPErr(newTokenPair.left); + authCookieHandler(res, newTokenPair.right, false); + } } diff --git a/packages/hoppscotch-backend/src/auth/auth.module.ts b/packages/hoppscotch-backend/src/auth/auth.module.ts index 18dc5f3a5..8fa6bb150 100644 --- a/packages/hoppscotch-backend/src/auth/auth.module.ts +++ b/packages/hoppscotch-backend/src/auth/auth.module.ts @@ -7,6 +7,7 @@ import { PrismaModule } from 'src/prisma/prisma.module'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt/dist'; import { JwtStrategy } from './strategies/jwt.strategy'; +import { RTJwtStrategy } from './strategies/rt-jwt.strategy'; @Module({ imports: [ @@ -18,7 +19,7 @@ import { JwtStrategy } from './strategies/jwt.strategy'; secret: process.env.JWT_SECRET, }), ], - providers: [AuthService, JwtStrategy], + providers: [AuthService, JwtStrategy, RTJwtStrategy], controllers: [AuthController], }) export class AuthModule {} diff --git a/packages/hoppscotch-backend/src/auth/auth.service.ts b/packages/hoppscotch-backend/src/auth/auth.service.ts index 52d83bfcd..954bb88ce 100644 --- a/packages/hoppscotch-backend/src/auth/auth.service.ts +++ b/packages/hoppscotch-backend/src/auth/auth.service.ts @@ -20,6 +20,7 @@ import { PASSWORDLESS_DATA_NOT_FOUND, MAGIC_LINK_EXPIRED, USER_NOT_FOUND, + INVALID_REFRESH_TOKEN, } from 'src/errors'; import { validateEmail } from 'src/utils'; import { @@ -30,6 +31,8 @@ import { import { ProviderAccount } from 'src/types/ProviderAccount'; import { JwtService } from '@nestjs/jwt'; import { AuthErrorHandler } from 'src/types/AuthErrorHandler'; +import { AuthUser } from 'src/types/AuthUser'; +import { isLeafType } from 'graphql'; @Injectable() export class AuthService { @@ -207,7 +210,7 @@ export class AuthService { }); } - async verify( + async verifyPasswordlessTokens( data: verifyMagicDto, ): Promise | E.Left> { const passwordlessTokens = await this.validatePasswordlessTokens(data); @@ -218,7 +221,7 @@ export class AuthService { }); const currentTime = DateTime.now().toISOTime(); - + //TODO: new to check this datetime checking logic if (currentTime > passwordlessTokens.value.expiresOn.toISOString()) return E.left({ message: MAGIC_LINK_EXPIRED, @@ -244,4 +247,31 @@ export class AuthService { return E.right(tokens.right); } + + async refreshAuthTokens( + refresh_token: string, + user: AuthUser, + ): Promise | E.Right> { + if (!user) + return E.left({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + + const isMatched = await argon2.verify(user.refreshToken, refresh_token); + if (!isMatched) + return E.left({ + message: INVALID_REFRESH_TOKEN, + statusCode: HttpStatus.NOT_FOUND, + }); + + const generatedAuthTokens = await this.generateAuthTokens(user.id); + if (E.isLeft(generatedAuthTokens)) + return E.left({ + message: generatedAuthTokens.left.message, + statusCode: generatedAuthTokens.left.statusCode, + }); + + return E.right(generatedAuthTokens.right); + } } diff --git a/packages/hoppscotch-backend/src/auth/guards/rt-jwt-auth.guard.ts b/packages/hoppscotch-backend/src/auth/guards/rt-jwt-auth.guard.ts new file mode 100644 index 000000000..5b4ec6816 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/guards/rt-jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class RTJwtAuthGuard extends AuthGuard('jwt-refresh') {} diff --git a/packages/hoppscotch-backend/src/auth/strategies/rt-jwt.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/rt-jwt.strategy.ts new file mode 100644 index 000000000..669fbb170 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/strategies/rt-jwt.strategy.ts @@ -0,0 +1,45 @@ +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { + Injectable, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; +import { UserService } from 'src/user/user.service'; +import { Request } from 'express'; +import { RefreshTokenPayload } from 'src/types/AuthTokens'; +import { + COOKIES_NOT_FOUND, + INVALID_REFRESH_TOKEN, + USER_NOT_FOUND, +} from 'src/errors'; +import * as O from 'fp-ts/Option'; + +@Injectable() +export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { + constructor(private usersService: UserService) { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([ + (request: Request) => { + const RTCookie = request.cookies['refresh_token']; + if (!RTCookie) { + throw new ForbiddenException(COOKIES_NOT_FOUND); + } + return RTCookie; + }, + ]), + secretOrKey: process.env.JWT_SECRET, + }); + } + + async validate(payload: RefreshTokenPayload) { + if (!payload) throw new ForbiddenException(INVALID_REFRESH_TOKEN); + + const user = await this.usersService.findUserById(payload.sub); + if (O.isNone(user)) { + throw new UnauthorizedException(USER_NOT_FOUND); + } + + return user.value; + } +} diff --git a/packages/hoppscotch-backend/src/decorators/rt-cookie.decorator.ts b/packages/hoppscotch-backend/src/decorators/rt-cookie.decorator.ts new file mode 100644 index 000000000..88a9f7414 --- /dev/null +++ b/packages/hoppscotch-backend/src/decorators/rt-cookie.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; + +export const RTCookie = createParamDecorator( + (data: unknown, context: ExecutionContext) => { + const ctx = GqlExecutionContext.create(context); + return ctx.getContext().req.cookies['refresh_token']; + }, +); diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index ce44db307..1e79f9465 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -246,3 +246,9 @@ export const COOKIES_NOT_FOUND = 'auth/cookies_not_found' as const; * (AuthService) */ export const INVALID_ACCESS_TOKEN = 'auth/invalid_access_token' as const; + +/** + * Refresh Token is malformed or invalid + * (AuthService) + */ +export const INVALID_REFRESH_TOKEN = 'auth/invalid_refresh_token' as const; diff --git a/packages/hoppscotch-backend/src/types/AuthUser.ts b/packages/hoppscotch-backend/src/types/AuthUser.ts new file mode 100644 index 000000000..4626d8bdf --- /dev/null +++ b/packages/hoppscotch-backend/src/types/AuthUser.ts @@ -0,0 +1,9 @@ +export interface AuthUser { + id: string; + name: string; + email: string; + image: string; + isAdmin: boolean; + refreshToken: string; + createdOn: Date; +} diff --git a/packages/hoppscotch-backend/src/user/user.service.ts b/packages/hoppscotch-backend/src/user/user.service.ts index ad24d94fe..d81376510 100644 --- a/packages/hoppscotch-backend/src/user/user.service.ts +++ b/packages/hoppscotch-backend/src/user/user.service.ts @@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import * as O from 'fp-ts/Option'; import { User } from './user.model'; +import { ProviderAccount } from '../types/ProviderAccount'; +import { AuthUser } from 'src/types/AuthUser'; @Injectable() export class UserService { @@ -9,7 +11,7 @@ export class UserService { async findUserByEmail(email: string) { try { - const user: User = await this.prisma.user.findUniqueOrThrow({ + const user: AuthUser = await this.prisma.user.findUniqueOrThrow({ where: { email: email, }, @@ -22,7 +24,7 @@ export class UserService { async findUserById(userUid: string) { try { - const user: User = await this.prisma.user.findUniqueOrThrow({ + const user: AuthUser = await this.prisma.user.findUniqueOrThrow({ where: { id: userUid, }, @@ -34,7 +36,7 @@ export class UserService { } async createUserMagic(email: string) { - const createdUser: User = await this.prisma.user.create({ + const createdUser: AuthUser = await this.prisma.user.create({ data: { email: email, accounts: { @@ -50,7 +52,7 @@ export class UserService { } async createUserSSO(accessToken, refreshToken, profile) { - const createdUser = await this.prisma.user.create({ + const createdUser: AuthUser = await this.prisma.user.create({ data: { name: profile.displayName, email: profile.emails[0].value, @@ -70,7 +72,7 @@ export class UserService { } async createProviderAccount(user, accessToken, refreshToken, profile) { - const createdProvider = await this.prisma.account.create({ + const createdProvider: ProviderAccount = await this.prisma.account.create({ data: { userId: user.id, provider: profile.provider,