From e06813d40592703358732f3e04fc147dbb182a7f Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Wed, 13 May 2026 18:20:51 -0400 Subject: [PATCH] fix: make otplib imports ESM-safe --- BUILD.bazel | 2 +- CHANGELOG.md | 7 ++ MODULE.bazel | 2 +- package.json | 4 +- src/core/totp/index.ts | 101 ++++++++++---------------- src/modules/invitation/index.ts | 124 ++++++++++++-------------------- src/totp/compat.ts | 83 ++++++--------------- 7 files changed, 113 insertions(+), 210 deletions(-) diff --git a/BUILD.bazel b/BUILD.bazel index 159771f..1913315 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -90,5 +90,5 @@ npm_package( ":package.json", ], package = "@tummycrypt/tinyland-auth", - version = "0.3.0", + version = "0.3.1", ) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a520d3..af0f488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # @tummycrypt/tinyland-auth +## 0.3.1 + +### Patch Changes + +- Fix Node ESM consumption of the TOTP and invitation exports by importing the + CommonJS `otplib` package through its default namespace. + ## 0.2.2 ### Patch Changes diff --git a/MODULE.bazel b/MODULE.bazel index 1c948fe..4458bad 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,6 +1,6 @@ module( name = "tummycrypt_tinyland_auth", - version = "0.3.0", + version = "0.3.1", compatibility_level = 1, ) diff --git a/package.json b/package.json index 1951b88..c568368 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tummycrypt/tinyland-auth", - "version": "0.3.0", + "version": "0.3.1", "packageManager": "pnpm@10.13.1", "description": "Production-grade authentication system with TOTP, RBAC, and pluggable storage", "type": "module", @@ -113,4 +113,4 @@ "access": "public", "provenance": true } -} \ No newline at end of file +} diff --git a/src/core/totp/index.ts b/src/core/totp/index.ts index 731702d..aa9d646 100644 --- a/src/core/totp/index.ts +++ b/src/core/totp/index.ts @@ -1,34 +1,34 @@ - - - - - - - - -import { authenticator } from 'otplib'; -import * as qrcode from 'qrcode'; -import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'; -import type { TOTPSecret, EncryptedData, TOTPConfig } from '../../types/index.js'; -import { timingSafeVerify } from '../security/index.js'; - +import otplib from "otplib"; +import * as qrcode from "qrcode"; +import { + createCipheriv, + createDecipheriv, + randomBytes, + scryptSync, +} from "crypto"; +import type { + TOTPSecret, + EncryptedData, + TOTPConfig, +} from "../../types/index.js"; +import { timingSafeVerify } from "../security/index.js"; + +const { authenticator } = otplib; authenticator.options = { window: 1 }; - -const ALGORITHM = 'aes-256-gcm'; +const ALGORITHM = "aes-256-gcm"; const SALT_LENGTH = 32; const IV_LENGTH = 16; const KEY_LENGTH = 32; export interface TOTPServiceConfig { - encryptionKey: string; - + issuer: string; - + devMode?: boolean; - + testCode?: string; } @@ -45,9 +45,6 @@ export class TOTPService { this.testCode = config.testCode; } - - - async generateSecret(handle: string, email: string): Promise { const secret = authenticator.generateSecret(); const otpauth = authenticator.keyuri(email, this.issuer, secret); @@ -62,9 +59,6 @@ export class TOTPService { }; } - - - encrypt(text: string): EncryptedData { const salt = randomBytes(SALT_LENGTH); const key = scryptSync(this.encryptionKey, salt, KEY_LENGTH); @@ -72,29 +66,26 @@ export class TOTPService { const cipher = createCipheriv(ALGORITHM, key, iv); const encrypted = Buffer.concat([ - cipher.update(text, 'utf8'), + cipher.update(text, "utf8"), cipher.final(), ]); const tag = cipher.getAuthTag(); return { - encrypted: encrypted.toString('base64'), - salt: salt.toString('base64'), - iv: iv.toString('base64'), - tag: tag.toString('base64'), + encrypted: encrypted.toString("base64"), + salt: salt.toString("base64"), + iv: iv.toString("base64"), + tag: tag.toString("base64"), }; } - - - decrypt(encryptedData: EncryptedData): string { - const salt = Buffer.from(encryptedData.salt, 'base64'); + const salt = Buffer.from(encryptedData.salt, "base64"); const key = scryptSync(this.encryptionKey, salt, KEY_LENGTH); - const iv = Buffer.from(encryptedData.iv, 'base64'); - const tag = Buffer.from(encryptedData.tag, 'base64'); - const encrypted = Buffer.from(encryptedData.encrypted, 'base64'); + const iv = Buffer.from(encryptedData.iv, "base64"); + const tag = Buffer.from(encryptedData.tag, "base64"); + const encrypted = Buffer.from(encryptedData.encrypted, "base64"); const decipher = createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(tag); @@ -104,27 +95,22 @@ export class TOTPService { decipher.final(), ]); - return decrypted.toString('utf8'); + return decrypted.toString("utf8"); } - - - async verifyToken( secretOrNull: TOTPSecret | null, - token: string + token: string, ): Promise { - const cleanToken = token.replace(/\s/g, ''); + const cleanToken = token.replace(/\s/g, ""); - if (this.devMode && this.testCode && cleanToken === this.testCode) { return true; } return await timingSafeVerify(async () => { if (!secretOrNull) { - - const dummySecret = 'JBSWY3DPEHPK3PXP'; + const dummySecret = "JBSWY3DPEHPK3PXP"; authenticator.verify({ token: cleanToken, secret: dummySecret }); return false; } @@ -136,40 +122,29 @@ export class TOTPService { }, 150); } - - - generateToken(secret: TOTPSecret): string { return authenticator.generate(secret.secret); } - - - async generateQRCode(secret: TOTPSecret): Promise { - const otpauth = authenticator.keyuri(secret.email, this.issuer, secret.secret); + const otpauth = authenticator.keyuri( + secret.email, + this.issuer, + secret.secret, + ); return await qrcode.toDataURL(otpauth); } - - - encryptBackupCodes(codes: string[]): EncryptedData { return this.encrypt(JSON.stringify(codes)); } - - - decryptBackupCodes(encryptedData: EncryptedData): string[] { const json = this.decrypt(encryptedData); return JSON.parse(json); } } - - - export function createTOTPService(config: TOTPConfig): TOTPService { return new TOTPService({ encryptionKey: config.encryptionKey, diff --git a/src/modules/invitation/index.ts b/src/modules/invitation/index.ts index f499d26..5ad249a 100644 --- a/src/modules/invitation/index.ts +++ b/src/modules/invitation/index.ts @@ -1,41 +1,37 @@ - - - - - - - - -import { randomBytes } from 'crypto'; -import { authenticator } from 'otplib'; -import * as qrcode from 'qrcode'; -import type { AdminInvitation, AdminRole, InvitationConfig } from '../../types/index.js'; -import type { InvitationStorage } from '../../storage/interface.js'; -import { canManageRole } from '../../core/permissions/index.js'; +import { randomBytes } from "crypto"; +import otplib from "otplib"; +import * as qrcode from "qrcode"; +import type { + AdminInvitation, + AdminRole, + InvitationConfig, +} from "../../types/index.js"; +import type { InvitationStorage } from "../../storage/interface.js"; +import { canManageRole } from "../../core/permissions/index.js"; + +const { authenticator } = otplib; export interface InvitationServiceConfig { - storage: InvitationStorage; - + config: InvitationConfig; - + baseUrl: string; - + totpIssuer?: string; } export interface CreateInvitationOptions { - role: AdminRole; - + createdBy: string; - + createdByHandle: string; - + expiresInHours?: number; - + message?: string; - + email?: string; } @@ -48,9 +44,6 @@ export interface CreateInvitationResult { error?: string; } - - - export class InvitationService { private storage: InvitationStorage; private config: InvitationConfig; @@ -61,29 +54,25 @@ export class InvitationService { this.storage = serviceConfig.storage; this.config = serviceConfig.config; this.baseUrl = serviceConfig.baseUrl; - this.totpIssuer = serviceConfig.totpIssuer || 'Tinyland.dev'; + this.totpIssuer = serviceConfig.totpIssuer || "Tinyland.dev"; } - - - - async createInvitation(options: CreateInvitationOptions): Promise { + async createInvitation( + options: CreateInvitationOptions, + ): Promise { try { - - const token = randomBytes(32).toString('hex'); + const token = randomBytes(32).toString("hex"); - const totpSecret = authenticator.generateSecret(); - - const expiresInHours = options.expiresInHours || this.config.defaultExpiryHours; + const expiresInHours = + options.expiresInHours || this.config.defaultExpiryHours; const expiresAt = new Date(); expiresAt.setHours(expiresAt.getHours() + expiresInHours); - - const invitationData: Omit = { + const invitationData: Omit = { token, - email: options.email || '', + email: options.email || "", role: options.role, createdBy: options.createdBy, createdAt: new Date().toISOString(), @@ -96,18 +85,15 @@ export class InvitationService { }, }; - const invitation = await this.storage.createInvitation(invitationData); - const otpauth = authenticator.keyuri( `invite-${invitation.id}`, `${this.totpIssuer} (Invite)`, - totpSecret + totpSecret, ); const qrCode = await qrcode.toDataURL(otpauth); - const inviteUrl = `${this.baseUrl}/admin/accept-invite?token=${token}`; return { @@ -118,28 +104,23 @@ export class InvitationService { qrCode, }; } catch (error) { - console.error('[InvitationService] Create error:', error); + console.error("[InvitationService] Create error:", error); return { success: false, - error: 'Failed to create invitation', + error: "Failed to create invitation", }; } } - - - async getInvitation(token: string): Promise { const invitation = await this.storage.getInvitation(token); if (!invitation) return null; - if (new Date(invitation.expiresAt) < new Date()) { return null; } - if (invitation.usedAt) { return null; } @@ -147,9 +128,6 @@ export class InvitationService { return invitation; } - - - async markAsUsed(token: string, usedBy: string): Promise { try { await this.storage.updateInvitation(token, { @@ -163,23 +141,14 @@ export class InvitationService { } } - - - async revokeInvitation(token: string): Promise { return this.storage.deleteInvitation(token); } - - - async listPendingInvitations(): Promise { return this.storage.getPendingInvitations(); } - - - async getStatistics(): Promise<{ total: number; pending: number; @@ -191,23 +160,22 @@ export class InvitationService { return { total: all.length, - pending: all.filter(i => new Date(i.expiresAt) > now && !i.usedAt).length, - expired: all.filter(i => new Date(i.expiresAt) <= now && !i.usedAt).length, - used: all.filter(i => i.usedAt).length, + pending: all.filter((i) => new Date(i.expiresAt) > now && !i.usedAt) + .length, + expired: all.filter((i) => new Date(i.expiresAt) <= now && !i.usedAt) + .length, + used: all.filter((i) => i.usedAt).length, }; } - - - async cleanupExpired(): Promise { return this.storage.cleanupExpiredInvitations(); } - - - - async extendInvitation(token: string, additionalHours: number): Promise { + async extendInvitation( + token: string, + additionalHours: number, + ): Promise { const invitation = await this.storage.getInvitation(token); if (!invitation || invitation.usedAt) return false; @@ -224,17 +192,13 @@ export class InvitationService { } } - - - canInviteForRole(creatorRole: AdminRole, targetRole: AdminRole): boolean { return canManageRole(creatorRole, targetRole); } } - - - -export function createInvitationService(config: InvitationServiceConfig): InvitationService { +export function createInvitationService( + config: InvitationServiceConfig, +): InvitationService { return new InvitationService(config); } diff --git a/src/totp/compat.ts b/src/totp/compat.ts index a355718..c3f4596 100644 --- a/src/totp/compat.ts +++ b/src/totp/compat.ts @@ -1,16 +1,8 @@ +import otplib from "otplib"; +import * as crypto from "crypto"; +import * as QRCode from "qrcode"; - - - - - - - - -import { authenticator } from 'otplib'; -import * as crypto from 'crypto'; -import * as QRCode from 'qrcode'; - +const { authenticator } = otplib; authenticator.options = { step: 30, @@ -18,123 +10,88 @@ authenticator.options = { digits: 6, }; - - - - export function generateTOTPSecret(): string { try { const secret = authenticator.generateSecret(); return secret; } catch (_error) { - throw new Error('Failed to generate secure TOTP secret'); + throw new Error("Failed to generate secure TOTP secret"); } } - - - - - - - -export function generateTOTPUri(secret: string, issuer: string, label: string): string { +export function generateTOTPUri( + secret: string, + issuer: string, + label: string, +): string { if (!secret || !issuer || !label) { - throw new Error('Secret, issuer, and label are required'); + throw new Error("Secret, issuer, and label are required"); } - if (!/^[A-Z2-7]+=*$/i.test(secret)) { - throw new Error('Invalid base32 secret'); + throw new Error("Invalid base32 secret"); } - const encodedLabel = encodeURIComponent(label); const encodedIssuer = encodeURIComponent(issuer); - const uri = `otpauth://totp/${encodedLabel}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`; return uri; } - - - - - export function generateTempPassword(length: number = 8): string { if (length < 8) { - throw new Error('Password must be at least 8 characters'); + throw new Error("Password must be at least 8 characters"); } - - - const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'; + const charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"; const charsetLength = charset.length; - let password = ''; + let password = ""; - for (let i = 0; i < length; i++) { const randomIndex = crypto.randomInt(0, charsetLength); password += charset[randomIndex]; } - const hasUpper = /[A-Z]/.test(password); const hasLower = /[a-z]/.test(password); const hasDigit = /[0-9]/.test(password); if (!hasUpper || !hasLower || !hasDigit) { - return generateTempPassword(length); } return password; } - - - - - export async function generateTOTPQRCode(uri: string): Promise { try { - const qrCodeDataUrl = await QRCode.toDataURL(uri, { - errorCorrectionLevel: 'M', + errorCorrectionLevel: "M", margin: 4, width: 256, color: { - dark: '#000000', - light: '#FFFFFF', + dark: "#000000", + light: "#FFFFFF", }, }); return qrCodeDataUrl; } catch (_error) { - throw new Error('Failed to generate QR code'); + throw new Error("Failed to generate QR code"); } } - - - - - export function generateTOTPToken(secret: string): string { if (!secret || !/^[A-Z2-7]+=*$/i.test(secret)) { - throw new Error('Invalid base32 secret'); + throw new Error("Invalid base32 secret"); } return authenticator.generate(secret); } - - - - export function getTOTPTimeRemaining(): number { const step = authenticator.options.step || 30; const now = Math.floor(Date.now() / 1000);