feat: /refresh route complete along with refresh token rotation
This commit is contained in:
@@ -5,7 +5,9 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Post,
|
Post,
|
||||||
|
Request,
|
||||||
Res,
|
Res,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { signInMagicDto } from './dto/signin-magic.dto';
|
import { signInMagicDto } from './dto/signin-magic.dto';
|
||||||
@@ -13,6 +15,10 @@ import { verifyMagicDto } from './dto/verify-magic.dto';
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import * as E from 'fp-ts/Either';
|
import * as E from 'fp-ts/Either';
|
||||||
import { authCookieHandler, throwHTTPErr } from 'src/utils';
|
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')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -27,8 +33,23 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('verify')
|
@Post('verify')
|
||||||
async verify(@Body() data: verifyMagicDto, @Res() res: Response) {
|
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);
|
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||||
authCookieHandler(res, authTokens.right, false);
|
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 { PassportModule } from '@nestjs/passport';
|
||||||
import { JwtModule } from '@nestjs/jwt/dist';
|
import { JwtModule } from '@nestjs/jwt/dist';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
import { RTJwtStrategy } from './strategies/rt-jwt.strategy';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -18,7 +19,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
|||||||
secret: process.env.JWT_SECRET,
|
secret: process.env.JWT_SECRET,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [AuthService, JwtStrategy],
|
providers: [AuthService, JwtStrategy, RTJwtStrategy],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
PASSWORDLESS_DATA_NOT_FOUND,
|
PASSWORDLESS_DATA_NOT_FOUND,
|
||||||
MAGIC_LINK_EXPIRED,
|
MAGIC_LINK_EXPIRED,
|
||||||
USER_NOT_FOUND,
|
USER_NOT_FOUND,
|
||||||
|
INVALID_REFRESH_TOKEN,
|
||||||
} from 'src/errors';
|
} from 'src/errors';
|
||||||
import { validateEmail } from 'src/utils';
|
import { validateEmail } from 'src/utils';
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +31,8 @@ import {
|
|||||||
import { ProviderAccount } from 'src/types/ProviderAccount';
|
import { ProviderAccount } from 'src/types/ProviderAccount';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { AuthErrorHandler } from 'src/types/AuthErrorHandler';
|
import { AuthErrorHandler } from 'src/types/AuthErrorHandler';
|
||||||
|
import { AuthUser } from 'src/types/AuthUser';
|
||||||
|
import { isLeafType } from 'graphql';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -207,7 +210,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async verify(
|
async verifyPasswordlessTokens(
|
||||||
data: verifyMagicDto,
|
data: verifyMagicDto,
|
||||||
): Promise<E.Right<AuthTokens> | E.Left<AuthErrorHandler>> {
|
): Promise<E.Right<AuthTokens> | E.Left<AuthErrorHandler>> {
|
||||||
const passwordlessTokens = await this.validatePasswordlessTokens(data);
|
const passwordlessTokens = await this.validatePasswordlessTokens(data);
|
||||||
@@ -218,7 +221,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const currentTime = DateTime.now().toISOTime();
|
const currentTime = DateTime.now().toISOTime();
|
||||||
|
//TODO: new to check this datetime checking logic
|
||||||
if (currentTime > passwordlessTokens.value.expiresOn.toISOString())
|
if (currentTime > passwordlessTokens.value.expiresOn.toISOString())
|
||||||
return E.left({
|
return E.left({
|
||||||
message: MAGIC_LINK_EXPIRED,
|
message: MAGIC_LINK_EXPIRED,
|
||||||
@@ -244,4 +247,31 @@ export class AuthService {
|
|||||||
|
|
||||||
return E.right(tokens.right);
|
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)
|
* (AuthService)
|
||||||
*/
|
*/
|
||||||
export const INVALID_ACCESS_TOKEN = 'auth/invalid_access_token' as const;
|
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 { PrismaService } from 'src/prisma/prisma.service';
|
||||||
import * as O from 'fp-ts/Option';
|
import * as O from 'fp-ts/Option';
|
||||||
import { User } from './user.model';
|
import { User } from './user.model';
|
||||||
|
import { ProviderAccount } from '../types/ProviderAccount';
|
||||||
|
import { AuthUser } from 'src/types/AuthUser';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
@@ -9,7 +11,7 @@ export class UserService {
|
|||||||
|
|
||||||
async findUserByEmail(email: string) {
|
async findUserByEmail(email: string) {
|
||||||
try {
|
try {
|
||||||
const user: User = await this.prisma.user.findUniqueOrThrow({
|
const user: AuthUser = await this.prisma.user.findUniqueOrThrow({
|
||||||
where: {
|
where: {
|
||||||
email: email,
|
email: email,
|
||||||
},
|
},
|
||||||
@@ -22,7 +24,7 @@ export class UserService {
|
|||||||
|
|
||||||
async findUserById(userUid: string) {
|
async findUserById(userUid: string) {
|
||||||
try {
|
try {
|
||||||
const user: User = await this.prisma.user.findUniqueOrThrow({
|
const user: AuthUser = await this.prisma.user.findUniqueOrThrow({
|
||||||
where: {
|
where: {
|
||||||
id: userUid,
|
id: userUid,
|
||||||
},
|
},
|
||||||
@@ -34,7 +36,7 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createUserMagic(email: string) {
|
async createUserMagic(email: string) {
|
||||||
const createdUser: User = await this.prisma.user.create({
|
const createdUser: AuthUser = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: email,
|
email: email,
|
||||||
accounts: {
|
accounts: {
|
||||||
@@ -50,7 +52,7 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createUserSSO(accessToken, refreshToken, profile) {
|
async createUserSSO(accessToken, refreshToken, profile) {
|
||||||
const createdUser = await this.prisma.user.create({
|
const createdUser: AuthUser = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
name: profile.displayName,
|
name: profile.displayName,
|
||||||
email: profile.emails[0].value,
|
email: profile.emails[0].value,
|
||||||
@@ -70,7 +72,7 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createProviderAccount(user, accessToken, refreshToken, profile) {
|
async createProviderAccount(user, accessToken, refreshToken, profile) {
|
||||||
const createdProvider = await this.prisma.account.create({
|
const createdProvider: ProviderAccount = await this.prisma.account.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
provider: profile.provider,
|
provider: profile.provider,
|
||||||
|
|||||||
Reference in New Issue
Block a user