RiserBob logoRiserBobv1.0.0
HomeDocsCLIRelease notes

Getting started

  • Introduction
  • Installation
  • Quick start
  • Project structure

Core concepts

  • Routing
  • Request
  • Responses
  • Error handling

Middleware

  • Middleware
  • Authentication
  • Logger and CORS

Data

  • Validation

Structure and tooling

  • Modules
  • CLI

Examples

  • Build a project
  • Mongoose
  • Drizzle ORM
  • Sequelize
  • JWT and cookies
  • File uploads

Packages

  • Cookies

Resources

  • Troubleshooting
  • Release notes
  • License
  • Security
  • Contributing
Examples

JWT and cookies

Register, log in, refresh and log out with JWT cookies, and restrict routes by role.

This page adds a full login flow to the tasks API: registration, login, a protected /me route, token refresh, logout and an admin-only middleware. The access token and the refresh token live in httpOnly cookies, so JavaScript on the page can never read them.

The examples use Mongoose for the users. With another library, replace the model calls and keep the rest.

Install

Terminal
bun add jsonwebtoken bcryptjs mongoosebun add -d @types/jsonwebtoken

Set JWT_SECRET and JWT_REFRESH_SECRET in .env, two different long random strings. The cookie helper comes from the `@riser-bob/cookie` package.

Terminal
bun add @riser-bob/cookie

The token manager

One class signs and verifies both tokens. A verification failure returns null instead of throwing, which is exactly what AuthMiddleware expects. A missing secret throws a plain Error, so the client sees a generic 500 and the details stay in your logs.

src/config/token/token.ts
import jwt from "jsonwebtoken"; export interface TokenPayload {  _id: string;  role: string;} export class TokenManager {  private secret(name: "JWT_SECRET" | "JWT_REFRESH_SECRET"): string {    const value = process.env[name];     if (!value) {      throw new Error(name + " is not defined");    }     return value;  }   generateAccessToken(payload: TokenPayload): string {    return jwt.sign(payload, this.secret("JWT_SECRET"), { expiresIn: "15m" });  }   generateRefreshToken(payload: TokenPayload): string {    return jwt.sign(payload, this.secret("JWT_REFRESH_SECRET"), {      expiresIn: "30d",    });  }   verifyAccessToken(token: string): TokenPayload | null {    return this.verify(token, this.secret("JWT_SECRET"));  }   verifyRefreshToken(token: string): TokenPayload | null {    return this.verify(token, this.secret("JWT_REFRESH_SECRET"));  }   private verify(token: string, secret: string): TokenPayload | null {    try {      const payload = jwt.verify(token, secret);       if (        typeof payload === "string" ||        typeof payload["_id"] !== "string" ||        typeof payload["role"] !== "string"      ) {        return null;      }       return { _id: payload["_id"], role: payload["role"] };    } catch {      return null;    }  }}

The user schema

src/modules/users/schemas/user.schema.ts
import mongoose, { Schema } from "mongoose";import { uuidPlugin } from "../../../utils/plugins/uuid.plugin"; export enum UserRole {  ADMIN = "admin",  USER = "user",} export interface IUser {  name: string;  email: string;  password: string;  role: UserRole;  isActive: boolean;  refreshToken: string | null;} export class UserSchema {  public static readonly schema = new Schema<IUser>(    {      name: { type: String, required: true, minLength: 3, maxLength: 50 },      email: {        type: String,        required: true,        unique: true,        trim: true,        lowercase: true,      },      password: { type: String, required: true, minLength: 8 },      role: {        type: String,        required: true,        enum: Object.values(UserRole),        default: UserRole.USER,      },      isActive: { type: Boolean, required: true, default: true },      refreshToken: { type: String, default: null },    },    { timestamps: true },  );   static {    this.schema.plugin(uuidPlugin);  }   public static readonly model = mongoose.model<IUser>(    "User",    UserSchema.schema,  );}

The uuidPlugin is the one from the Mongoose page.

The rules

src/modules/auth/rules/register.rule.ts
import type { ValidationSchema } from "riser-bob"; export const RegisterRule = {  name: ["required", "string", "minLength:3", "maxLength:50"],  email: ["required", "email", "maxLength:255"],  password: ["required", "string", "minLength:8", "maxLength:40"],} satisfies ValidationSchema;
src/modules/auth/rules/login.rule.ts
import type { ValidationSchema } from "riser-bob"; export const LoginRule = {  email: ["required", "email"],  password: ["required", "string"],} satisfies ValidationSchema;

The controller

A few choices in this controller are deliberate:

  • Cookies are built with CookieManager and must be applied to the response with applyTo, or the browser never receives them.
  • secure is on in production and off on http://localhost, where browsers may refuse Secure cookies.
  • Login answers the same message for an unknown email and a wrong password, so an attacker cannot learn which emails exist.
  • The refresh token is stored as a SHA-256 hash. A password hash such as bcrypt only reads the first 72 bytes, and every JWT of the same user starts with nearly the same 72 bytes, so it cannot tell two refresh tokens apart.
src/modules/auth/auth.controller.ts
import { createHash } from "node:crypto";import { CookieManager } from "@riser-bob/cookie";import bcrypt from "bcryptjs";import type { HydratedDocument, Model } from "mongoose";import { Validator } from "riser-bob";import type { SpongHandler, SpongRequest } from "riser-bob";import type { TokenManager } from "../../config/token/token";import { UserRole, type IUser } from "../users/schemas/user.schema";import { LoginRule } from "./rules/login.rule";import { RegisterRule } from "./rules/register.rule"; const ACCESS_MAX_AGE = 15 * 60;const REFRESH_MAX_AGE = 30 * 24 * 60 * 60; const sha256 = (value: string) =>  createHash("sha256").update(value).digest("hex"); export class AuthController {  constructor(    private readonly tokens: TokenManager,    private readonly users: Model<IUser>,  ) {}   private cookiesFor(spongReq: SpongRequest) {    return CookieManager.fromRequest(spongReq.request, {      defaults: {        secure: process.env.NODE_ENV === "production",        sameSite: "lax",      },    });  }   private async startSession(    user: HydratedDocument<IUser>,    cookies: CookieManager,  ) {    const payload = { _id: String(user._id), role: user.role };     const accessToken = this.tokens.generateAccessToken(payload);    const refreshToken = this.tokens.generateRefreshToken(payload);     user.refreshToken = sha256(refreshToken);    await user.save();     cookies.set("token", accessToken, { maxAge: ACCESS_MAX_AGE });    cookies.set("refreshToken", refreshToken, { maxAge: REFRESH_MAX_AGE });  }   readonly register: SpongHandler = async (spongReq, spongRes) => {    const body = await spongReq.json<Record<string, unknown>>();    const result = Validator.validate<{      name: string;      email: string;      password: string;    }>(body, RegisterRule);     if (!result.valid) {      return spongRes.SpongUnprocessableContent({        success: false,        message: "Validation failed",        errors: result.errors,      });    }     const { name, email, password } = result.data;     if (await this.users.findOne({ email })) {      return spongRes.SpongConflict({        success: false,        message: "Email already registered",      });    }     const user = await this.users.create({      name,      email,      password: await bcrypt.hash(password, 12),      role: UserRole.USER,      isActive: true,    });     const cookies = this.cookiesFor(spongReq);    await this.startSession(user, cookies);     return cookies.applyTo(      spongRes.SpongCreated({        success: true,        message: "Account created successfully",        data: { id: user._id, name: user.name, email: user.email },      }),    );  };   readonly login: SpongHandler = async (spongReq, spongRes) => {    const body = await spongReq.json<Record<string, unknown>>();    const result = Validator.validate<{ email: string; password: string }>(      body,      LoginRule,    );     if (!result.valid) {      return spongRes.SpongUnprocessableContent({        success: false,        message: "Validation failed",        errors: result.errors,      });    }     const user = await this.users.findOne({ email: result.data.email });    const passwordMatches = user      ? await bcrypt.compare(result.data.password, user.password)      : false;     if (!user || !passwordMatches) {      return spongRes.SpongBadRequest({        success: false,        message: "Invalid email or password",      });    }     if (!user.isActive) {      return spongRes.SpongForbidden({        success: false,        message: "This account is blocked",      });    }     const cookies = this.cookiesFor(spongReq);    await this.startSession(user, cookies);     return cookies.applyTo(      spongRes.SpongOK({        success: true,        message: "Logged in successfully",        data: { id: user._id, name: user.name, email: user.email },      }),    );  };   readonly me: SpongHandler = async (spongReq, spongRes) => {    const authUser = spongReq.user;     if (!authUser) {      return spongRes.SpongUnauthorized({        success: false,        message: "You are not authenticated",      });    }     const user = await this.users.findById(authUser.id).select("-password -refreshToken").lean();     if (!user) {      return spongRes.SpongNotFound({        success: false,        message: "User not found",      });    }     return spongRes.SpongOK({ success: true, data: user });  };   readonly logout: SpongHandler = async (spongReq, spongRes) => {    const cookies = this.cookiesFor(spongReq);     // Pass the same path, sameSite and secure you used to set the cookies.    cookies.delete("token", { path: "/" });    cookies.delete("refreshToken", { path: "/" });     // Without applyTo the browser keeps the cookies.    return cookies.applyTo(      spongRes.SpongOK({ success: true, message: "Logged out successfully" }),    );  };   readonly refreshToken: SpongHandler = async (spongReq, spongRes) => {    const cookies = this.cookiesFor(spongReq);    const refreshToken = cookies.get("refreshToken");     if (!refreshToken) {      return spongRes.SpongUnauthorized({        success: false,        message: "Refresh token is missing",      });    }     const payload = this.tokens.verifyRefreshToken(refreshToken);    const user = payload ? await this.users.findById(payload._id) : null;     if (      !user ||      !user.isActive ||      !user.refreshToken ||      user.refreshToken !== sha256(refreshToken)    ) {      return spongRes.SpongUnauthorized({        success: false,        message: "Invalid or expired refresh token",      });    }     const accessToken = this.tokens.generateAccessToken({      _id: String(user._id),      role: user.role,    });     cookies.set("token", accessToken, { maxAge: ACCESS_MAX_AGE });     return cookies.applyTo(      spongRes.SpongOK({        success: true,        message: "Access token refreshed successfully",      }),    );  };}

The router

src/modules/auth/auth.route.ts
import { AuthMiddleware, RiserBob } from "riser-bob";import type { AuthController } from "./auth.controller"; export class AuthRouter {  constructor(    private readonly bob: RiserBob,    private readonly controller: AuthController,    private readonly authMiddleware: AuthMiddleware,  ) {}   register(): void {    // Public routes    this.bob.spongPost("/api/auth/register", this.controller.register);    this.bob.spongPost("/api/auth/login", this.controller.login);    this.bob.spongPost("/api/auth/refreshToken", this.controller.refreshToken);     // Protected routes    this.bob.spongGet(      "/api/auth/me",      this.controller.me,      this.authMiddleware.middleware,    );    this.bob.spongPost(      "/api/auth/logout",      this.controller.logout,      this.authMiddleware.middleware,    );  }}

The module

The module is where AuthMiddleware gets its verifier and its mode. The verifier returns an object with an id, plus the role that the admin middleware needs.

src/modules/auth/auth.module.ts
import { AuthMiddleware } from "riser-bob";import { RiserBob } from "riser-bob";import { TokenManager } from "../../config/token/token";import { UserSchema } from "../users/schemas/user.schema";import { AuthController } from "./auth.controller";import { AuthRouter } from "./auth.route"; export class AuthModule {  register(bob: RiserBob): void {    const tokens = new TokenManager();    const controller = new AuthController(tokens, UserSchema.model);     const authMiddleware = new AuthMiddleware(      (token) => {        const payload = tokens.verifyAccessToken(token);         if (!payload) return null;         return { id: payload._id, role: payload.role };      },      { mode: "cookie", cookieName: "token" },    );     new AuthRouter(bob, controller, authMiddleware).register();  }}
Other modules that need protected routes build their own AuthMiddleware the same way, or you create it once and pass it in. The verifier only needs the TokenManager.

Protect the tasks by owner

With the user on spongReq, a task can belong to its author. Store the userId when you create it and filter by it everywhere else, so one user can never read or change another user's tasks.

src/modules/tasks/tasks.controller.ts
readonly create: SpongHandler = async (spongReq, spongRes) => {  const user = spongReq.user;   if (!user) {    return spongRes.SpongUnauthorized({      success: false,      message: "You are not authenticated",    });  }   // ... validate the body, then:  const task = await this.taskModel.create({    userId: user.id,    title: result.data.title,  });   return spongRes.SpongCreated({ success: true, data: task });}; // Reads and writes always filter by the owner:const task = await this.taskModel.findOne({ _id: id, userId: user.id });

Roles with a second middleware

Authentication answers who the caller is. A second middleware answers what that caller may do. The AdminMiddleware from the Middleware page reads spongReq.user.role. Pass it after the auth middleware:

src/modules/users/users.route.ts
export class UsersRouter {  constructor(    private readonly bob: RiserBob,    private readonly controller: UsersController,    private readonly authMiddleware: AuthMiddleware,    private readonly adminMiddleware: AdminMiddleware,  ) {}   register(): void {    this.bob.spongGet("/api/users", this.controller.findAll);     this.bob.spongPut(      "/api/users/:id",      this.controller.update,      this.authMiddleware.middleware,      this.adminMiddleware.middleware,    );     this.bob.spongDelete(      "/api/users/:id",      this.controller.delete,      this.authMiddleware.middleware,      this.adminMiddleware.middleware,    );  }}
GET /api/users above is public. If your user objects contain private data, protect that route too and never return fields such as password or refreshToken.

Register it and try it

src/index.ts
new AuthModule().register(bob);new UsersModule().register(bob);new TasksModule().register(bob);
Terminal
# the -c and -b flags save and send the cookiescurl -c cookies.txt -X POST http://localhost:4000/api/auth/register \  -H "Content-Type: application/json" \  -d '{"name":"Zen","email":"zen@example.com","password":"supersecret"}' curl -b cookies.txt http://localhost:4000/api/auth/me curl -b cookies.txt -c cookies.txt -X POST http://localhost:4000/api/auth/logout

For a browser app on another origin, remember both halves: the API uses corsMiddleware with credentials: true and an explicit origin, and the front end uses fetch(url, { credentials: 'include' }).

PreviousSequelizeNextFile uploads

On this page

  • Install
  • The token manager
  • The user schema
  • The rules
  • The controller
  • The router
  • The module
  • Protect the tasks by owner
  • Roles with a second middleware
  • Register it and try it