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

@lightsoft-pe/auth

v0.1.0

Published

Storage-agnostic hexagonal auth library for Node.js/TypeScript: login, registration, JWT (HS256), RBAC, admin user management, and optional password reset. Ships a bundled Cosmos DB adapter.

Downloads

142

Readme

@lightsoft-pe/auth

Storage-agnostic hexagonal authentication library for Node.js / TypeScript.

Ships a clean ports-and-adapters core (login, registration, JWT HS256, RBAC, generic user profiles, account state, admin user management, optional password reset) plus a bundled Cosmos DB adapter — bring your own database for everything else.


Features

  • Register / Login with bcryptjs password hashing (configurable cost factor)
  • JWT access tokens via jose (HS256; configurable TTL)
  • Generic user profilesUser<TProfile> carries an application-defined profile type
  • Account statestatus (active/disabled), emailVerified, emailVerifiedAt, lastLoginAt
  • RBAC — roles → permissions map, wildcard *, deny-by-default, checkPermission + standalone can()
  • Admin user managementcreateUser, updateUser, deleteUser, listUsers (cursor-paginated), assignRole
  • Optional password reset — request/reset flow; tokens are SHA-256 hashed, single-use, expiry-enforced
  • Zero framework coupling — the core has no HTTP, no cloud, no ORM
  • Bundled Cosmos DB adapter via @lightsoft-pe/auth/adapters/cosmos (optional peer dep)
  • Swap any adapter — implement a port interface to use any database, token strategy, or email service
  • Full TypeScript — strict typings, overloaded factory returns AuthCore or AuthWithReset
  • ESM only, Node >= 18

Install

npm install @lightsoft-pe/auth

If you want the bundled Cosmos DB adapter, also install the optional peer dependency:

npm install @azure/cosmos

Quick Start

import { createAuth } from '@lightsoft-pe/auth'

// Define your application-specific profile shape
type Profile = { firstName: string; lastName: string }

// Bring your own UserRepositoryPort<Profile> implementation (in-memory, Postgres, etc.)
import { myUserRepository } from './my-user-repository.js'

const auth = createAuth<Profile>({
  config: {
    jwtSecret: process.env.JWT_SECRET!, // required
    tokenExpiresInSeconds: 3600,        // default: 3600
    bcryptRounds: 12,                   // default: 12
    defaultRoles: ['user'],             // default: ['user']
    rolePermissions: {
      admin: ['users:read', 'users:write', 'users:delete'],
      user:  ['users:read:self'],
    },
  },
  userRepository: myUserRepository,
})

// Register a new user — profile is REQUIRED
const registered = await auth.register({
  email: '[email protected]',
  password: 'secret123',
  profile: { firstName: 'Alice', lastName: 'Smith' },
})
// { id: '...', email: '[email protected]', roles: ['user'] }

// Login
const { token, user } = await auth.login({ email: '[email protected]', password: 'secret123' })
// user is PublicUser<Profile> — includes profile, status, emailVerified, lastLoginAt, etc.

// Verify a token
const { principal } = await auth.verifyToken(token)
// principal: { userId: '...', email: '[email protected]', roles: ['user'] }

// RBAC guard
const allowed = auth.checkPermission({ roles: principal.roles, permission: 'users:write' })
if (!allowed) throw new Error('Forbidden')

If your app does not need a typed profile, pass profile: {} — the default type is Record<string, unknown> and profile is still required on register/createUser:

const auth = createAuth({ config: { jwtSecret: '...' }, userRepository })
await auth.register({ email: '[email protected]', password: 'secret', profile: {} })

Configuration

createAuth accepts a CreateAuthOptionsExtended object:

interface CreateAuthOptions<TProfile> {
  config: AuthConfig
  userRepository: UserRepositoryPort<TProfile>       // required
  emailSender?: EmailSenderPort                      // enables password reset
  resetTokenRepository?: ResetTokenRepositoryPort    // enables password reset
}

interface AuthConfig {
  jwtSecret: string                        // required — HS256 signing secret
  tokenExpiresInSeconds?: number           // default: 3600
  bcryptRounds?: number                    // default: 12
  defaultRoles?: Role[]                    // default: ['user']
  rolePermissions?: Record<Role, string[]> // required for checkPermission to be meaningful
  requireVerifiedEmail?: boolean           // default: false — when true, login rejects unverified emails
}

requireVerifiedEmail

When set to true, a user with emailVerified: false will receive an EmailNotVerifiedError on login — but only after their password is verified. This preserves the anti-enumeration guarantee (wrong credentials always return InvalidCredentialsError regardless of account state).

Note: this library ships the emailVerified field and the login gate. The full email-verification flow (issuing and confirming a verification token) is not built-in yet — implement it in your application using updateUser({ id, emailVerified: true }) once you have confirmed the address.

Default adapters (injected automatically)

| Adapter | Default | Override via | |---|---|---| | Password hasher | BcryptjsPasswordHasher | passwordHasher | | Token issuer | JoseTokenIssuer (HS256) | tokenIssuer | | Clock | SystemClock | clock | | ID generator | CryptoIdGenerator (crypto.randomUUID) | idGenerator | | Reset-token hasher | Sha256TokenHasher (deterministic) | resetTokenHasher |

All defaults are exported from the main entry point so you can use them individually.


User Model

Login returns a PublicUser<TProfile> and admin operations work with the same shape. toPublic() strips the password hash — it is never included in any output.

interface PublicUser<TProfile = Record<string, unknown>> {
  id: string
  email: string
  roles: readonly Role[]
  profile: TProfile           // application-defined shape
  status: 'active' | 'disabled'
  emailVerified: boolean
  emailVerifiedAt: Date | null
  lastLoginAt: Date | null
  createdAt: Date
  updatedAt: Date
}

Field notes

| Field | Default on create | Notes | |---|---|---| | profile | — (required) | Full replacement on updateUser | | status | 'active' | 'disabled' blocks login | | emailVerified | false | Gates login only when requireVerifiedEmail: true | | emailVerifiedAt | null | Set automatically by updateUser({ emailVerified: true }) | | lastLoginAt | null | Updated on every successful login; does not bump updatedAt |


Login Behavior

The login use case enforces checks in the following order to prevent account enumeration:

  1. Email lookup — if the user does not exist, a dummy bcrypt comparison runs (constant-time), then InvalidCredentialsError is thrown.
  2. Password verification — if the password is wrong, InvalidCredentialsError is thrown.
  3. Account status — if status !== 'active', AccountDisabledError is thrown.
  4. Email verification — if requireVerifiedEmail: true and emailVerified === false, EmailNotVerifiedError is thrown.
  5. SuccesslastLoginAt is recorded, a JWT is issued, and { token, user } is returned.

The critical property: steps 3 and 4 only execute after a correct password, so the existence of an account is only revealed to a caller who has already authenticated successfully.


Admin: User Management

// Create a user directly (no self-registration flow)
await auth.createUser({
  email: '[email protected]',
  password: 'secret',
  roles: ['admin'],
  profile: { firstName: 'Bob', lastName: 'Jones' },
})

// Update a user — all fields are optional except id
await auth.updateUser({
  id: userId,
  profile: { firstName: 'Robert', lastName: 'Jones' }, // full replace
  status: 'disabled',    // disable the account
  emailVerified: true,   // mark email as verified (sets emailVerifiedAt automatically)
  roles: ['user'],
  password: 'newSecret',
})

// List users (cursor-paginated)
const { users, nextCursor } = await auth.listUsers({ limit: 20 })

// Assign a role
await auth.assignRole(userId, 'admin')

// Delete a user
await auth.deleteUser(userId)

updateUser performs a full replacement of the profile field — merge partial changes before calling if needed.


Password Reset (optional)

Provide both emailSender and resetTokenRepository to unlock reset capabilities. The factory's return type changes to AuthWithReset automatically:

import { createAuth } from '@lightsoft-pe/auth'
import { createCosmosResetTokenRepository } from '@lightsoft-pe/auth/adapters/cosmos'

const auth = createAuth<Profile>({
  config: { jwtSecret: process.env.JWT_SECRET! },
  userRepository,
  resetTokenRepository: createCosmosResetTokenRepository(resetTokensContainer),
  emailSender: {
    sendPasswordResetEmail: async (to, rawToken) => {
      // deliver the reset link however you like (SendGrid, nodemailer, etc.)
      await mailer.send({ to, subject: 'Reset your password', text: `?token=${rawToken}` })
    },
  },
})

// Request a reset — always resolves (anti-enumeration: no error for unknown email)
await auth.requestPasswordReset({ email: '[email protected]' })

// Complete the reset with the raw token from the email link
await auth.resetPassword({ rawToken: req.query.token, newPassword: 'newSecret456' })

If only one of the two optional ports is provided, or if you call reset methods on an AuthCore-typed facade, you get a CapabilityUnavailableError at runtime.

Reset tokens are:

  • SHA-256 hashed before storage (never stored raw)
  • Single-use (invalidate is called on successful reset)
  • Expiry-enforced by the use case

Storage Adapters

Bundled: Cosmos DB

import { createCosmosUserRepository, createCosmosResetTokenRepository } from '@lightsoft-pe/auth/adapters/cosmos'
import { CosmosClient } from '@azure/cosmos'

type Profile = { firstName: string; lastName: string }

const cosmos = new CosmosClient({ endpoint: process.env.COSMOS_ENDPOINT!, key: process.env.COSMOS_KEY! })
const db = cosmos.database('authdb')

const userRepository = createCosmosUserRepository<Profile>(db.container('users'))
const resetTokenRepository = createCosmosResetTokenRepository(db.container('resetTokens'))

Required Cosmos DB containers:

| Container | Partition Key | |---|---| | users | /id | | resetTokens | /id |

You can also instantiate CosmosUserRepository and CosmosResetTokenRepository directly if you prefer not to use the factory helpers.

Bring Your Own

Implement any of the 7 outbound port interfaces and pass them to createAuth. All are exported from the main entry point:

| Port | Purpose | |---|---| | UserRepositoryPort<TProfile> | findById, findByEmail, save, delete, list (cursor-paginated) | | PasswordHasherPort | hash, compare | | TokenIssuerPort | issue, verify (JWT or any token scheme) | | ClockPort | now(): Date (useful for testing) | | IdGeneratorPort | generate(): string | | EmailSenderPort | sendPasswordResetEmail(to, rawToken) | | ResetTokenRepositoryPort | save, findByHash, invalidate |

UserRepositoryPort is generic — it carries and returns User<TProfile> throughout. The persisted user record includes profile, status, emailVerified, emailVerifiedAt, and lastLoginAt.

import type { UserRepositoryPort, CursorPage, User } from '@lightsoft-pe/auth'

type Profile = { firstName: string; lastName: string }

class PrismaUserRepository implements UserRepositoryPort<Profile> {
  async findById(id: string): Promise<User<Profile> | undefined> { /* ... */ }
  async findByEmail(email: Email): Promise<User<Profile> | undefined> { /* ... */ }
  async save(user: User<Profile>): Promise<void> { /* ... */ }
  async delete(id: string): Promise<void> { /* ... */ }
  async list(options?: ListUsersOptions): Promise<CursorPage<User<Profile>>> { /* ... */ }
}

RBAC

Roles are plain strings. Permissions are strings. The rolePermissions map controls what each role can do.

const auth = createAuth<Profile>({
  config: {
    jwtSecret: '...',
    rolePermissions: {
      admin: ['*'],           // wildcard — grants everything
      editor: ['posts:write', 'posts:read'],
      user:   ['posts:read'],
    },
  },
  userRepository,
})

// Via the facade (uses the wired rolePermissions)
auth.checkPermission({ roles: ['editor'], permission: 'posts:write' }) // true
auth.checkPermission({ roles: ['user'],   permission: 'posts:write' }) // false

// Standalone pure function (you supply the map)
import { can } from '@lightsoft-pe/auth'
can(['admin'], 'anything', rolePermissions) // true (wildcard)

RBAC is deny-by-default: any role not listed, or any permission not in its list, returns false.


Errors

All errors extend AuthError which carries a code string. Import and catch by class or by code:

import {
  AuthError,
  UserAlreadyExistsError,
  UserNotFoundError,
  InvalidCredentialsError,
  AccountDisabledError,
  EmailNotVerifiedError,
  InvalidTokenError,
  TokenExpiredError,
  InvalidEmailError,
  CapabilityUnavailableError,
  InvalidResetTokenError,
} from '@lightsoft-pe/auth'

try {
  await auth.login({ email, password })
} catch (err) {
  if (err instanceof InvalidCredentialsError) { /* err.code === 'INVALID_CREDENTIALS' */ }
  if (err instanceof AccountDisabledError)    { /* err.code === 'ACCOUNT_DISABLED' */ }
  if (err instanceof EmailNotVerifiedError)   { /* err.code === 'EMAIL_NOT_VERIFIED' */ }
  if (err instanceof TokenExpiredError)       { /* err.code === 'TOKEN_EXPIRED' */ }
}

| Error class | Code | Thrown when | |---|---|---| | AuthError | varies | Base class for all auth errors | | UserAlreadyExistsError | USER_ALREADY_EXISTS | register / createUser with a duplicate email | | UserNotFoundError | USER_NOT_FOUND | updateUser, deleteUser, assignRole with unknown id | | InvalidCredentialsError | INVALID_CREDENTIALS | login with wrong email or password (unified — no enumeration) | | AccountDisabledError | ACCOUNT_DISABLED | login when account status === 'disabled' (only after correct password) | | EmailNotVerifiedError | EMAIL_NOT_VERIFIED | login when requireVerifiedEmail: true and emailVerified === false (only after correct password) | | InvalidEmailError | INVALID_EMAIL | Email fails format validation | | InvalidTokenError | INVALID_TOKEN | verifyToken with a malformed or invalid JWT | | TokenExpiredError | TOKEN_EXPIRED | verifyToken with an expired JWT (subclass of InvalidTokenError) | | InvalidResetTokenError | INVALID_RESET_TOKEN | resetPassword with unknown/used/expired token | | CapabilityUnavailableError | CAPABILITY_UNAVAILABLE | Reset methods called without emailSender+resetTokenRepository wired |


Security Notes

  • Passwords are hashed with bcryptjs (default cost 12) and never stored in plain text.
  • Anti-enumeration on login: wrong credentials always return InvalidCredentialsError regardless of whether the account exists. AccountDisabledError and EmailNotVerifiedError are only reachable after a correct password.
  • Anti-enumeration on reset: requestPasswordReset always resolves regardless of whether the email exists — no timing oracle.
  • Reset tokens are SHA-256 hashed before storage; only the raw token is sent to the user's email.
  • Reset tokens are single-use: invalidated immediately after a successful resetPassword.
  • toPublic() never includes passwordHash — it is safe to serialize and return to callers.
  • JWT secrets should be loaded from environment variables, never hardcoded.
  • No secrets or tokens are logged by the library.

Architecture

The library follows hexagonal architecture (ports and adapters):

Your app (inbound adapter)
      │
      ▼
  createAuth()  ←── outbound adapters (Cosmos, bcrypt, jose, …)
      │
      ▼
  AuthCore / AuthWithReset  (application use cases)
      │
      ▼
  Domain (User<TProfile>, Email, Role, errors)

The package is the core + outbound adapters. Your app provides the inbound adapter (HTTP handlers, CLI, queue consumer — whatever fits your runtime). The core has zero knowledge of HTTP, Azure, or any framework.

For a complete Azure Functions v4 wiring example — including HTTP trigger handlers, Bearer token middleware, and environment variable setup — see examples/azure-functions/.


Requirements

  • Node.js >= 18
  • ESM ("type": "module" or .mjs)

License

MIT — see LICENSE.