Skip to content

Commit

Permalink
feat: Decentralized JWT Token Validation Across Microservices Without…
Browse files Browse the repository at this point in the history
… Overloading User-Service
  • Loading branch information
sanjaysah101 committed Nov 24, 2024
1 parent 83052ca commit addbd6a
Show file tree
Hide file tree
Showing 17 changed files with 151 additions and 73 deletions.
8 changes: 6 additions & 2 deletions src/api/v1/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
validateEmail,
validateResetPasswordSchema,
} from "@/api/v1/user/user.validation";
import { ErrorTypeEnum } from "@/constants";
import { ErrorTypeEnum, Permission } from "@/constants";
import { notificationService } from "@/services";
import { GoogleUser } from "@/types/passport-google";
import { comparePassword, generateAccessToken, generateRefreshToken, validateObjectId } from "@/utils";
Expand Down Expand Up @@ -117,10 +117,14 @@ export class AuthService {

static async generateAccessAndRefreshToken(userId: string) {
validateObjectId(userId);
const userRolePermissions = await UserDAL.getUserRolesAndPermissionsByUserId(userId);

// Generate both tokens concurrently
const [accessToken, refreshToken] = await Promise.all([
generateAccessToken({ userId }),
generateAccessToken({
userId,
permissions: userRolePermissions.allPermissions.map(({ name }) => name) as Permission[],
}),
generateRefreshToken({ id: userId }),
]);

Expand Down
5 changes: 5 additions & 0 deletions src/api/v1/auth/auth.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export const authenticateSchema = AuthSchema.pick({

export const jwtAccessTokenSchema = z.object({
userId: z.string(),
permissions: z.array(z.string()),
tokenVersion: z.number(),
issuedAt: z.number(),
issuer: z.string(),
audience: z.string(),
});

export const jwtRefreshTokenSchema = z.object({
Expand Down
4 changes: 2 additions & 2 deletions src/api/v1/permission/__test__/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import {
getPermissions,
} from "@/utils/test";

import { PermissionCategory, createPermission } from "../permission.validation";
import { CreatePermission, PermissionCategory } from "../permission.validation";

const VALID_PERMISSION: createPermission = {
const VALID_PERMISSION: CreatePermission = {
name: "create-permission",
description: "this is crete permission creating first time",
category: PermissionCategory.SYSTEM,
Expand Down
6 changes: 3 additions & 3 deletions src/api/v1/permission/permission.dal.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { PermissionModel } from "./permission.model";
import { Permission, createPermission } from "./permission.validation";
import { CreatePermission, Permission } from "./permission.validation";

export class PermissionDAL {
static async createPermission(permission: createPermission): Promise<Permission> {
static async createPermission(permission: CreatePermission): Promise<Permission> {
return await PermissionModel.create({
...permission,
updatedBy: permission.createdBy,
});
}

static async createPermissions(permission: createPermission[]): Promise<Permission[]> {
static async createPermissions(permission: CreatePermission[]): Promise<Permission[]> {
return await PermissionModel.insertMany(
permission.map((permission) => ({
...permission,
Expand Down
4 changes: 2 additions & 2 deletions src/api/v1/permission/permission.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Permission, getPermission } from "./permission.validation";
import { GetPermission, Permission } from "./permission.validation";

export const PermissionDto = (permission: Permission) => ({
getPermission: (): getPermission => ({
getPermission: (): GetPermission => ({
id: permission.id,
name: permission.name,
description: permission.description,
Expand Down
6 changes: 3 additions & 3 deletions src/api/v1/permission/permission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { ErrorTypeEnum } from "@/constants";

import { PermissionDAL } from "./permission.dal";
import { PermissionDto } from "./permission.dto";
import { createPermission, createPermissionSchema, getPermission } from "./permission.validation";
import { CreatePermission, GetPermission, createPermissionSchema } from "./permission.validation";

export class PermissionService {
static async createPermission(createPermission: createPermission): Promise<getPermission> {
static async createPermission(createPermission: CreatePermission): Promise<GetPermission> {
const validPermissionData = createPermissionSchema.parse(createPermission);

const isPermissionExist = await PermissionDAL.getPermissionByName(validPermissionData.name);
Expand All @@ -17,7 +17,7 @@ export class PermissionService {
return PermissionDto(createdPermission).getPermission();
}

static async getPermissions(): Promise<getPermission[]> {
static async getPermissions(): Promise<GetPermission[]> {
const permissions = await PermissionDAL.getPermissions();
return permissions.map((permission) => PermissionDto(permission).getPermission());
}
Expand Down
4 changes: 2 additions & 2 deletions src/api/v1/permission/permission.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,5 @@ export const getPermissionSchema = permissionSchema.omit({
});

export type Permission = z.infer<typeof permissionSchema>;
export type createPermission = z.infer<typeof createPermissionSchema>;
export type getPermission = z.infer<typeof getPermissionSchema>;
export type CreatePermission = z.infer<typeof createPermissionSchema>;
export type GetPermission = z.infer<typeof getPermissionSchema>;
4 changes: 2 additions & 2 deletions src/api/v1/rolePermission/__test__/role-permission.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PermissionCategory, createPermission } from "@/api/v1/permission/permission.validation";
import { CreatePermission, PermissionCategory } from "@/api/v1/permission/permission.validation";
import { createRole } from "@/api/v1/role/role.validation";
import { defaultUsers } from "@/constants";
import {
Expand All @@ -20,7 +20,7 @@ const VALID_ROLE: createRole = {
isSystemRole: false,
};

const VALID_PERMISSION: createPermission = {
const VALID_PERMISSION: CreatePermission = {
name: "new-permissions",
description: "this is crete permission creating first time",
createdBy: "65f6dac9156e93e7b6f1b88d",
Expand Down
43 changes: 22 additions & 21 deletions src/api/v1/token/token.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isPast } from "date-fns";

import { ErrorTypeEnum, FIVE_MINUTES_IN_MS } from "@/constants";
import { generateTokenForAction, verifyJWTToken } from "@/utils";
import { generateTokenForAction, logger, verifyJWTToken } from "@/utils";

import { TokenDAL } from "./token.dal";
import { CreateToken, Token, TokenAction } from "./token.validation";
Expand Down Expand Up @@ -29,26 +29,27 @@ export class TokenService {
}

async verifyActionToken(token: string, action: TokenAction): Promise<Token> {
const verifiedToken = await verifyJWTToken<Token>(token, "action");

// Check if the token is valid for the intended action
// e.g. if the token is for changing subscription, then the action must be changeSubscription
if (verifiedToken.action !== action) {
throw new Error(ErrorTypeEnum.enum.INVALID_TOKEN_TYPE);
}

const storedToken = await this.tokenDAL.getToken(token);

if (!storedToken) {
throw new Error(ErrorTypeEnum.enum.INVALID_ACCESS);
try {
const verifiedToken = await verifyJWTToken<Token>(token, "action");

if (verifiedToken.action !== action) {
throw new Error(ErrorTypeEnum.enum.INVALID_TOKEN_TYPE);
}

const storedToken = await this.tokenDAL.getToken(token);
if (!storedToken) {
throw new Error(ErrorTypeEnum.enum.INVALID_ACCESS);
}

if (storedToken.isUsed || isPast(storedToken.expiryTime)) {
throw new Error(ErrorTypeEnum.enum.TOKEN_EXPIRED);
}

await this.tokenDAL.updateToken(storedToken.id, { isUsed: true });
return verifiedToken;
} catch (error) {
logger.error("Action token verification error:", error);
throw error;
}

if (storedToken.isUsed || isPast(storedToken.expiryTime)) {
throw new Error(ErrorTypeEnum.enum.TOKEN_EXPIRED);
}

await this.tokenDAL.updateToken(storedToken.id, { isUsed: true });

return verifiedToken;
}
}
10 changes: 10 additions & 0 deletions src/constants/error-types.constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const errorType = [
"INITIAL_SETUP_FAILED",
"NOT_ENOUGH_PERMISSION",
"TOO_MANY_REQUESTS",
"TOKEN_NOT_ACTIVE",
"INVALID_TOKEN_AUDIENCE",
] as const;

export const ErrorTypeEnum = z.enum(errorType);
Expand Down Expand Up @@ -198,6 +200,14 @@ export const errorMap = {
httpStatusCode: STATUS_CODES.TOO_MANY_REQUESTS,
body: { code: "too_many_requests", message: "Too many requests" },
},
[ErrorTypeEnum.enum.TOKEN_NOT_ACTIVE]: {
httpStatusCode: STATUS_CODES.UNAUTHORIZED,
body: { code: "token_not_active", message: "Token not active" },
},
[ErrorTypeEnum.enum.INVALID_TOKEN_AUDIENCE]: {
httpStatusCode: STATUS_CODES.UNAUTHORIZED,
body: { code: "invalid_token_audience", message: "Invalid token audience" },
},
};

export type ErrorTypeEnum = z.infer<typeof ErrorTypeEnum>;
Expand Down
1 change: 1 addition & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from "./env.constant";
export * from "./error-types.constant";
export * from "./magic-numbers.constant";
export * from "./rbac.constants";
export * from "./service.constant";
export * from "./status-code.constant";
4 changes: 2 additions & 2 deletions src/constants/rbac.constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Types } from "mongoose";

import { PermissionCategory, createPermission } from "@/api/v1/permission/permission.validation";
import { CreatePermission, PermissionCategory } from "@/api/v1/permission/permission.validation";
import { createRole } from "@/api/v1/role/role.validation";

import { envConstants } from "./env.constant";
Expand Down Expand Up @@ -64,7 +64,7 @@ export const ROLES = {
USER: "user",
};

export const defaultPermissions: createPermission[] = [
export const defaultPermissions: CreatePermission[] = [
// User Management Permissions
{
name: PERMISSIONS.CREATE_USERS,
Expand Down
10 changes: 10 additions & 0 deletions src/constants/service.constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export enum ServiceEnum {
USER_SERVICE = "user-service",
NOTIFICATION_SERVICE = "notification-service",
CMS_SERVICE = "cms-service",
CHAT_SERVICE = "chat-service",
}

export const SERVICE_AUDIENCE = Object.values(ServiceEnum);

export const CURRENT_SERVICE = ServiceEnum.USER_SERVICE;
34 changes: 24 additions & 10 deletions src/middlewares/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,37 @@ import { extractTokenFromBearerString, verifyJWTToken } from "@/utils/jwt.util";
const parseUser = async (req: Request, _: Response, next: NextFunction, tokenType: "access" | "refresh") => {
try {
const authHeader = req.headers.authorization;

if (authHeader == null || authHeader === "") throw new Error(ErrorTypeEnum.enum.NO_AUTH_HEADER);
if (authHeader == null || authHeader === "") {
throw new Error(ErrorTypeEnum.enum.NO_AUTH_HEADER);
}

const token = extractTokenFromBearerString(authHeader);
if (!token) {
throw new Error(ErrorTypeEnum.enum.INVALID_ACCESS);
}

let user: Auth;
try {
if (tokenType === "refresh") {
const { id } = await verifyJWTToken<JwtRefreshToken>(token, tokenType);
user = await AuthService.verifyToken(id);
} else {
const res = await verifyJWTToken<JwtAccessToken>(token, tokenType);
const { userId } = res;
user = await AuthService.verifyToken(userId);
}
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error(ErrorTypeEnum.enum.INVALID_TOKEN);
}

// TODO: instead of first verifying jwt token and then calling auth service to get user, call auth service directly and add logic to verify jwt token in auth service
if (tokenType === "refresh") {
const { id }: JwtRefreshToken = await verifyJWTToken<JwtRefreshToken>(token, tokenType);
user = await AuthService.verifyToken(id);
} else {
const { userId }: JwtAccessToken = await verifyJWTToken<JwtAccessToken>(token, tokenType);
user = await AuthService.verifyToken(userId);
if (user === null) {
throw new Error(ErrorTypeEnum.enum.USER_NOT_FOUND);
}

req.body.loggedInUser = { ...user, userId: user.userId.toString() };

next();
} catch (error) {
next(error);
Expand Down
4 changes: 2 additions & 2 deletions src/utils/__test__/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { JwtAccessToken } from "@/api/v1/auth/auth.validation";
import { ErrorTypeEnum } from "@/constants";
import { extractTokenFromBearerString, generateAccessToken, generateRefreshToken } from "@/utils";

Expand All @@ -8,8 +7,9 @@ jest.mock("jsonwebtoken", () => ({
}));

describe("Jwt token", () => {
const mockdata: JwtAccessToken = {
const mockdata = {
userId: "123",
permissions: [],
};

beforeEach(() => {
Expand Down
Loading

0 comments on commit addbd6a

Please sign in to comment.