JWT Authentication

createJwtPlugin() returns a self-contained authentication plugin built on Node.js crypto — no external JWT library is required.

Features: HMAC / RSA / ECDSA algorithms, refresh token rotation, role and permission guards, pluggable user database, pluggable token store.


Quick start

import { createRouter, json, createJwtPlugin } from 'expediate';

const app  = createRouter();
const auth = createJwtPlugin({
  accessTokenSecret: process.env.JWT_SECRET!,
});

// Auth endpoints (all require the json() body parser)
app.post('/auth/login',   json(), auth.login);
app.post('/auth/refresh', json(), auth.refresh);
app.post('/auth/logout',  json(), auth.logout);

// Protected route
app.get('/me', auth.authenticate, auth.authorize, (req, res) => {
  res.json((req as any).user);
});

Auth endpoints

POST /auth/login

// Request
{ "username": "alice", "password": "password123" }

// Response 200
{
  "accessToken":  "eyJ...",
  "refreshToken": "a3f8...",
  "expiresIn":    900,
  "tokenType":    "Bearer"
}

POST /auth/refresh

// Request
{ "username": "alice", "refreshToken": "a3f8..." }

// Response 200 — new token pair (old refresh token is invalidated immediately)
{ "accessToken": "eyJ...", "refreshToken": "b9c2...", "expiresIn": 900, "tokenType": "Bearer" }

Refresh tokens are rotated on every use — the old token is invalidated before the new pair is issued, so a stolen refresh token can only be used once.

POST /auth/logout

// Request
{ "refreshToken": "b9c2..." }

// Response 200 — refresh token revoked
{ "message": "Logged out successfully" }

Protecting routes

// authenticate — populates req.user; calls next() silently on failure
// authorize   — rejects with 401 Unauthorized if req.user is not set
app.get('/profile', auth.authenticate, auth.authorize, handler);

authenticate always clears req.user at the start of the middleware call to prevent stale data leaking across requests. This split design means you can optionally inspect req.user in middleware before requiring it.

req.user shape when authenticated:

interface TokenPayload {
  sub:          string;    // user ID
  username:     string;
  iss:          string;    // issuer
  iat:          number;    // issued at (Unix seconds)
  exp:          number;    // expires at (Unix seconds)
  roles?:       string[];
  permissions?: string[];
}

Role and permission guards

Both helpers return [authenticate, guard] — spread them into route registration:

// Require at least ONE of the listed roles
app.delete('/admin/users/:id', ...auth.requireRole('admin'), deleteUser);
app.get('/reports',           ...auth.requireRole('admin', 'editor'), getReports);

// Require ALL of the listed permissions
app.put('/posts/:id', ...auth.requirePermission('write', 'publish'), updatePost);

Configuration reference

const auth = createJwtPlugin({
  // ── Secrets (always set in production) ──────────────────────────────────
  accessTokenSecret:  'change-me',  // shared HMAC secret (HS*)

  // For asymmetric algorithms (RS*, ES*), supply PEM keys instead:
  // accessTokenPrivateKey: readFileSync('private.pem', 'utf8'),
  // accessTokenPublicKey:  readFileSync('public.pem',  'utf8'),

  // ── Algorithm ───────────────────────────────────────────────────────────
  alg: 'HS256',  // 'HS256' | 'HS384' | 'HS512'
                 // 'RS256' | 'RS384' | 'RS512'
                 // 'ES256' | 'ES384' | 'ES512'

  // ── Expiry ──────────────────────────────────────────────────────────────
  accessTokenExpiry:  15 * 60,        // 15 minutes (seconds)
  refreshTokenExpiry: 7 * 24 * 3600,  // 7 days (seconds)

  // ── Claims ──────────────────────────────────────────────────────────────
  issuer:      'my-app',
  checkIssuer: true,  // reject tokens with a different iss claim

  // ── User database ───────────────────────────────────────────────────────
  // Replace with a real database query; return null to reject login
  fetchUser: async (username) => {
    return await db.users.findOne({ username });
  },

  // ── Password validation ─────────────────────────────────────────────────
  // Default uses SHA-256 — replace with bcrypt/argon2 for production
  isPasswordValid: async (user, password) => {
    return await bcrypt.compare(password, user.passwordHash);
  },

  // ── Custom JWT payload ──────────────────────────────────────────────────
  payload: (user) => ({
    sub:         user.id,
    username:    user.username,
    roles:       user.roles,
    permissions: user.permissions,
  }),

  // ── Token store ─────────────────────────────────────────────────────────
  // Default is an in-memory Map. Replace with a Redis adapter for multi-instance.
  refreshTokenStore: redisAdapter,
});

Token store interface

interface TokenStore {
  get(token: string):                      Promise<RefreshTokenRecord | undefined>;
  set(token: string, record: RefreshTokenRecord): Promise<void>;
  delete(token: string):                   Promise<void>;
}

interface RefreshTokenRecord {
  username:  string;
  expiresAt: number;  // Unix seconds
}

A built-in in-memory store is available for testing:

import { createMapTokenStore } from 'expediate';

const auth = createJwtPlugin({
  accessTokenSecret: 'dev-secret',
  refreshTokenStore: createMapTokenStore(),
});

Algorithms

All algorithms are implemented using Node.js crypto — no third-party JWT library:

Algorithm Key type Notes
HS256 / HS384 / HS512 Shared HMAC secret string Default; accessTokenSecret
RS256 / RS384 / RS512 RSA PEM key pair accessTokenPrivateKey + accessTokenPublicKey
ES256 / ES384 / ES512 ECDSA PEM key pair DER↔P1363 conversion for standard JWT wire format

Verification uses crypto.timingSafeEqual to prevent timing attacks.


Security notes

  • The default password hashing uses SHA-256, which is not suitable for production. Always supply an isPasswordValid function that uses bcrypt, argon2, or scrypt.
  • Calling createJwtPlugin() with no accessTokenSecret uses a placeholder secret and should only be done in tests or demos.
  • Refresh tokens are opaque 128-character hex strings (64 random bytes), not JWTs. They are stored server-side in the token store and invalidated on each use.
MIT © Fabien Bavent