otp-toolkit
Version:
Secure, pluggable OTP generation and validation toolkit for Node.js (TypeScript ready).
140 lines (119 loc) • 3.87 kB
text/typescript
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,
};