redis-otp-manager 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vijay prakash
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # redis-otp-manager
2
+
3
+ Lightweight, Redis-backed OTP manager for Node.js apps.
4
+
5
+ ## Current MVP
6
+
7
+ This first implementation includes:
8
+ - OTP generation
9
+ - OTP verification
10
+ - SHA-256 OTP hashing
11
+ - Redis-compatible storage adapter
12
+ - In-memory adapter for tests
13
+ - Intent-aware key strategy
14
+ - Rate limiting
15
+ - Max-attempt protection
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm install redis-otp-manager
21
+ ```
22
+
23
+ ## Quality Checks
24
+
25
+ ```bash
26
+ npm run check
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ ```ts
32
+ import { OTPManager, RedisAdapter } from "redis-otp-manager";
33
+
34
+ const redisClient = /* your redis client */;
35
+
36
+ const otp = new OTPManager({
37
+ store: new RedisAdapter(redisClient),
38
+ ttl: 300,
39
+ maxAttempts: 5,
40
+ rateLimit: {
41
+ window: 60,
42
+ max: 3,
43
+ },
44
+ devMode: process.env.NODE_ENV !== "production",
45
+ });
46
+
47
+ const generated = await otp.generate({
48
+ type: "email",
49
+ identifier: "abc@gmail.com",
50
+ intent: "login",
51
+ });
52
+
53
+ await otp.verify({
54
+ type: "email",
55
+ identifier: "abc@gmail.com",
56
+ intent: "login",
57
+ otp: generated.otp ?? "123456",
58
+ });
59
+ ```
60
+
61
+ ## API
62
+
63
+ ### `new OTPManager(options)`
64
+
65
+ ```ts
66
+ type OTPManagerOptions = {
67
+ store: StoreAdapter;
68
+ ttl: number;
69
+ maxAttempts: number;
70
+ rateLimit?: {
71
+ window: number;
72
+ max: number;
73
+ };
74
+ devMode?: boolean;
75
+ otpLength?: number;
76
+ };
77
+ ```
78
+
79
+ ### `generate(input)`
80
+
81
+ ```ts
82
+ const result = await otp.generate({
83
+ type: "email",
84
+ identifier: "abc@gmail.com",
85
+ intent: "login",
86
+ });
87
+ ```
88
+
89
+ Returns:
90
+
91
+ ```ts
92
+ {
93
+ expiresIn: 300,
94
+ otp?: "123456"
95
+ }
96
+ ```
97
+
98
+ ### `verify(input)`
99
+
100
+ ```ts
101
+ await otp.verify({
102
+ type: "email",
103
+ identifier: "abc@gmail.com",
104
+ intent: "login",
105
+ otp: "123456",
106
+ });
107
+ ```
108
+
109
+ Returns `true` or throws a typed error.
110
+
111
+ ## Errors
112
+
113
+ - `OTPRateLimitExceededError`
114
+ - `OTPExpiredError`
115
+ - `OTPInvalidError`
116
+ - `OTPMaxAttemptsExceededError`
117
+
118
+ ## Key Design
119
+
120
+ ```txt
121
+ otp:{intent}:{type}:{identifier}
122
+ attempts:{intent}:{type}:{identifier}
123
+ rate:{type}:{identifier}
124
+ ```
125
+
126
+ ## Next Roadmap
127
+
128
+ - NestJS module and decorator
129
+ - alphanumeric tokens
130
+ - hooks/events
131
+ - email token helpers
132
+
@@ -0,0 +1,8 @@
1
+ import type { StoreAdapter } from "../core/otp.types.js";
2
+ export declare class MemoryAdapter implements StoreAdapter {
3
+ private readonly storage;
4
+ get(key: string): Promise<string | null>;
5
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
6
+ del(key: string): Promise<void>;
7
+ increment(key: string, ttlSeconds: number): Promise<number>;
8
+ }
@@ -0,0 +1,36 @@
1
+ export class MemoryAdapter {
2
+ storage = new Map();
3
+ async get(key) {
4
+ const record = this.storage.get(key);
5
+ if (!record) {
6
+ return null;
7
+ }
8
+ if (record.expiresAt <= Date.now()) {
9
+ this.storage.delete(key);
10
+ return null;
11
+ }
12
+ return record.value;
13
+ }
14
+ async set(key, value, ttlSeconds) {
15
+ this.storage.set(key, {
16
+ value,
17
+ expiresAt: Date.now() + ttlSeconds * 1000,
18
+ });
19
+ }
20
+ async del(key) {
21
+ this.storage.delete(key);
22
+ }
23
+ async increment(key, ttlSeconds) {
24
+ const currentRecord = this.storage.get(key);
25
+ if (!currentRecord || currentRecord.expiresAt <= Date.now()) {
26
+ await this.set(key, "1", ttlSeconds);
27
+ return 1;
28
+ }
29
+ const nextValue = Number(currentRecord.value) + 1;
30
+ this.storage.set(key, {
31
+ value: String(nextValue),
32
+ expiresAt: currentRecord.expiresAt,
33
+ });
34
+ return nextValue;
35
+ }
36
+ }
@@ -0,0 +1,18 @@
1
+ import type { StoreAdapter } from "../core/otp.types.js";
2
+ export interface RedisLikeClient {
3
+ get(key: string): Promise<string | null>;
4
+ set(key: string, value: string, options?: {
5
+ EX?: number;
6
+ }): Promise<unknown>;
7
+ del(key: string): Promise<unknown>;
8
+ incr(key: string): Promise<number>;
9
+ expire(key: string, seconds: number): Promise<unknown>;
10
+ }
11
+ export declare class RedisAdapter implements StoreAdapter {
12
+ private readonly client;
13
+ constructor(client: RedisLikeClient);
14
+ get(key: string): Promise<string | null>;
15
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
16
+ del(key: string): Promise<void>;
17
+ increment(key: string, ttlSeconds: number): Promise<number>;
18
+ }
@@ -0,0 +1,22 @@
1
+ export class RedisAdapter {
2
+ client;
3
+ constructor(client) {
4
+ this.client = client;
5
+ }
6
+ async get(key) {
7
+ return this.client.get(key);
8
+ }
9
+ async set(key, value, ttlSeconds) {
10
+ await this.client.set(key, value, { EX: ttlSeconds });
11
+ }
12
+ async del(key) {
13
+ await this.client.del(key);
14
+ }
15
+ async increment(key, ttlSeconds) {
16
+ const nextValue = await this.client.incr(key);
17
+ if (nextValue === 1) {
18
+ await this.client.expire(key, ttlSeconds);
19
+ }
20
+ return nextValue;
21
+ }
22
+ }
@@ -0,0 +1 @@
1
+ export declare function generateNumericOtp(length?: number): string;
@@ -0,0 +1,11 @@
1
+ import { randomInt } from "node:crypto";
2
+ export function generateNumericOtp(length = 6) {
3
+ if (!Number.isInteger(length) || length <= 0) {
4
+ throw new TypeError("OTP length must be a positive integer.");
5
+ }
6
+ let otp = "";
7
+ for (let index = 0; index < length; index += 1) {
8
+ otp += randomInt(0, 10).toString();
9
+ }
10
+ return otp;
11
+ }
@@ -0,0 +1,2 @@
1
+ export declare function hashOtp(otp: string): string;
2
+ export declare function verifyOtpHash(otp: string, expectedHash: string): boolean;
@@ -0,0 +1,12 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ export function hashOtp(otp) {
3
+ return createHash("sha256").update(otp).digest("hex");
4
+ }
5
+ export function verifyOtpHash(otp, expectedHash) {
6
+ const actualBuffer = Buffer.from(hashOtp(otp), "hex");
7
+ const expectedBuffer = Buffer.from(expectedHash, "hex");
8
+ if (actualBuffer.length !== expectedBuffer.length) {
9
+ return false;
10
+ }
11
+ return timingSafeEqual(actualBuffer, expectedBuffer);
12
+ }
@@ -0,0 +1,7 @@
1
+ import type { GenerateOTPInput, GenerateOTPResult, OTPManagerOptions, VerifyOTPInput } from "./otp.types.js";
2
+ export declare class OTPManager {
3
+ private readonly options;
4
+ constructor(options: OTPManagerOptions);
5
+ generate(input: GenerateOTPInput): Promise<GenerateOTPResult>;
6
+ verify(input: VerifyOTPInput): Promise<true>;
7
+ }
@@ -0,0 +1,84 @@
1
+ import { generateNumericOtp } from "./otp.generator.js";
2
+ import { hashOtp, verifyOtpHash } from "./otp.hash.js";
3
+ import { OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, } from "../errors/otp.errors.js";
4
+ import { buildAttemptsKey, buildOtpKey, buildRateLimitKey, } from "../utils/key-builder.js";
5
+ import { assertWithinRateLimit } from "../utils/rate-limiter.js";
6
+ export class OTPManager {
7
+ options;
8
+ constructor(options) {
9
+ validateManagerOptions(options);
10
+ this.options = {
11
+ ...options,
12
+ otpLength: options.otpLength ?? 6,
13
+ devMode: options.devMode ?? false,
14
+ };
15
+ }
16
+ async generate(input) {
17
+ validatePayload(input);
18
+ const otpKey = buildOtpKey(input);
19
+ const attemptsKey = buildAttemptsKey(input);
20
+ const rateLimitKey = buildRateLimitKey(input);
21
+ await assertWithinRateLimit(this.options.store, rateLimitKey, this.options.rateLimit);
22
+ const otp = generateNumericOtp(this.options.otpLength);
23
+ const hashedOtp = hashOtp(otp);
24
+ await this.options.store.set(otpKey, hashedOtp, this.options.ttl);
25
+ await this.options.store.del(attemptsKey);
26
+ return {
27
+ expiresIn: this.options.ttl,
28
+ otp: this.options.devMode ? otp : undefined,
29
+ };
30
+ }
31
+ async verify(input) {
32
+ validatePayload(input);
33
+ if (!input.otp || !input.otp.trim()) {
34
+ throw new TypeError("OTP must be a non-empty string.");
35
+ }
36
+ const otpKey = buildOtpKey(input);
37
+ const attemptsKey = buildAttemptsKey(input);
38
+ const storedHash = await this.options.store.get(otpKey);
39
+ if (!storedHash) {
40
+ throw new OTPExpiredError();
41
+ }
42
+ const isValid = verifyOtpHash(input.otp, storedHash);
43
+ if (!isValid) {
44
+ const attempts = await this.options.store.increment(attemptsKey, this.options.ttl);
45
+ if (attempts >= this.options.maxAttempts) {
46
+ await this.options.store.del(otpKey);
47
+ await this.options.store.del(attemptsKey);
48
+ throw new OTPMaxAttemptsExceededError();
49
+ }
50
+ throw new OTPInvalidError();
51
+ }
52
+ await this.options.store.del(otpKey);
53
+ await this.options.store.del(attemptsKey);
54
+ return true;
55
+ }
56
+ }
57
+ function validateManagerOptions(options) {
58
+ if (!Number.isInteger(options.ttl) || options.ttl <= 0) {
59
+ throw new TypeError("ttl must be a positive integer.");
60
+ }
61
+ if (!Number.isInteger(options.maxAttempts) || options.maxAttempts <= 0) {
62
+ throw new TypeError("maxAttempts must be a positive integer.");
63
+ }
64
+ if (options.otpLength !== undefined &&
65
+ (!Number.isInteger(options.otpLength) || options.otpLength <= 0)) {
66
+ throw new TypeError("otpLength must be a positive integer when provided.");
67
+ }
68
+ if (options.rateLimit) {
69
+ if (!Number.isInteger(options.rateLimit.window) || options.rateLimit.window <= 0) {
70
+ throw new TypeError("rateLimit.window must be a positive integer.");
71
+ }
72
+ if (!Number.isInteger(options.rateLimit.max) || options.rateLimit.max <= 0) {
73
+ throw new TypeError("rateLimit.max must be a positive integer.");
74
+ }
75
+ }
76
+ }
77
+ function validatePayload(input) {
78
+ if (!input.type || !input.type.trim()) {
79
+ throw new TypeError("type must be a non-empty string.");
80
+ }
81
+ if (!input.identifier || !input.identifier.trim()) {
82
+ throw new TypeError("identifier must be a non-empty string.");
83
+ }
84
+ }
@@ -0,0 +1,33 @@
1
+ export type OTPChannel = "email" | "sms" | "token" | (string & {});
2
+ export interface OTPPayload {
3
+ type: OTPChannel;
4
+ identifier: string;
5
+ intent?: string;
6
+ }
7
+ export interface GenerateOTPInput extends OTPPayload {
8
+ }
9
+ export interface VerifyOTPInput extends OTPPayload {
10
+ otp: string;
11
+ }
12
+ export interface RateLimitConfig {
13
+ window: number;
14
+ max: number;
15
+ }
16
+ export interface OTPManagerOptions {
17
+ store: StoreAdapter;
18
+ ttl: number;
19
+ maxAttempts: number;
20
+ rateLimit?: RateLimitConfig;
21
+ devMode?: boolean;
22
+ otpLength?: number;
23
+ }
24
+ export interface GenerateOTPResult {
25
+ expiresIn: number;
26
+ otp?: string;
27
+ }
28
+ export interface StoreAdapter {
29
+ get(key: string): Promise<string | null>;
30
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
31
+ del(key: string): Promise<void>;
32
+ increment(key: string, ttlSeconds: number): Promise<number>;
33
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ export declare class OTPError extends Error {
2
+ readonly code: string;
3
+ constructor(message: string, code: string);
4
+ }
5
+ export declare class OTPRateLimitExceededError extends OTPError {
6
+ constructor(message?: string);
7
+ }
8
+ export declare class OTPExpiredError extends OTPError {
9
+ constructor(message?: string);
10
+ }
11
+ export declare class OTPInvalidError extends OTPError {
12
+ constructor(message?: string);
13
+ }
14
+ export declare class OTPMaxAttemptsExceededError extends OTPError {
15
+ constructor(message?: string);
16
+ }
@@ -0,0 +1,28 @@
1
+ export class OTPError extends Error {
2
+ code;
3
+ constructor(message, code) {
4
+ super(message);
5
+ this.name = new.target.name;
6
+ this.code = code;
7
+ }
8
+ }
9
+ export class OTPRateLimitExceededError extends OTPError {
10
+ constructor(message = "OTP request rate limit exceeded.") {
11
+ super(message, "OTP_RATE_LIMIT_EXCEEDED");
12
+ }
13
+ }
14
+ export class OTPExpiredError extends OTPError {
15
+ constructor(message = "OTP has expired or does not exist.") {
16
+ super(message, "OTP_EXPIRED");
17
+ }
18
+ }
19
+ export class OTPInvalidError extends OTPError {
20
+ constructor(message = "OTP is invalid.") {
21
+ super(message, "OTP_INVALID");
22
+ }
23
+ }
24
+ export class OTPMaxAttemptsExceededError extends OTPError {
25
+ constructor(message = "Maximum OTP verification attempts exceeded.") {
26
+ super(message, "OTP_MAX_ATTEMPTS_EXCEEDED");
27
+ }
28
+ }
@@ -0,0 +1,5 @@
1
+ export { OTPManager } from "./core/otp.service.js";
2
+ export { RedisAdapter } from "./adapters/redis.adapter.js";
3
+ export { MemoryAdapter } from "./adapters/memory.adapter.js";
4
+ export type { GenerateOTPInput, GenerateOTPResult, OTPManagerOptions, OTPPayload, RateLimitConfig, StoreAdapter, VerifyOTPInput, } from "./core/otp.types.js";
5
+ export { OTPError, OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPRateLimitExceededError, } from "./errors/otp.errors.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { OTPManager } from "./core/otp.service.js";
2
+ export { RedisAdapter } from "./adapters/redis.adapter.js";
3
+ export { MemoryAdapter } from "./adapters/memory.adapter.js";
4
+ export { OTPError, OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPRateLimitExceededError, } from "./errors/otp.errors.js";
@@ -0,0 +1,4 @@
1
+ import type { OTPPayload } from "../core/otp.types.js";
2
+ export declare function buildOtpKey(payload: OTPPayload): string;
3
+ export declare function buildAttemptsKey(payload: OTPPayload): string;
4
+ export declare function buildRateLimitKey(payload: OTPPayload): string;
@@ -0,0 +1,12 @@
1
+ function normalizeIntent(intent) {
2
+ return intent ?? "default";
3
+ }
4
+ export function buildOtpKey(payload) {
5
+ return `otp:${normalizeIntent(payload.intent)}:${payload.type}:${payload.identifier}`;
6
+ }
7
+ export function buildAttemptsKey(payload) {
8
+ return `attempts:${normalizeIntent(payload.intent)}:${payload.type}:${payload.identifier}`;
9
+ }
10
+ export function buildRateLimitKey(payload) {
11
+ return `rate:${payload.type}:${payload.identifier}`;
12
+ }
@@ -0,0 +1,2 @@
1
+ import type { RateLimitConfig, StoreAdapter } from "../core/otp.types.js";
2
+ export declare function assertWithinRateLimit(store: StoreAdapter, key: string, config?: RateLimitConfig): Promise<void>;
@@ -0,0 +1,10 @@
1
+ import { OTPRateLimitExceededError } from "../errors/otp.errors.js";
2
+ export async function assertWithinRateLimit(store, key, config) {
3
+ if (!config) {
4
+ return;
5
+ }
6
+ const nextCount = await store.increment(key, config.window);
7
+ if (nextCount > config.max) {
8
+ throw new OTPRateLimitExceededError();
9
+ }
10
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "redis-otp-manager",
3
+ "version": "0.1.0",
4
+ "description": "Lightweight, Redis-backed OTP manager for Node.js apps",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+ssh://git@github.com/prakashu51/otp-generator.git"
8
+ },
9
+ "homepage": "https://github.com/prakashu51/otp-generator#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/prakashu51/otp-generator/issues"
12
+ },
13
+ "license": "MIT",
14
+ "author": "Prakash",
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "scripts": {
30
+ "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
31
+ "build": "npm run clean && tsc -p tsconfig.json",
32
+ "test": "tsx --test test/**/*.test.ts",
33
+ "check": "npm run build && npm run test",
34
+ "prepublishOnly": "npm run build"
35
+ },
36
+ "keywords": [
37
+ "otp",
38
+ "redis",
39
+ "nodejs",
40
+ "authentication",
41
+ "security",
42
+ "verification",
43
+ "one-time-password"
44
+ ],
45
+ "engines": {
46
+ "node": ">=18"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "peerDependencies": {
52
+ "redis": "^4.0.0 || ^5.0.0"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "redis": {
56
+ "optional": true
57
+ }
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "^24.0.0",
61
+ "tsx": "^4.19.0",
62
+ "typescript": "^5.8.0"
63
+ }
64
+ }
65
+