UNPKG

otp-toolkit

Version:

Secure, pluggable OTP generation and validation toolkit for Node.js (TypeScript ready).

140 lines (119 loc) 3.87 kB
import crypto from "crypto"; import { InMemoryOtpStore } from "./store"; import { Charset, GenerateOptions, GenerateResult, OtpRecord, OtpStore, ValidateResult, } from "./types"; // ---- helpers ---- function randomString(length: number, charset: Charset): string { const sets = { numeric: "0123456789", alphanumeric: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", hex: "0123456789abcdef", } as const; const chars = sets[charset]; const bytes = crypto.randomBytes(length); let out = ""; for (let i = 0; i < length; i++) { out += chars[bytes[i] % chars.length]; } return out; } function hashWithSalt(code: string, salt: string): string { // PBKDF2 for stronger hashing (avoid plain SHA) return crypto .pbkdf2Sync(code, salt, 150_000, 32, "sha256") .toString("hex"); } export interface OtpToolkitConfig { store?: OtpStore; // custom store (e.g., Redis) defaultExpirySeconds?: number; // default 300 defaultLength?: number; // default 6 defaultCharset?: Charset; // default "numeric" } export class OtpToolkit { private store: OtpStore; private defaultExpirySeconds: number; private defaultLength: number; private defaultCharset: Charset; constructor(config: OtpToolkitConfig = {}) { this.store = config.store ?? new InMemoryOtpStore(); this.defaultExpirySeconds = config.defaultExpirySeconds ?? 300; this.defaultLength = config.defaultLength ?? 6; this.defaultCharset = config.defaultCharset ?? "numeric"; } /** * Generate an OTP and persist a hashed record in the store. */ async generate(options: GenerateOptions = {}): Promise<GenerateResult> { const length = options.length ?? this.defaultLength; const expirySeconds = options.expirySeconds ?? this.defaultExpirySeconds; const charset = options.charset ?? this.defaultCharset; if (length < 4 || length > 12) { throw new Error("length must be between 4 and 12"); } const code = randomString(length, charset); const salt = crypto.randomBytes(16).toString("hex"); const hash = hashWithSalt(code, salt); const token = crypto.randomBytes(16).toString("hex"); const expiresAt = Date.now() + expirySeconds * 1000; const record: OtpRecord = { id: token, hash, salt, expiresAt, consumed: false, metadata: options.metadata, }; await this.store.save(record); return { code, token, expiresAt }; } /** * Validate an OTP against a token; consumes on success. */ async validate(code: string, token: string): Promise<ValidateResult> { const rec = await this.store.get(token); if (!rec) return { valid: false, reason: "not_found" }; const now = Date.now(); if (rec.expiresAt <= now) { await this.store.delete(token); return { valid: false, reason: "expired", metadata: rec.metadata }; } if (rec.consumed) { return { valid: false, reason: "consumed", metadata: rec.metadata }; } const candidateHash = hashWithSalt(code, rec.salt); if (crypto.timingSafeEqual(Buffer.from(candidateHash, "hex"), Buffer.from(rec.hash, "hex"))) { await this.store.consume(token); // one-time return { valid: true, metadata: rec.metadata }; } return { valid: false, reason: "invalid", metadata: rec.metadata }; } /** * Manually invalidate an OTP (e.g., user retries or cancels). */ async invalidate(token: string): Promise<void> { await this.store.delete(token); } /** * Expose store cleanup for cron jobs (optional). */ async cleanup(): Promise<void> { if (this.store.cleanup) { await this.store.cleanup(Date.now()); } } } // Named exports for convenience export { InMemoryOtpStore }; export type { OtpStore, GenerateOptions, GenerateResult, ValidateResult, Charset, };