feat: /refresh route complete along with refresh token rotation

This commit is contained in:
Balu Babu
2023-01-11 19:29:33 +05:30
parent d3a43cb65f
commit 36b32a1813
9 changed files with 137 additions and 9 deletions

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class RTJwtAuthGuard extends AuthGuard('jwt-refresh') {}

View File

@@ -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;
}
}

View File

@@ -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'];
},
);

View File

@@ -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;

View File

@@ -0,0 +1,9 @@
export interface AuthUser {
id: string;
name: string;
email: string;
image: string;
isAdmin: boolean;
refreshToken: string;
createdOn: Date;
}

View File

@@ -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,