Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auth Workflow, JWT, Role Checking #6

Merged
merged 10 commits into from
Apr 11, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
"devDependencies": {
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/morgan": "^1.9.9",
"@types/node": "^20.9.3",
"@types/passport-google-oauth20": "^2.0.14",
"@typescript-eslint/eslint-plugin": "^6.4.0",
"eslint": "8.2.0",
"eslint-config-airbnb": "19.0.4",
Expand All @@ -35,8 +37,11 @@
"dotenv": "^16.4.5",
"express": "^4.19.1",
"http-status-codes": "^2.3.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.2.3",
"morgan": "^1.10.0",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"tsx": "^4.5.0",
"typescript": "*",
"zod": "^3.22.4"
Expand Down
14 changes: 14 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,18 @@ export const Config = {
DATABASE_USERNAME: getEnv("DATABASE_USERNAME"),
DATABASE_PASSWORD: getEnv("DATABASE_PASSWORD"),
DATABASE_HOST: getEnv("DATABASE_HOST"),

CLIENT_ID: getEnv("OAUTH_GOOGLE_CLIENT_ID"),
CLIENT_SECRET: getEnv("OAUTH_GOOGLE_CLIENT_SECRET"),

AUTH_CALLBACK_URI_BASE: "http://localhost:3000/auth/callback/",
// AUTH_CALLBACK_URI_BASE: "https://api.reflectionsprojections.org/auth/callback/",

JWT_SIGNING_SECRET: getEnv("JWT_SIGNING_SECRET"),
JWT_EXPIRATION_TIME: "1 day",
};

export const DeviceRedirects: Record<string, string> = {
web: "https://www.google.com/",
dev: "http://127.0.0.1:3000/auth/dev/",
};
4 changes: 2 additions & 2 deletions src/database.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import mongoose, { Schema } from "mongoose";
import { RoleInfo, RoleSchema } from "./services/auth/auth-schema";
import { RoleValidator, RoleSchema } from "./services/auth/auth-schema";
import { EventSchema, EventValidator } from "./services/events/events-schema";
import {
SubscriptionValidator,
Expand Down Expand Up @@ -36,7 +36,7 @@ function initializeModel(

// Example usage
export const Database = {
ROLES: initializeModel("roles", RoleSchema, RoleInfo),
ROLES: initializeModel("roles", RoleSchema, RoleValidator),
EVENTS: initializeModel("events", EventSchema, EventValidator),
SUBSCRIPTION: initializeModel(
"subscription",
Expand Down
67 changes: 67 additions & 0 deletions src/middleware/role-checker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { NextFunction, Request, Response } from "express";
import { JwtPayload, Role } from "../services/auth/auth-models";
import { z } from "zod";
import jsonwebtoken from "jsonwebtoken";
import { Config } from "../config";
import { StatusCodes } from "http-status-codes";

export default function RoleChecker(
requiredRoles: z.infer<typeof Role>[],
weakVerification: boolean = false
) {
return function (req: Request, res: Response, next: NextFunction) {
const jwt = req.headers.authorization;

if (jwt == undefined) {
if (weakVerification) {
next();
}

return res.status(StatusCodes.BAD_REQUEST).json({ error: "NoJWT" });
}

try {
console.log("in");
const payloadData = jsonwebtoken.verify(
jwt,
Config.JWT_SIGNING_SECRET
);
const payload = JwtPayload.parse(payloadData);
res.locals.payload = payload;

const error = new Error("InvalidRoles");
const userRoles = payload.roles;

if (weakVerification) {
next();
}

if (requiredRoles.length == 0) {
next();
}

// Admins (staff) can access any endpoint
if (userRoles.includes(Role.Enum.ADMIN)) {
next();
}

// Corporate role can access corporate only endpoints
if (requiredRoles.includes(Role.Enum.CORPORATE)) {
if (userRoles.includes(Role.Enum.CORPORATE)) {
next();
}
}

// Need to be a user to access user endpoints (app users)
if (requiredRoles.includes(Role.Enum.USER)) {
if (userRoles.includes(Role.Enum.USER)) {
next();
}
}

throw error;
} catch (error) {
next(error);
}
};
}
8 changes: 8 additions & 0 deletions src/services/auth/auth-models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { z } from "zod";

export const Role = z.enum(["USER", "ADMIN", "CORPORATE"]);

export const JwtPayload = z.object({
AydanPirani marked this conversation as resolved.
Show resolved Hide resolved
userId: z.string(),
roles: Role.array(),
});
80 changes: 62 additions & 18 deletions src/services/auth/auth-router.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,73 @@
import { Router } from "express";
import { Database } from "../../database";
import passport from "passport";
import { Config, DeviceRedirects } from "../../config";
import { StatusCodes } from "http-status-codes";
import { Role } from "./auth-schema";
import { createId } from "@paralleldrive/cuid2";
import { Strategy as GoogleStrategy, Profile } from "passport-google-oauth20";
import { createGoogleStrategy, getJwtPayloadFromDatabase } from "./auth-utils";
import jsonwebtoken from "jsonwebtoken";

const authStrategies: Record<string, GoogleStrategy> = {};

const authRouter = Router();

authRouter.get("/", async (req, res) => {
const result = await Database.ROLES.find();
const mappedResult = result.map((item) => item.toObject());
return res.status(StatusCodes.OK).send(mappedResult);
authRouter.get("/login/:DEVICE/", (req, res) => {
const device = req.params.DEVICE;

// Check if this is a valid device (i.e. does a redirectURI exist for it)
if (!(device in DeviceRedirects)) {
return res.status(StatusCodes.BAD_REQUEST).send({ error: "BadDevice" });
}

// Check if we've already created an auth strategy for the device
// If not, create a new one
if (!(device in authStrategies)) {
authStrategies[device] = createGoogleStrategy(device);
}

// Use the pre-created strategy
passport.use(device, authStrategies[device]);

return passport.authenticate(device, {
scope: ["profile", "email"],
})(req, res);
});

authRouter.post("/", async (_, res, next) => {
const user = {
userId: createId(),
roles: [Role.Enum.USER],
};

try {
const result = (await Database.ROLES.create(user)).toObject();
return res.status(StatusCodes.CREATED).send(result);
} catch (err) {
next(err);
authRouter.get(
"/callback/:DEVICE",
(req, res, next) =>
// Check based on the pre-existing strategy name
passport.authenticate(req.params.DEVICE, {
session: false,
})(req, res, next),
async function (req, res, next) {
// Authentication failed - redirect to login
if (req.user == undefined) {
return res.redirect(`/auth/login/${req.params.DEVICE}`);
}
const userData = req.user as Profile;
const userId = `user${userData.id}`;

// Generate the JWT, and redirect to JWT initialization
try {
const jwtPayload = (
await getJwtPayloadFromDatabase(userId)
).toObject();
const token = jsonwebtoken.sign(
jwtPayload,
Config.JWT_SIGNING_SECRET,
{ expiresIn: Config.JWT_EXPIRATION_TIME }
);
const redirectUri =
DeviceRedirects[req.params.DEVICE] + `?token=${token}`;
return res.redirect(redirectUri);
} catch (error) {
next(error);
}
}
);

authRouter.get("/dev/", (req, res) => {
return res.status(StatusCodes.OK).json(req.query);
});

export default authRouter;
45 changes: 29 additions & 16 deletions src/services/auth/auth-schema.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,36 @@
import { Schema } from "mongoose";
import { z } from "zod";
import { Role } from "./auth-models";

export const Role = z.enum(["USER", "ADMIN", "CORPORATE"]);

export const RoleInfo = z.object({
userId: z.coerce.string().cuid2(),
export const RoleValidator = z.object({
userId: z.coerce.string().regex(/user[0-9]*/),
name: z.coerce.string(),
email: z.coerce.string().email(),
roles: z.array(Role),
});

export const RoleSchema = new Schema({
userId: {
type: String,
required: true,
unique: true,
},
roles: {
type: [String],
enum: Role.Values,
default: [],
required: true,
export const RoleSchema = new Schema(
{
userId: {
type: String,
required: true,
unique: true,
},
name: {
type: String,
required: true,
},
email: {
type: String,
required: true,
unique: true,
},
roles: {
type: [String],
enum: Role.Values,
default: [],
required: true,
},
},
});
{ timestamps: { createdAt: "createdAt" } }
);
41 changes: 41 additions & 0 deletions src/services/auth/auth-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Create a function to generate GoogleStrategy instances
import { Strategy as GoogleStrategy } from "passport-google-oauth20";
import { Config } from "../../config";
import { Database } from "../../database";

export function createGoogleStrategy(device: string) {
return new GoogleStrategy(
{
clientID: Config.CLIENT_ID,
clientSecret: Config.CLIENT_SECRET,
callbackURL: Config.AUTH_CALLBACK_URI_BASE + device,
},

// Strategy -> insert user into database if they don't exist
async function (_1, _2, profile, cb) {
const userId = `user${profile.id}`;
const name = profile.displayName;
const email = profile._json.email;

Database.ROLES.findOneAndUpdate(
{ userId: userId },
{ userId, name, email },
{ upsert: true }
)
.then(() => cb(null, profile))
.catch((err) => cb(err, profile));
}
);
}

export async function getJwtPayloadFromDatabase(userId: string) {
const payload = await Database.ROLES.findOne({ userId: userId }).select([
"userId",
"roles",
]);
if (!payload) {
throw new Error("NoUserFound");
}

return payload;
}
9 changes: 5 additions & 4 deletions src/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ export function isDev() {
return Config.ENV == Environment.enum.DEVELOPMENT;
}

export function getEnv(target: string) {
if (process.env[target] === undefined) {
throw new Error(`env value ${target} not found, exiting...`);
export function getEnv(key: string): string {
const val = process.env[key];
if (val === undefined) {
throw new Error(`env value ${key} not found, exiting...`);
}
return process.env[target];
return val;
}
Loading