@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 profiles —
User<TProfile>carries an application-defined profile type - Account state —
status(active/disabled),emailVerified,emailVerifiedAt,lastLoginAt - RBAC — roles → permissions map, wildcard
*, deny-by-default,checkPermission+ standalonecan() - Admin user management —
createUser,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
AuthCoreorAuthWithReset - ESM only, Node >= 18
Install
npm install @lightsoft-pe/authIf you want the bundled Cosmos DB adapter, also install the optional peer dependency:
npm install @azure/cosmosQuick 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:
- Email lookup — if the user does not exist, a dummy bcrypt comparison runs (constant-time), then
InvalidCredentialsErroris thrown. - Password verification — if the password is wrong,
InvalidCredentialsErroris thrown. - Account status — if
status !== 'active',AccountDisabledErroris thrown. - Email verification — if
requireVerifiedEmail: trueandemailVerified === false,EmailNotVerifiedErroris thrown. - Success —
lastLoginAtis 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 (
invalidateis 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
InvalidCredentialsErrorregardless of whether the account exists.AccountDisabledErrorandEmailNotVerifiedErrorare only reachable after a correct password. - Anti-enumeration on reset:
requestPasswordResetalways 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 includespasswordHash— 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.
