npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

najm-auth

v1.1.39

Published

Authentication and authorization library for najm framework

Downloads

646

Readme

najm-auth

Production-ready authentication and authorization library for the Najm framework. Provides JWT-based authentication, role-based access control (RBAC), permission-based access control (PBAC), and row-level ownership scoping.

Features:

  • ✅ JWT authentication (access + refresh token strategy)
  • ✅ Automatic token rotation and blacklist-based revocation
  • ✅ Role-based access control (RBAC) with hierarchies
  • ✅ Permission-based access control (PBAC) with wildcards
  • ✅ Row-level ownership scoping for multi-tenant apps
  • ✅ Built-in password reset flow with email support
  • ✅ Multi-dialect support (PostgreSQL, SQLite, MySQL)
  • ✅ Type-safe decorators with TypeScript
  • ✅ Rate limiting on auth endpoints
  • ✅ Internationalization (i18n) for all messages

Installation

bun add najm-auth
# Peer dependencies
bun add hono drizzle-orm reflect-metadata

Quick Setup

1. Initialize Database

// src/database/schema.ts
import { authSchema } from 'najm-auth';
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

// Your app tables
export const products = sqliteTable('products', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  userId: text('userId').notNull(),
});

// Combined schema (always include authSchema)
export const schema = {
  ...authSchema,  // users, roles, permissions, tokens, rolePermissions
  products,
};

// src/database/index.ts
import { drizzle } from 'drizzle-orm/bun-sqlite';
import { Database } from 'bun:sqlite';
import { schema } from './schema';

const sqlite = new Database('./app.db');
export const db = drizzle(sqlite, { schema });

2. Configure Auth Plugin

// src/main.ts
import 'reflect-metadata';
import { Server } from 'najm-core';
import { database } from 'najm-database';
import { auth } from 'najm-auth';
import { db } from './database';

const server = new Server()
  .use(database({ default: db }))  // Required: database must be registered first
  .use(auth({
    dialect: 'sqlite',  // Auto-selects SQLite schema
    jwt: {
      accessSecret: process.env.JWT_ACCESS_SECRET!,    // Required
      refreshSecret: process.env.JWT_REFRESH_SECRET!,  // Required
      accessExpiresIn: '15m',       // Optional, default: 1h
      refreshExpiresIn: '7d',       // Optional, default: 7d
    },
    frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000',  // For password reset links
  }))
  .load(/* your controllers and services */)
  .listen(3000);

3. Set Environment Variables

# .env
JWT_ACCESS_SECRET=<32-character-minimum-secret>
JWT_REFRESH_SECRET=<32-character-minimum-secret>
FRONTEND_URL=https://app.example.com

⚠️ Security: Generate secrets with openssl rand -base64 32


Configuration Reference

AuthPluginConfig

auth({
  // Database
  dialect?: 'pg' | 'sqlite' | 'mysql'    // Default: 'pg'
  schema?: AuthSchema                      // Override dialect schema

  // JWT
  jwt?: {
    accessSecret: string                   // Required, min 32 chars
    accessExpiresIn?: string               // Default: 1h
    refreshSecret: string                  // Required, min 32 chars
    refreshExpiresIn?: string              // Default: 7d
  }

  // Cookies
  refreshCookieName?: string               // Default: 'refreshToken'

  // Database
  database?: string                        // Default: 'default'
  blacklistPrefix?: string                 // Default: 'auth:blacklist:'

  // Registration
  defaultRole?: string | null              // Auto-assign role to new users
  bcryptRounds?: number                    // Default: 10 (valid: 4-31)

  // Frontend
  frontendUrl?: string                     // Password reset link base URL

  // Dependencies (forwarded to plugins)
  validation?: ValidationPluginConfig
  rateLimit?: RateLimitPluginConfig
})

Auto-Registered Routes

All routes are prefixed with /auth and auto-registered by the plugin.

Authentication Routes

| Method | Path | Description | Auth | |--------|------|-------------|------| | POST | /auth/register | Register new user | None | | POST | /auth/login | Login with email/password | None | | POST | /auth/refresh | Refresh access token (cookie) | None (uses refresh cookie) | | POST | /auth/logout | Logout and revoke tokens | ✅ Required | | GET | /auth/me | Get current user profile | ✅ Required | | POST | /auth/forgot-password | Request password reset | None | | POST | /auth/reset-password | Confirm password reset | None |

Admin Routes (all require @isAdmin())

| Method | Path | Description | |--------|------|-------------| | GET | /users?limit=50&offset=0 | List users (limit 1-100) | | GET | /users/:id | Get user by ID | | POST | /users | Create new user | | PUT | /users/:id | Update user | | DELETE | /users/:id | Delete user | | GET | /roles | List all roles | | GET | /roles/:id | Get role by ID | | POST | /roles | Create new role | | PUT | /roles/:id | Update role | | DELETE | /roles/:id | Delete role | | GET | /permissions | List all permissions | | GET | /permissions/:id | Get permission by ID | | POST | /permissions | Create new permission | | PUT | /permissions/:id | Update permission | | DELETE | /permissions/:id | Delete permission | | POST | /permissions/assign/:roleId/:permissionId | Assign permission to role | | DELETE | /permissions/remove/:roleId/:permissionId | Remove permission from role |


Guards Reference

Authentication Guard

import { isAuth } from 'najm-auth';

@Controller('/api/posts')
class PostController {
  @Get('/')                    // Public
  getAll() { }

  @Post('/')
  @isAuth()                    // Requires valid JWT
  create(@Body() data: any) { }
}

Role Guards

import { defineRoles } from 'najm-auth';

const roles = defineRoles({
  ADMIN: 'admin',
  MODERATOR: 'moderator',
  USER: 'user',
}, {
  superRoles: ['ADMIN'],       // admin also passes moderator/user role guards
});

export const { isAdmin, isModerator, isUser } = roles;

@Controller('/admin')
@isAdmin()                     // All methods require admin role
class AdminController {
  @Get('/users')
  getUsers() { }
}

@Controller('/api/posts')
class PostController {
  @Delete('/:id')
  @isModerator()               // Method-level guard
  deletePost() { }
}

Permission Guards

import { Can, canRead, canCreate, canUpdate, canDelete } from 'najm-auth';

@Controller('/api/posts')
class PostController {
  @Get('/')
  @canRead('posts')            // Requires 'read:posts' permission
  getAll() { }

  @Post('/')
  @canCreate('posts')          // Requires 'create:posts' permission
  create(@Body() data: any) { }

  @Put('/:id')
  @canUpdate('posts')          // Requires 'update:posts' permission
  update() { }

  @Delete('/:id')
  @canDelete('posts')          // Requires 'delete:posts' permission
  delete() { }

  @Post('/:id/publish')
  @Can('publish:posts')        // Custom permission
  publish() { }
}

Permission Wildcards:

  • *:* — All actions on all resources
  • create:* — Create action on any resource
  • *:posts — Any action on posts

Combined Guards

@Controller('/admin/reports')
@isAdmin()                     // Require admin role
class ReportController {
  @Get('/financial')
  @Can('view:financial')       // AND require financial view permission
  getFinancial() { }
}

Ownership System

Control row-level access based on ownership (e.g., users see only their own data).

Declaring Ownership Rules

import { own, join, where } from 'najm-auth';
import { schema } from '../database/schema';

const { products, users } = schema;
const _users = alias(users, '_u');

export const Product = own(products)
  .for('user',
    join(products.userId, _users.id),
    where(_users.id)
  )
  .writeBy(products.userId);  // Enforce on create/update

Using @Policy and @Owned

import { configureOwnership, Policy, CanList, CanRead, CanCreate, CanUpdate, CanDelete } from 'najm-auth';

const config = configureOwnership({
  adminRoles: ['admin'],
  rules: {
    'user': {
      'products': Product.getRules()['user']
    }
  }
});

@Policy(Product)
@Controller('/api/products')
export class ProductController {
  @Get('/')
  @CanList()                   // List only owned products
  getAll(@GuardParams() filter: any) { }

  @Get('/:id')
  @CanRead()                   // Read only if owner
  getOne() { }

  @Post('/')
  @CanCreate()                 // Create (ownership assigned automatically)
  create(@Body() data: any) { }

  @Put('/:id')
  @CanUpdate()                 // Update only if owner
  update(@Body() data: any) { }

  @Delete('/:id')
  @CanDelete()                 // Delete only if owner
  delete() { }
}

@Repository('default')
@Owned(Product)
export class ProductRepository {
  @DB() db!: Database;

  // Auto-scoped to current user
  async findMany(opts?: { where?: any; limit?: number }) {
    return this.findMany(opts);  // Only returns owned products
  }

  async findOne(opts: { where: any }) {
    return this.findOne(opts);   // Returns null if not owned
  }

  async scopedQuery() {
    return this.scopedQuery();   // Raw scoped query builder
  }
}

Advanced Ownership: Multi-Role Scoping

const Grade = own(grades)
  // Teachers see students' grades
  .for('teacher',
    join(grades.studentId, _s.id),
    join(_s.id, _t.studentId),
    where(_t.userId)
  )
  // Parents see only their child's grades
  .for('parent',
    join(grades.studentId, _s.id),
    join(_s.id, _p.studentId),
    where(_p.userId)
  );

Database Schema

Tables

users
├── id (string, primary key)
├── email (string, unique)
├── password (string, hashed)
├── emailVerified (boolean, default: false)
├── image (string, nullable)
├── status (enum: ACTIVE, INACTIVE)
├── roleId (string, FK → roles.id)
├── lastLogin (timestamp, nullable)
├── createdAt (timestamp)
└── updatedAt (timestamp)

roles
├── id (string, primary key)
├── name (string, unique)
├── description (string, nullable)
├── createdAt (timestamp)
└── updatedAt (timestamp)

permissions
├── id (string, primary key)
├── name (string, unique)
├── description (string, nullable)
├── resource (string)
├── action (string)
├── createdAt (timestamp)
└── updatedAt (timestamp)

tokens
├── id (string, primary key)
├── userId (string, FK → users.id, unique)
├── token (string, hashed)
├── type (enum: REFRESH, RESET)
├── status (enum: ACTIVE, REVOKED)
├── expiresAt (timestamp)
├── createdAt (timestamp)
└── updatedAt (timestamp)

role_permissions
├── id (string, primary key)
├── roleId (string, FK → roles.id)
├── permissionId (string, FK → permissions.id)
├── createdAt (timestamp)
└── updatedAt (timestamp)

ID Strategy

Uses nanoid with short lengths for efficient storage:

  • Users: 8 characters
  • Roles: 5 characters
  • Permissions: 5 characters
  • Tokens: 10 characters

To use UUIDs instead, customize the schema:

import { customAlphabet } from 'nanoid';
import { uuid } from 'uuid';

// Use UUID for larger ID space
const customUsers = sqliteTable('users', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  // ...
});

Seeding

Low-Level Seeding (authSeed)

import { authSeed } from 'najm-auth';
import { SeedService } from 'najm-database';

@Service()
class SetupService {
  constructor(private seeder: SeedService) {}

  async seed() {
    const entries = authSeed({
      adminEmail: '[email protected]',
      adminPass: 'AdminPass123!',
      roles: [
        { name: 'editor', description: 'Can edit content' },
        { name: 'viewer', description: 'Can view only' },
      ],
      permissions: [
        { name: 'read:posts', resource: 'posts', action: 'read' },
        { name: 'create:posts', resource: 'posts', action: 'create' },
      ],
      additionalUsers: [
        { email: '[email protected]', password: 'User123!', roleName: 'viewer' },
      ]
    });

    await this.seeder.run(entries);
  }
}

High-Level Seeding (seedAuthData)

import { seedAuthData } from 'najm-auth';

await seedAuthData({
  db,
  adminEmail: process.env.ADMIN_EMAIL!,
  adminPassword: process.env.ADMIN_PASSWORD!,
  roles: [
    { name: 'moderator', description: 'Content moderator' },
  ],
  users: [
    { email: '[email protected]', password: 'Mod123!' , roleName: 'moderator' },
  ],
  verbose: true
});

// Note: Return type has empty users[] and roles[] arrays
// Query the database directly to retrieve inserted records

Rate Limiting

Auth routes have built-in rate limiting to prevent brute force attacks.

| Route | Limit | Window | Key Strategy | |-------|-------|--------|--------------| | POST /auth/register | 5 | 15 minutes | IP | | POST /auth/login | 5 | 15 minutes | IP | | POST /auth/refresh | 15 | 15 minutes | Cookie fingerprint | | POST /auth/logout | 10 | 15 minutes | User ID | | GET /auth/me | 30 | 1 minute | User ID | | POST /auth/forgot-password | 3 | 15 minutes | IP | | POST /auth/reset-password | 5 | 15 minutes | IP |

Customizing Rate Limits

auth({
  rateLimit: {
    keyGenerator: 'ip',        // or 'user', 'api-key', 'user+ip'
    defaultWindow: '10m',
    skip: (ctx) => ctx.path === '/health'  // Skip for certain routes
  }
})

TypeScript Types

import type {
  AuthUser,           // { id, email, name?, role?, permissions? }
  TokenPair,          // { accessToken, refreshToken, expiresAt? }
  JwtPayload,         // { userId, jti, exp?, iat? }
  AuthConfig,         // Full resolved config
  AuthPluginConfig,   // User-facing config
} from 'najm-auth';

Error Handling

All errors are i18n-based. Error messages are automatically localized.

Common Error Codes

| HTTP | Scenario | |------|----------| | 400 | Invalid input (bad email format, weak password) | | 401 | Missing or invalid authentication (bad token, no header) | | 403 | Forbidden (lacks required role/permission) | | 409 | Conflict (email already registered) | | 429 | Rate limited (too many requests) | | 500 | Server error (email send failure, DB error) |

Examples

// Invalid credentials
throw new HttpError(401, 'Invalid email or password');

// User already exists
throw new HttpError(409, 'Email already registered');

// Insufficient permissions
throw new HttpError(403, 'Insufficient permissions for this action');

Security Considerations

Password Reset Tokens

⚠️ Current behavior: Reset tokens use JWT expiry (default 1h) for single-use validation. To add database-backed single-use tokens:

// In AuthService.resetPassword():
async resetPassword(token: string, newPassword: string) {
  const userId = this.tokenService.verifyResetToken(token);
  // ... update password ...
  // Blacklist the reset token to prevent reuse
  await this.tokenService.blacklistCurrentToken(token);
}

Session Management

  • Sessions are single-device: the token table stores one refresh row per user, so a new login replaces the previous device's refresh session
  • A stale refresh token presented after the 120-second rotation grace window revokes the active refresh session as reuse protection
  • The signed session cookie is accepted for up to its configured TTL (5 minutes by default) without a database or revocation-cache read
  • Use @RateLimit on logout for DDoS protection

Token Blacklist

  • Built-in cache-based blacklist for immediate revocation
  • Supports Redis via cache() plugin configuration
  • Default: in-memory store (development/single-process only; entries are lost on restart)
  • Use Redis in production when immediate revocation must survive restarts or propagate across instances
  • Session-version revocation keys are cache-backed and TTL-bound to active access tokens

Timing Attack Prevention

  • Dummy hash used for missing users in login
  • Constant-time password comparison
  • Same response for forgot-password (prevents email enumeration)

Testing

bun run test      # Run all tests
bun run test:auth # Run auth tests only

Test files include:

  • schema.test.ts — Schema exports validation
  • auth.test.ts — Authentication flow
  • user.test.ts — User CRUD
  • role.test.ts — Role management
  • permission.test.ts — Permission guards
  • guards.test.ts — Guard composability
  • ownership.test.ts — Row-level scoping
  • integration.test.ts — Multi-role scenarios

Production Checklist

  • ✅ Use strong JWT secrets (32+ chars, generated with openssl rand -base64 32)
  • ✅ Set FRONTEND_URL environment variable
  • ✅ Enable HTTPS in production
  • ✅ Store secrets in environment variables (never in code)
  • ✅ Use Redis for token blacklist/session-version revocation in production and distributed systems
  • ✅ Trust forwarded IP headers only behind a known proxy; otherwise provide a custom rate-limit key generator
  • ✅ Enable rate limiting on all auth routes
  • ✅ Log authentication events for audit trails
  • ✅ Test ownership scoping rules with multi-user scenarios
  • ✅ Run full test suite before deploying

Migration Guide

From v1.0 to v1.1

  • FRONTEND_URL now part of AuthPluginConfig (falls back to env var)
  • New: Rate limiting on /auth/logout and /auth/me
  • New: configureOwnership() for advanced scoping
  • New: @Policy and @Owned decorators

Support & Contributing

For issues, feature requests, or contributions, please refer to the main Najm repository: https://github.com/najm/najm-api