feat: /refresh route complete along with refresh token rotation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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.Right<AuthTokens> | E.Left<AuthErrorHandler>> {
|
||||
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.Left<AuthErrorHandler> | E.Right<AuthTokens>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class RTJwtAuthGuard extends AuthGuard('jwt-refresh') {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
9
packages/hoppscotch-backend/src/types/AuthUser.ts
Normal file
9
packages/hoppscotch-backend/src/types/AuthUser.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image: string;
|
||||
isAdmin: boolean;
|
||||
refreshToken: string;
|
||||
createdOn: Date;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user