@otp-service/redis-store 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 +21 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +105 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 otp-service contributors
|
|
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/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ChallengeStore } from '@otp-service/core';
|
|
2
|
+
|
|
3
|
+
interface RedisStoreClient {
|
|
4
|
+
del(key: string): Promise<number>;
|
|
5
|
+
get(key: string): Promise<string | null>;
|
|
6
|
+
set(key: string, value: string, options: {
|
|
7
|
+
expiration: {
|
|
8
|
+
type: "EX";
|
|
9
|
+
value: number;
|
|
10
|
+
};
|
|
11
|
+
}): Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
interface CreateRedisChallengeStoreOptions {
|
|
14
|
+
client: RedisStoreClient;
|
|
15
|
+
clock?: () => Date;
|
|
16
|
+
keyPrefix?: string;
|
|
17
|
+
}
|
|
18
|
+
declare function createRedisChallengeStore(options: CreateRedisChallengeStoreOptions): ChallengeStore;
|
|
19
|
+
|
|
20
|
+
export { type CreateRedisChallengeStoreOptions, type RedisStoreClient, createRedisChallengeStore };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
function createRedisChallengeStore(options) {
|
|
3
|
+
const clock = options.clock ?? (() => /* @__PURE__ */ new Date());
|
|
4
|
+
const keyPrefix = options.keyPrefix ?? "otp:challenge";
|
|
5
|
+
return {
|
|
6
|
+
async create(record) {
|
|
7
|
+
await options.client.set(toRedisKey(keyPrefix, record.challengeId), serializeRecord(record), {
|
|
8
|
+
expiration: {
|
|
9
|
+
type: "EX",
|
|
10
|
+
value: ttlSecondsFrom(record.expiresAt, clock)
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
},
|
|
14
|
+
async delete(challengeId) {
|
|
15
|
+
await options.client.del(toRedisKey(keyPrefix, challengeId));
|
|
16
|
+
},
|
|
17
|
+
async get(challengeId) {
|
|
18
|
+
const value = await options.client.get(toRedisKey(keyPrefix, challengeId));
|
|
19
|
+
if (value === null) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return deserializeRecord(value);
|
|
23
|
+
},
|
|
24
|
+
async update(record) {
|
|
25
|
+
const ttlSeconds = ttlSecondsFrom(record.expiresAt, clock);
|
|
26
|
+
if (ttlSeconds <= 0) {
|
|
27
|
+
await options.client.del(toRedisKey(keyPrefix, record.challengeId));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
await options.client.set(toRedisKey(keyPrefix, record.challengeId), serializeRecord(record), {
|
|
31
|
+
expiration: {
|
|
32
|
+
type: "EX",
|
|
33
|
+
value: ttlSeconds
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function deserializeRecord(value) {
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(value);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error("Redis challenge record contains invalid JSON.");
|
|
45
|
+
}
|
|
46
|
+
assertSerializedChallengeRecord(parsed);
|
|
47
|
+
return {
|
|
48
|
+
attemptsRemaining: parsed.attemptsRemaining,
|
|
49
|
+
channel: parsed.channel,
|
|
50
|
+
challengeId: parsed.challengeId,
|
|
51
|
+
createdAt: new Date(parsed.createdAt),
|
|
52
|
+
expiresAt: new Date(parsed.expiresAt),
|
|
53
|
+
otpHash: parsed.otpHash,
|
|
54
|
+
purpose: parsed.purpose,
|
|
55
|
+
recipient: parsed.recipient
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function assertSerializedChallengeRecord(value) {
|
|
59
|
+
if (typeof value !== "object" || value === null) {
|
|
60
|
+
throw new Error("Redis challenge record must be an object.");
|
|
61
|
+
}
|
|
62
|
+
const record = value;
|
|
63
|
+
if (!Number.isInteger(record.attemptsRemaining)) {
|
|
64
|
+
throw new Error("Redis challenge record attemptsRemaining must be an integer.");
|
|
65
|
+
}
|
|
66
|
+
if (record.channel !== "sms" && record.channel !== "email") {
|
|
67
|
+
throw new Error("Redis challenge record channel is invalid.");
|
|
68
|
+
}
|
|
69
|
+
const stringFields = ["challengeId", "createdAt", "expiresAt", "otpHash", "purpose", "recipient"];
|
|
70
|
+
for (const field of stringFields) {
|
|
71
|
+
const fieldValue = record[field];
|
|
72
|
+
if (typeof fieldValue !== "string" || fieldValue.trim().length === 0) {
|
|
73
|
+
throw new Error(`Redis challenge record ${field} must be a non-empty string.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (Number.isNaN(new Date(record.createdAt).getTime())) {
|
|
77
|
+
throw new Error("Redis challenge record createdAt must be a valid ISO date string.");
|
|
78
|
+
}
|
|
79
|
+
if (Number.isNaN(new Date(record.expiresAt).getTime())) {
|
|
80
|
+
throw new Error("Redis challenge record expiresAt must be a valid ISO date string.");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function serializeRecord(record) {
|
|
84
|
+
return JSON.stringify({
|
|
85
|
+
attemptsRemaining: record.attemptsRemaining,
|
|
86
|
+
channel: record.channel,
|
|
87
|
+
challengeId: record.challengeId,
|
|
88
|
+
createdAt: record.createdAt.toISOString(),
|
|
89
|
+
expiresAt: record.expiresAt.toISOString(),
|
|
90
|
+
otpHash: record.otpHash,
|
|
91
|
+
purpose: record.purpose,
|
|
92
|
+
recipient: record.recipient
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function toRedisKey(keyPrefix, challengeId) {
|
|
96
|
+
return `${keyPrefix}:${challengeId}`;
|
|
97
|
+
}
|
|
98
|
+
function ttlSecondsFrom(expiresAt, clock) {
|
|
99
|
+
const millisecondsRemaining = expiresAt.getTime() - clock().getTime();
|
|
100
|
+
return Math.max(0, Math.ceil(millisecondsRemaining / 1e3));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { createRedisChallengeStore };
|
|
104
|
+
//# sourceMappingURL=index.js.map
|
|
105
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { ChallengeRecord, ChallengeStore, OtpChannel } from \"@otp-service/core\";\n\nexport interface RedisStoreClient {\n del(key: string): Promise<number>;\n get(key: string): Promise<string | null>;\n set(key: string, value: string, options: { expiration: { type: \"EX\"; value: number } }): Promise<unknown>;\n}\n\nexport interface CreateRedisChallengeStoreOptions {\n client: RedisStoreClient;\n clock?: () => Date;\n keyPrefix?: string;\n}\n\ninterface SerializedChallengeRecord {\n attemptsRemaining: number;\n channel: OtpChannel;\n challengeId: string;\n createdAt: string;\n expiresAt: string;\n otpHash: string;\n purpose: string;\n recipient: string;\n}\n\nexport function createRedisChallengeStore(\n options: CreateRedisChallengeStoreOptions\n): ChallengeStore {\n const clock = options.clock ?? (() => new Date());\n const keyPrefix = options.keyPrefix ?? \"otp:challenge\";\n\n return {\n async create(record) {\n await options.client.set(toRedisKey(keyPrefix, record.challengeId), serializeRecord(record), {\n expiration: {\n type: \"EX\",\n value: ttlSecondsFrom(record.expiresAt, clock)\n }\n });\n },\n\n async delete(challengeId) {\n await options.client.del(toRedisKey(keyPrefix, challengeId));\n },\n\n async get(challengeId) {\n const value = await options.client.get(toRedisKey(keyPrefix, challengeId));\n if (value === null) {\n return null;\n }\n\n return deserializeRecord(value);\n },\n\n async update(record) {\n const ttlSeconds = ttlSecondsFrom(record.expiresAt, clock);\n if (ttlSeconds <= 0) {\n await options.client.del(toRedisKey(keyPrefix, record.challengeId));\n return;\n }\n\n await options.client.set(toRedisKey(keyPrefix, record.challengeId), serializeRecord(record), {\n expiration: {\n type: \"EX\",\n value: ttlSeconds\n }\n });\n }\n };\n}\n\nfunction deserializeRecord(value: string): ChallengeRecord {\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(value);\n } catch {\n throw new Error(\"Redis challenge record contains invalid JSON.\");\n }\n\n assertSerializedChallengeRecord(parsed);\n\n return {\n attemptsRemaining: parsed.attemptsRemaining,\n channel: parsed.channel,\n challengeId: parsed.challengeId,\n createdAt: new Date(parsed.createdAt),\n expiresAt: new Date(parsed.expiresAt),\n otpHash: parsed.otpHash,\n purpose: parsed.purpose,\n recipient: parsed.recipient\n };\n}\n\nfunction assertSerializedChallengeRecord(value: unknown): asserts value is SerializedChallengeRecord {\n if (typeof value !== \"object\" || value === null) {\n throw new Error(\"Redis challenge record must be an object.\");\n }\n\n const record = value as Record<string, unknown>;\n\n if (!Number.isInteger(record.attemptsRemaining)) {\n throw new Error(\"Redis challenge record attemptsRemaining must be an integer.\");\n }\n\n if (record.channel !== \"sms\" && record.channel !== \"email\") {\n throw new Error(\"Redis challenge record channel is invalid.\");\n }\n\n const stringFields = [\"challengeId\", \"createdAt\", \"expiresAt\", \"otpHash\", \"purpose\", \"recipient\"] as const;\n\n for (const field of stringFields) {\n const fieldValue = record[field];\n if (typeof fieldValue !== \"string\" || fieldValue.trim().length === 0) {\n throw new Error(`Redis challenge record ${field} must be a non-empty string.`);\n }\n }\n\n if (Number.isNaN(new Date(record.createdAt as string).getTime())) {\n throw new Error(\"Redis challenge record createdAt must be a valid ISO date string.\");\n }\n\n if (Number.isNaN(new Date(record.expiresAt as string).getTime())) {\n throw new Error(\"Redis challenge record expiresAt must be a valid ISO date string.\");\n }\n}\n\nfunction serializeRecord(record: ChallengeRecord): string {\n return JSON.stringify({\n attemptsRemaining: record.attemptsRemaining,\n channel: record.channel,\n challengeId: record.challengeId,\n createdAt: record.createdAt.toISOString(),\n expiresAt: record.expiresAt.toISOString(),\n otpHash: record.otpHash,\n purpose: record.purpose,\n recipient: record.recipient\n } satisfies SerializedChallengeRecord);\n}\n\nfunction toRedisKey(keyPrefix: string, challengeId: string): string {\n return `${keyPrefix}:${challengeId}`;\n}\n\nfunction ttlSecondsFrom(expiresAt: Date, clock: () => Date): number {\n const millisecondsRemaining = expiresAt.getTime() - clock().getTime();\n return Math.max(0, Math.ceil(millisecondsRemaining / 1000));\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@otp-service/redis-store",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Redis-backed challenge state storage for OTP services.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Suraj-H/otp-service-package-v2.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/Suraj-H/otp-service-package-v2/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/Suraj-H/otp-service-package-v2/tree/main/packages/redis-store#readme",
|
|
15
|
+
"sideEffects": false,
|
|
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
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.0.0"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@otp-service/core": "0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup --config tsup.config.ts",
|
|
38
|
+
"clean": "rm -rf dist",
|
|
39
|
+
"lint": "eslint src test",
|
|
40
|
+
"test": "vitest run --config vitest.config.ts",
|
|
41
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
42
|
+
}
|
|
43
|
+
}
|