@exortek/challenge 1.0.0 → 1.0.2
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/CHANGELOG.md +35 -25
- package/dist/errors.d.ts +3 -13
- package/dist/index.d.ts +115 -129
- package/dist/internal/guards.d.ts +5 -8
- package/dist/stores/index.d.ts +2 -16
- package/dist/stores/memory.d.ts +11 -38
- package/dist/stores/redis.d.ts +7 -22
- package/dist/stores.cjs +203 -148
- package/dist/stores.mjs +203 -148
- package/dist/token.d.ts +6 -25
- package/package.json +2 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,31 +1,41 @@
|
|
|
1
1
|
# @exortek/challenge
|
|
2
2
|
|
|
3
|
+
## 1.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 689cf87: Remove workspace-only `devDependencies` from published package.json — `@exortek/shared` no longer appears as
|
|
8
|
+
`"0.0.0"` on the npm registry.
|
|
9
|
+
|
|
10
|
+
## 1.0.1
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 31223e4: Consolidate duplicated store internals into @exortek/shared utilities (redis-helpers, incr-store,
|
|
15
|
+
record-store). No public API changes.
|
|
16
|
+
|
|
17
|
+
apikey: fix Redis store race condition where a concurrent update() could silently un-revoke a key — revocations now
|
|
18
|
+
use a tombstone key that update() never touches.
|
|
19
|
+
|
|
20
|
+
apikey: fix memory store put() storing by reference instead of copying — now consistent with getById()'s copy-on-read
|
|
21
|
+
contract.
|
|
22
|
+
|
|
3
23
|
## 1.0.0
|
|
4
24
|
|
|
5
25
|
### Initial release
|
|
6
26
|
|
|
7
|
-
- **`createChallenge(options)`** — issue an HMAC-signed challenge token
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
`
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
`@exortek/security`'s rate-limit stores.
|
|
23
|
-
- **Token format:** `<prefix>.<base64url(payload)>.<base64url(hmac)>`
|
|
24
|
-
— deliberately not a JWT so the two token families cannot be confused
|
|
25
|
-
at a call site. Prefix defaults to `chall_v1`; callers can override
|
|
26
|
-
via `options.prefix` (e.g. `'server_challenge'`, `'myapp_v1'`) to
|
|
27
|
-
brand the wire format for their service. Must match
|
|
28
|
-
`/^[A-Za-z0-9_-]{1,32}$/`, and the same prefix must be used at create
|
|
29
|
-
and verify time.
|
|
30
|
-
- **Errors:** stable `ErrorCode.INVALID_ARGUMENT` /
|
|
31
|
-
`ErrorCode.INVALID_SECRET` codes on the `ChallengeError` class.
|
|
27
|
+
- **`createChallenge(options)`** — issue an HMAC-signed challenge token carrying `userId` / `method` / `step` /
|
|
28
|
+
`nextStep` / `metadata` across a multi-step auth flow. Optional `ipBinding` stamps the origin IP into the payload;
|
|
29
|
+
optional `singleUse` marks the token for one-shot consumption via a caller-supplied store.
|
|
30
|
+
- **`verifyChallenge(token, options)`** — HMAC-verify, expiry-check, and optionally match `expectedUserId` /
|
|
31
|
+
`expectedMethod` / `expectedStep` / `expectedNextStep`, plus IP match when the token was IP-bound. Returns
|
|
32
|
+
`{ valid: true, payload }` on success or `{ valid: false, reason }` on any expected failure; never throws on bad
|
|
33
|
+
tokens, only on programmer errors.
|
|
34
|
+
- **Stores** — ships `memoryStore()` (single-node / dev, LRU + TTL sweep) and `redisStore(client)` (cluster-safe, single
|
|
35
|
+
Lua round-trip per verify) under the `@exortek/challenge/stores` subpath. Any object exposing
|
|
36
|
+
`incr(key, ttlMs) → { count }` also works — e.g. `@exortek/security`'s rate-limit stores.
|
|
37
|
+
- **Token format:** `<prefix>.<base64url(payload)>.<base64url(hmac)>` — deliberately not a JWT so the two token families
|
|
38
|
+
cannot be confused at a call site. Prefix defaults to `chall_v1`; callers can override via `options.prefix` (e.g.
|
|
39
|
+
`'server_challenge'`, `'myapp_v1'`) to brand the wire format for their service. Must match `/^[A-Za-z0-9_-]{1,32}$/`,
|
|
40
|
+
and the same prefix must be used at create and verify time.
|
|
41
|
+
- **Errors:** stable `ErrorCode.INVALID_ARGUMENT` / `ErrorCode.INVALID_SECRET` codes on the `ChallengeError` class.
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Stable machine-readable codes for every programmer-error failure that
|
|
3
|
-
* `@exortek/challenge` can raise. Branch on `code`, never on the
|
|
4
|
-
* message. Note: expected verify failures (bad signature, expired,
|
|
5
|
-
* mismatched claim) are NOT thrown — `verifyChallenge` returns
|
|
6
|
-
* `{ valid: false, reason }` for those so a wrong or stale token is a
|
|
7
|
-
* normal auth outcome, not an exception. Errors below fire only when
|
|
8
|
-
* the caller configured something wrong.
|
|
9
|
-
*/
|
|
10
|
-
import { BaseError } from '@exortek/shared/errors';
|
|
11
|
-
export declare const ErrorCode: Readonly<{
|
|
1
|
+
export const ErrorCode: Readonly<{
|
|
12
2
|
INVALID_ARGUMENT: "INVALID_ARGUMENT";
|
|
13
3
|
INVALID_SECRET: "INVALID_SECRET";
|
|
14
4
|
}>;
|
|
@@ -17,10 +7,10 @@ export declare const ErrorCode: Readonly<{
|
|
|
17
7
|
* `code` (from {@link ErrorCode}) and a `status` — the HTTP response
|
|
18
8
|
* status a middleware layer would use when translating the error.
|
|
19
9
|
*/
|
|
20
|
-
export
|
|
10
|
+
export class ChallengeError extends BaseError {
|
|
21
11
|
static statuses: {
|
|
22
12
|
INVALID_ARGUMENT: number;
|
|
23
13
|
INVALID_SECRET: number;
|
|
24
14
|
};
|
|
25
|
-
static defaultStatus: number;
|
|
26
15
|
}
|
|
16
|
+
import { BaseError } from '@exortek/shared/errors';
|
package/dist/index.d.ts
CHANGED
|
@@ -1,130 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `@exortek/challenge` — signed, single-use challenge tokens for
|
|
3
|
-
* multi-step auth flows.
|
|
4
|
-
*
|
|
5
|
-
* A challenge is a small, HMAC-signed envelope that carries context
|
|
6
|
-
* across a redirect or a follow-up request without a server-side
|
|
7
|
-
* session: who is being challenged (`userId`), how they proved
|
|
8
|
-
* themselves so far (`method`), what step of the flow they've cleared
|
|
9
|
-
* (`step`), and any bespoke metadata the caller needs on the other
|
|
10
|
-
* side. The token is stateless by default; single-use enforcement and
|
|
11
|
-
* revocation are opt-in via a store the caller supplies.
|
|
12
|
-
*
|
|
13
|
-
* See the README for the full API and worked examples.
|
|
14
|
-
*/
|
|
15
|
-
export { ChallengeError, ErrorCode } from './errors.js';
|
|
16
|
-
export type ChallengeMethod = 'totp' | 'hotp' | 'email_otp' | 'sms_otp' | 'backup_code' | 'passkey' | 'magic_link' | 'password' | 'webauthn' | 'oauth' | 'oidc' | string;
|
|
17
|
-
export type IncrStore = {
|
|
18
|
-
/**
|
|
19
|
-
* Atomic increment-with-expiry. First call returns `{ count: 1 }` and
|
|
20
|
-
* arms a TTL; subsequent calls before expiry return the incremented
|
|
21
|
-
* count. Used as compare-and-set for single-use enforcement.
|
|
22
|
-
*/
|
|
23
|
-
incr: (key: string, ttlMs: number) => Promise<{
|
|
24
|
-
count: number;
|
|
25
|
-
}>;
|
|
26
|
-
};
|
|
27
|
-
export type ChallengePayload = {
|
|
28
|
-
jti: string;
|
|
29
|
-
iat: number;
|
|
30
|
-
exp: number;
|
|
31
|
-
userId?: string;
|
|
32
|
-
method?: ChallengeMethod;
|
|
33
|
-
step?: string;
|
|
34
|
-
nextStep?: string;
|
|
35
|
-
/**
|
|
36
|
-
* Only set when `ipBinding: true`.
|
|
37
|
-
*/
|
|
38
|
-
ip?: string;
|
|
39
|
-
/**
|
|
40
|
-
* Only set when `ua` supplied.
|
|
41
|
-
*/
|
|
42
|
-
ua?: string;
|
|
43
|
-
meta?: Record<string, unknown>;
|
|
44
|
-
};
|
|
45
|
-
export type CreateChallengeOptions = {
|
|
46
|
-
/**
|
|
47
|
-
* HMAC-SHA256 secret. **Must be at least 32 raw bytes.** A string is
|
|
48
|
-
* interpreted as UTF-8 — for a hex or base64 secret, decode to
|
|
49
|
-
* Buffer first.
|
|
50
|
-
*/
|
|
51
|
-
secret: string | Buffer | Uint8Array;
|
|
52
|
-
userId?: string;
|
|
53
|
-
method?: ChallengeMethod;
|
|
54
|
-
step?: string;
|
|
55
|
-
nextStep?: string;
|
|
56
|
-
ip?: string;
|
|
57
|
-
ua?: string;
|
|
58
|
-
metadata?: Record<string, unknown>;
|
|
59
|
-
/**
|
|
60
|
-
* Duration string (`'5m'`) or ms integer.
|
|
61
|
-
*/
|
|
62
|
-
expiresIn: string | number;
|
|
63
|
-
/**
|
|
64
|
-
* When true, the returned token can only be verified once — subsequent
|
|
65
|
-
* verifies with `consume: true` fail with `reason: 'replay'`. Requires
|
|
66
|
-
* `store` to be supplied.
|
|
67
|
-
*/
|
|
68
|
-
singleUse?: boolean;
|
|
69
|
-
/**
|
|
70
|
-
* Any object exposing `incr(key, ttlMs) → { count }`. Compatible with
|
|
71
|
-
* `@exortek/security`'s rate-limit stores; also easy to wrap Redis.
|
|
72
|
-
*/
|
|
73
|
-
store?: IncrStore;
|
|
74
|
-
/**
|
|
75
|
-
* When true, the caller-supplied `ip` is stamped into the payload and
|
|
76
|
-
* `verifyChallenge` will reject a request whose `ip` differs.
|
|
77
|
-
*/
|
|
78
|
-
ipBinding?: boolean;
|
|
79
|
-
/**
|
|
80
|
-
* Wire-format prefix. Defaults to `'chall_v1'` — the value shipped
|
|
81
|
-
* with this package. Callers can override to brand the token
|
|
82
|
-
* family (e.g. `'server_challenge'`, `'myapp_v1'`); must match
|
|
83
|
-
* `/^[A-Za-z0-9_-]{1,32}$/`. The same prefix must be passed at
|
|
84
|
-
* verify time or verification returns `reason: 'malformed'`.
|
|
85
|
-
*/
|
|
86
|
-
prefix?: string;
|
|
87
|
-
/**
|
|
88
|
-
* Override `Date.now()` for testing.
|
|
89
|
-
*/
|
|
90
|
-
now?: number;
|
|
91
|
-
};
|
|
92
|
-
export type VerifyChallengeOptions = {
|
|
93
|
-
secret: string | Buffer | Uint8Array;
|
|
94
|
-
/**
|
|
95
|
-
* Enforce single-use. Requires `store` (typically the same one used
|
|
96
|
-
* at create time).
|
|
97
|
-
*/
|
|
98
|
-
consume?: boolean;
|
|
99
|
-
store?: IncrStore;
|
|
100
|
-
expectedUserId?: string;
|
|
101
|
-
expectedMethod?: ChallengeMethod;
|
|
102
|
-
expectedStep?: string;
|
|
103
|
-
expectedNextStep?: string;
|
|
104
|
-
/**
|
|
105
|
-
* The current request's IP. Required to verify a token that was
|
|
106
|
-
* created with `ipBinding: true`; ignored otherwise.
|
|
107
|
-
*/
|
|
108
|
-
ip?: string;
|
|
109
|
-
/**
|
|
110
|
-
* Wire-format prefix. Must match the value passed to
|
|
111
|
-
* `createChallenge` — a token minted with a different prefix will
|
|
112
|
-
* fail with `reason: 'malformed'`.
|
|
113
|
-
*/
|
|
114
|
-
prefix?: string;
|
|
115
|
-
/**
|
|
116
|
-
* Override `Date.now()` for testing.
|
|
117
|
-
*/
|
|
118
|
-
now?: number;
|
|
119
|
-
};
|
|
120
|
-
export type VerifyFailureReason = 'malformed' | 'bad_signature' | 'expired' | 'not_yet_valid' | 'user_mismatch' | 'method_mismatch' | 'step_mismatch' | 'next_step_mismatch' | 'ip_mismatch' | 'ip_missing' | 'replay' | 'store_unavailable';
|
|
121
|
-
export type VerifyChallengeResult = {
|
|
122
|
-
valid: true;
|
|
123
|
-
payload: ChallengePayload;
|
|
124
|
-
} | {
|
|
125
|
-
valid: false;
|
|
126
|
-
reason: VerifyFailureReason;
|
|
127
|
-
};
|
|
128
1
|
/**
|
|
129
2
|
* @typedef {'totp' | 'hotp' | 'email_otp' | 'sms_otp' | 'backup_code'
|
|
130
3
|
* | 'passkey' | 'magic_link' | 'password' | 'webauthn' | 'oauth' | 'oidc'
|
|
@@ -229,7 +102,7 @@ export type VerifyChallengeResult = {
|
|
|
229
102
|
* @param {CreateChallengeOptions} options
|
|
230
103
|
* @returns {Promise<string>}
|
|
231
104
|
*/
|
|
232
|
-
export
|
|
105
|
+
export function createChallenge(options: CreateChallengeOptions): Promise<string>;
|
|
233
106
|
/**
|
|
234
107
|
* Verify a challenge token. Returns `{ valid: true, payload }` on
|
|
235
108
|
* success or `{ valid: false, reason }` on any expected failure. Only
|
|
@@ -249,4 +122,117 @@ export declare function createChallenge(options: CreateChallengeOptions): Promis
|
|
|
249
122
|
* @param {VerifyChallengeOptions} options
|
|
250
123
|
* @returns {Promise<VerifyChallengeResult>}
|
|
251
124
|
*/
|
|
252
|
-
export
|
|
125
|
+
export function verifyChallenge(token: string, options: VerifyChallengeOptions): Promise<VerifyChallengeResult>;
|
|
126
|
+
export type ChallengeMethod = "totp" | "hotp" | "email_otp" | "sms_otp" | "backup_code" | "passkey" | "magic_link" | "password" | "webauthn" | "oauth" | "oidc" | string;
|
|
127
|
+
export type IncrStore = {
|
|
128
|
+
/**
|
|
129
|
+
* Atomic increment-with-expiry. First call returns `{ count: 1 }` and
|
|
130
|
+
* arms a TTL; subsequent calls before expiry return the incremented
|
|
131
|
+
* count. Used as compare-and-set for single-use enforcement.
|
|
132
|
+
*/
|
|
133
|
+
incr: (key: string, ttlMs: number) => Promise<{
|
|
134
|
+
count: number;
|
|
135
|
+
}>;
|
|
136
|
+
};
|
|
137
|
+
export type ChallengePayload = {
|
|
138
|
+
jti: string;
|
|
139
|
+
iat: number;
|
|
140
|
+
exp: number;
|
|
141
|
+
userId?: string | undefined;
|
|
142
|
+
method?: string | undefined;
|
|
143
|
+
step?: string | undefined;
|
|
144
|
+
nextStep?: string | undefined;
|
|
145
|
+
/**
|
|
146
|
+
* Only set when `ipBinding: true`.
|
|
147
|
+
*/
|
|
148
|
+
ip?: string | undefined;
|
|
149
|
+
/**
|
|
150
|
+
* Only set when `ua` supplied.
|
|
151
|
+
*/
|
|
152
|
+
ua?: string | undefined;
|
|
153
|
+
meta?: Record<string, unknown> | undefined;
|
|
154
|
+
};
|
|
155
|
+
export type CreateChallengeOptions = {
|
|
156
|
+
/**
|
|
157
|
+
* HMAC-SHA256 secret. **Must be at least 32 raw bytes.** A string is
|
|
158
|
+
* interpreted as UTF-8 — for a hex or base64 secret, decode to
|
|
159
|
+
* Buffer first.
|
|
160
|
+
*/
|
|
161
|
+
secret: string | Buffer | Uint8Array;
|
|
162
|
+
userId?: string | undefined;
|
|
163
|
+
method?: string | undefined;
|
|
164
|
+
step?: string | undefined;
|
|
165
|
+
nextStep?: string | undefined;
|
|
166
|
+
ip?: string | undefined;
|
|
167
|
+
ua?: string | undefined;
|
|
168
|
+
metadata?: Record<string, unknown> | undefined;
|
|
169
|
+
/**
|
|
170
|
+
* Duration string (`'5m'`) or ms integer.
|
|
171
|
+
*/
|
|
172
|
+
expiresIn: string | number;
|
|
173
|
+
/**
|
|
174
|
+
* When true, the returned token can only be verified once — subsequent
|
|
175
|
+
* verifies with `consume: true` fail with `reason: 'replay'`. Requires
|
|
176
|
+
* `store` to be supplied.
|
|
177
|
+
*/
|
|
178
|
+
singleUse?: boolean | undefined;
|
|
179
|
+
/**
|
|
180
|
+
* Any object exposing `incr(key, ttlMs) → { count }`. Compatible with
|
|
181
|
+
* `@exortek/security`'s rate-limit stores; also easy to wrap Redis.
|
|
182
|
+
*/
|
|
183
|
+
store?: IncrStore | undefined;
|
|
184
|
+
/**
|
|
185
|
+
* When true, the caller-supplied `ip` is stamped into the payload and
|
|
186
|
+
* `verifyChallenge` will reject a request whose `ip` differs.
|
|
187
|
+
*/
|
|
188
|
+
ipBinding?: boolean | undefined;
|
|
189
|
+
/**
|
|
190
|
+
* Wire-format prefix. Defaults to `'chall_v1'` — the value shipped
|
|
191
|
+
* with this package. Callers can override to brand the token
|
|
192
|
+
* family (e.g. `'server_challenge'`, `'myapp_v1'`); must match
|
|
193
|
+
* `/^[A-Za-z0-9_-]{1,32}$/`. The same prefix must be passed at
|
|
194
|
+
* verify time or verification returns `reason: 'malformed'`.
|
|
195
|
+
*/
|
|
196
|
+
prefix?: string | undefined;
|
|
197
|
+
/**
|
|
198
|
+
* Override `Date.now()` for testing.
|
|
199
|
+
*/
|
|
200
|
+
now?: number | undefined;
|
|
201
|
+
};
|
|
202
|
+
export type VerifyChallengeOptions = {
|
|
203
|
+
secret: string | Buffer | Uint8Array;
|
|
204
|
+
/**
|
|
205
|
+
* Enforce single-use. Requires `store` (typically the same one used
|
|
206
|
+
* at create time).
|
|
207
|
+
*/
|
|
208
|
+
consume?: boolean | undefined;
|
|
209
|
+
store?: IncrStore | undefined;
|
|
210
|
+
expectedUserId?: string | undefined;
|
|
211
|
+
expectedMethod?: string | undefined;
|
|
212
|
+
expectedStep?: string | undefined;
|
|
213
|
+
expectedNextStep?: string | undefined;
|
|
214
|
+
/**
|
|
215
|
+
* The current request's IP. Required to verify a token that was
|
|
216
|
+
* created with `ipBinding: true`; ignored otherwise.
|
|
217
|
+
*/
|
|
218
|
+
ip?: string | undefined;
|
|
219
|
+
/**
|
|
220
|
+
* Wire-format prefix. Must match the value passed to
|
|
221
|
+
* `createChallenge` — a token minted with a different prefix will
|
|
222
|
+
* fail with `reason: 'malformed'`.
|
|
223
|
+
*/
|
|
224
|
+
prefix?: string | undefined;
|
|
225
|
+
/**
|
|
226
|
+
* Override `Date.now()` for testing.
|
|
227
|
+
*/
|
|
228
|
+
now?: number | undefined;
|
|
229
|
+
};
|
|
230
|
+
export type VerifyFailureReason = "malformed" | "bad_signature" | "expired" | "not_yet_valid" | "user_mismatch" | "method_mismatch" | "step_mismatch" | "next_step_mismatch" | "ip_mismatch" | "ip_missing" | "replay" | "store_unavailable";
|
|
231
|
+
export type VerifyChallengeResult = {
|
|
232
|
+
valid: true;
|
|
233
|
+
payload: ChallengePayload;
|
|
234
|
+
} | {
|
|
235
|
+
valid: false;
|
|
236
|
+
reason: VerifyFailureReason;
|
|
237
|
+
};
|
|
238
|
+
export { ChallengeError, ErrorCode } from "./errors.js";
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
* error class for the whole package.
|
|
7
|
-
*/
|
|
8
|
-
export declare const invalidArgument: (msg: string, extra?: import("@exortek/shared/asserts").WrapExtra) => Error, parse: (schema: import("@exortek/shared/asserts").ParseableSchema, input: unknown, path?: string) => unknown, assertObject: (value: unknown, name: string, opts?: import("@exortek/shared/asserts").AssertOptions) => void, assertNonEmptyString: (value: unknown, name: string, opts?: import("@exortek/shared/asserts").AssertOptions) => void, assertBytes: (value: unknown, name: string, opts?: import("@exortek/shared/asserts").AssertOptions) => void;
|
|
1
|
+
export const invalidArgument: (msg: string, extra?: WrapExtra) => Error;
|
|
2
|
+
export const parse: (schema: ParseableSchema, input: unknown, path?: string) => unknown;
|
|
3
|
+
export const assertObject: (value: unknown, name: string, opts?: AssertOptions) => void;
|
|
4
|
+
export const assertNonEmptyString: (value: unknown, name: string, opts?: AssertOptions) => void;
|
|
5
|
+
export const assertBytes: (value: unknown, name: string, opts?: AssertOptions) => void;
|
package/dist/stores/index.d.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* exposing the {@link IncrStore} shape works — these are provided as
|
|
4
|
-
* a zero-config default (memory) and a cluster-safe default (Redis).
|
|
5
|
-
* `@exortek/security`'s rate-limit stores are wire-compatible.
|
|
6
|
-
*/
|
|
7
|
-
export { memoryStore } from './memory.js';
|
|
8
|
-
export { redisStore } from './redis.js';
|
|
1
|
+
export { memoryStore } from "./memory.js";
|
|
2
|
+
export { redisStore } from "./redis.js";
|
|
9
3
|
export type IncrStore = {
|
|
10
4
|
/**
|
|
11
5
|
* Atomic increment-with-expiry. First call for a fresh key returns
|
|
@@ -18,11 +12,3 @@ export type IncrStore = {
|
|
|
18
12
|
expiresAt?: number;
|
|
19
13
|
}>;
|
|
20
14
|
};
|
|
21
|
-
/**
|
|
22
|
-
* @typedef {object} IncrStore
|
|
23
|
-
* @property {(key: string, ttlMs: number) => Promise<{ count: number, expiresAt?: number }>} incr
|
|
24
|
-
* Atomic increment-with-expiry. First call for a fresh key returns
|
|
25
|
-
* `{ count: 1 }` and arms a TTL of `ttlMs`; subsequent calls before
|
|
26
|
-
* expiry return the incremented count. `verifyChallenge` reads
|
|
27
|
-
* `count > 1` as "already consumed".
|
|
28
|
-
*/
|
package/dist/stores/memory.d.ts
CHANGED
|
@@ -1,40 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* In-process memory store for challenge single-use enforcement.
|
|
3
|
-
*
|
|
4
|
-
* Not cluster-safe: every worker has its own state, so a token accepted
|
|
5
|
-
* on one worker could still be replayed on another. Use for dev,
|
|
6
|
-
* single-node deploys, sticky-session behind an LB, and tests. For
|
|
7
|
-
* multi-worker production, use {@link redisStore} or pass any object
|
|
8
|
-
* exposing `incr(key, ttlMs) → { count }` (e.g.
|
|
9
|
-
* `@exortek/security`'s rate-limit stores).
|
|
10
|
-
*
|
|
11
|
-
* Eviction: true LRU (least-recently-used). Every `incr` on an existing
|
|
12
|
-
* key re-inserts it so it becomes the newest entry;
|
|
13
|
-
* `map.keys().next().value` is then the least-recently-touched key and
|
|
14
|
-
* is dropped when `maxKeys` is exceeded. This matters for the
|
|
15
|
-
* replay-guard tombstone: a repeated verify attempt refreshes the
|
|
16
|
-
* `count > 1` entry so it stays authoritative until its TTL expires.
|
|
17
|
-
* FIFO would let an idle tombstone age out prematurely and a third
|
|
18
|
-
* replay attempt could see a fresh counter.
|
|
19
|
-
*
|
|
20
|
-
* Textbook pattern, popularised in the JS community by projects like
|
|
21
|
-
* `toad-cache` (MIT); no code copied, only the same ES2015
|
|
22
|
-
* iteration-order guarantee.
|
|
23
|
-
*
|
|
24
|
-
* Expired entries are pruned lazily on read, plus a `setInterval` sweep
|
|
25
|
-
* (unref'd so it never blocks process exit). The `maxKeys` cap
|
|
26
|
-
* protects against unbounded growth if the caller forgets to clean up.
|
|
27
|
-
*/
|
|
28
|
-
export type MemoryStoreOptions = {
|
|
29
|
-
/**
|
|
30
|
-
* Hard cap; oldest entry dropped when exceeded.
|
|
31
|
-
*/
|
|
32
|
-
maxKeys?: number;
|
|
33
|
-
/**
|
|
34
|
-
* Interval for the background TTL sweep.
|
|
35
|
-
*/
|
|
36
|
-
sweepMs?: number;
|
|
37
|
-
};
|
|
38
1
|
/**
|
|
39
2
|
* @typedef {object} MemoryStoreOptions
|
|
40
3
|
* @property {number} [maxKeys=10000] Hard cap; oldest entry dropped when exceeded.
|
|
@@ -44,7 +7,17 @@ export type MemoryStoreOptions = {
|
|
|
44
7
|
* @param {MemoryStoreOptions} [options]
|
|
45
8
|
* @returns {import('./index.js').IncrStore & { _size: () => number, _stop: () => void }}
|
|
46
9
|
*/
|
|
47
|
-
export
|
|
10
|
+
export function memoryStore(options?: MemoryStoreOptions): import("./index.js").IncrStore & {
|
|
48
11
|
_size: () => number;
|
|
49
12
|
_stop: () => void;
|
|
50
13
|
};
|
|
14
|
+
export type MemoryStoreOptions = {
|
|
15
|
+
/**
|
|
16
|
+
* Hard cap; oldest entry dropped when exceeded.
|
|
17
|
+
*/
|
|
18
|
+
maxKeys?: number | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Interval for the background TTL sweep.
|
|
21
|
+
*/
|
|
22
|
+
sweepMs?: number | undefined;
|
|
23
|
+
};
|
package/dist/stores/redis.d.ts
CHANGED
|
@@ -1,24 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Redis-backed store for challenge single-use enforcement.
|
|
3
|
-
*
|
|
4
|
-
* Cluster-safe: state lives in Redis, so a challenge accepted on one
|
|
5
|
-
* worker cannot be replayed on another. Works with any client exposing
|
|
6
|
-
* `eval(script, numkeys, ...args)` — verified against `ioredis`,
|
|
7
|
-
* `node-redis` v4+, and `@upstash/redis` (HTTP client, runs on
|
|
8
|
-
* Cloudflare Workers / Vercel Edge / Deno Deploy).
|
|
9
|
-
*
|
|
10
|
-
* Atomicity: `incr` runs a single Lua script that INCR's the key and
|
|
11
|
-
* PEXPIRE's it only when the key is fresh (count === 1). The TTL
|
|
12
|
-
* anchors to the first increment — exactly what single-use enforcement
|
|
13
|
-
* needs (the tombstone lives as long as the token could still verify,
|
|
14
|
-
* then rolls off).
|
|
15
|
-
*/
|
|
16
|
-
export type RedisStoreOptions = {
|
|
17
|
-
/**
|
|
18
|
-
* Prepended to every key.
|
|
19
|
-
*/
|
|
20
|
-
keyPrefix?: string;
|
|
21
|
-
};
|
|
22
1
|
/**
|
|
23
2
|
* @typedef {object} RedisStoreOptions
|
|
24
3
|
* @property {string} [keyPrefix='chall:'] Prepended to every key.
|
|
@@ -28,4 +7,10 @@ export type RedisStoreOptions = {
|
|
|
28
7
|
* @param {RedisStoreOptions} [options]
|
|
29
8
|
* @returns {import('./index.js').IncrStore}
|
|
30
9
|
*/
|
|
31
|
-
export
|
|
10
|
+
export function redisStore(client: any, options?: RedisStoreOptions): import("./index.js").IncrStore;
|
|
11
|
+
export type RedisStoreOptions = {
|
|
12
|
+
/**
|
|
13
|
+
* Prepended to every key.
|
|
14
|
+
*/
|
|
15
|
+
keyPrefix?: string | undefined;
|
|
16
|
+
};
|
package/dist/stores.cjs
CHANGED
|
@@ -59,6 +59,205 @@ function isInteger(value) {
|
|
|
59
59
|
return Number.isSafeInteger(value);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Duck-type guard for a Redis-compatible client. Every `@exortek/*`
|
|
64
|
+
* package that ships a Redis store (jwt blacklist, session store,
|
|
65
|
+
* security rate-limit) does the same "not null + required methods
|
|
66
|
+
* are functions" check up front so a missing dependency surfaces as
|
|
67
|
+
* a clean typed error instead of `TypeError: client.get is not a
|
|
68
|
+
* function` deep in a store operation.
|
|
69
|
+
*
|
|
70
|
+
* Callers pass their own `wrap` callback that throws their typed
|
|
71
|
+
* error class with the message — this file has no opinion on the
|
|
72
|
+
* error surface.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {unknown} client
|
|
77
|
+
* @param {readonly string[]} methods Method names that must be functions on the client.
|
|
78
|
+
* @param {(message: string) => never} wrap
|
|
79
|
+
* Called with a diagnostic message when the check fails; must throw.
|
|
80
|
+
* The caller's typed error class is emitted from inside this callback.
|
|
81
|
+
* @returns {void}
|
|
82
|
+
*/
|
|
83
|
+
function assertRedisClient(client, methods, wrap) {
|
|
84
|
+
if (!client || typeof client !== 'object') {
|
|
85
|
+
wrap('client is required — pass a Redis-compatible instance (ioredis / node-redis / @upstash/redis)');
|
|
86
|
+
}
|
|
87
|
+
const c = /** @type {Record<string, unknown>} */ (client);
|
|
88
|
+
for (const method of methods) {
|
|
89
|
+
if (typeof c[method] !== 'function') {
|
|
90
|
+
wrap(`client is missing '${method}()' — is this a Redis-compatible client?`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Shared IncrStore — atomic TTL counter for replay guards and rate
|
|
97
|
+
* limiting. Memory and Redis factories.
|
|
98
|
+
*
|
|
99
|
+
* Interface: `incr(key, ttlMs) → { count, expiresAt }`.
|
|
100
|
+
* First call arms a TTL and returns `{ count: 1 }`; subsequent calls
|
|
101
|
+
* before expiry increment the counter.
|
|
102
|
+
*
|
|
103
|
+
* Consumers: `@exortek/challenge`, `@exortek/magic-link` (rate limiter).
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
// Memory
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @typedef {object} MemoryIncrStoreOptions
|
|
111
|
+
* @property {number} [maxKeys=10000] Hard cap; oldest entry dropped when exceeded.
|
|
112
|
+
* @property {number} [sweepMs=60000] Interval for the background TTL sweep.
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {MemoryIncrStoreOptions} [options]
|
|
117
|
+
* @param {(msg: string) => never} [wrap] Error factory for validation.
|
|
118
|
+
* @returns {{ incr: (key: string, ttlMs: number) => Promise<{ count: number, expiresAt: number }>, _size: () => number, _stop: () => void }}
|
|
119
|
+
*/
|
|
120
|
+
function createMemoryIncrStore(options = {}, wrap) {
|
|
121
|
+
const fail =
|
|
122
|
+
wrap ??
|
|
123
|
+
(msg => {
|
|
124
|
+
throw new TypeError(msg);
|
|
125
|
+
});
|
|
126
|
+
const maxKeys = options.maxKeys ?? 10_000;
|
|
127
|
+
const sweepMs = options.sweepMs ?? 60_000;
|
|
128
|
+
|
|
129
|
+
if (!isInteger(maxKeys) || maxKeys < 1) {
|
|
130
|
+
fail(`memoryIncrStore.maxKeys must be a positive integer; got ${maxKeys}`);
|
|
131
|
+
}
|
|
132
|
+
if (!isInteger(sweepMs) || sweepMs < 1000) {
|
|
133
|
+
fail(`memoryIncrStore.sweepMs must be an integer >= 1000; got ${sweepMs}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** @type {Map<string, { count: number, expiresAt: number }>} */
|
|
137
|
+
const map = new Map();
|
|
138
|
+
let timer = null;
|
|
139
|
+
|
|
140
|
+
function peekFresh(key) {
|
|
141
|
+
const entry = map.get(key);
|
|
142
|
+
if (!entry) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
if (entry.expiresAt <= Date.now()) {
|
|
146
|
+
map.delete(key);
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return entry;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sweep() {
|
|
153
|
+
const t = Date.now();
|
|
154
|
+
for (const [key, entry] of map) {
|
|
155
|
+
if (entry.expiresAt <= t) {
|
|
156
|
+
map.delete(key);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function scheduleSweeper() {
|
|
162
|
+
if (timer) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
timer = setInterval(sweep, sweepMs);
|
|
166
|
+
if (isFunction(timer.unref)) {
|
|
167
|
+
timer.unref();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function evictIfFull() {
|
|
172
|
+
if (map.size < maxKeys) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const oldest = map.keys().next().value;
|
|
176
|
+
if (!isUndefined(oldest)) {
|
|
177
|
+
map.delete(oldest);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
async incr(key, ttlMs) {
|
|
183
|
+
scheduleSweeper();
|
|
184
|
+
const existing = peekFresh(key);
|
|
185
|
+
if (existing) {
|
|
186
|
+
existing.count += 1;
|
|
187
|
+
map.delete(key);
|
|
188
|
+
map.set(key, existing);
|
|
189
|
+
return { count: existing.count, expiresAt: existing.expiresAt };
|
|
190
|
+
}
|
|
191
|
+
evictIfFull();
|
|
192
|
+
const expiresAt = Date.now() + ttlMs;
|
|
193
|
+
map.set(key, { count: 1, expiresAt });
|
|
194
|
+
return { count: 1, expiresAt };
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
_size: () => map.size,
|
|
198
|
+
_stop: () => {
|
|
199
|
+
if (timer) {
|
|
200
|
+
clearInterval(timer);
|
|
201
|
+
timer = null;
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Redis
|
|
208
|
+
|
|
209
|
+
const INCR_SCRIPT = `
|
|
210
|
+
local key = KEYS[1]
|
|
211
|
+
local ttl = tonumber(ARGV[1])
|
|
212
|
+
local count = redis.call('INCR', key)
|
|
213
|
+
local pttl
|
|
214
|
+
if count == 1 then
|
|
215
|
+
redis.call('PEXPIRE', key, ttl)
|
|
216
|
+
pttl = ttl
|
|
217
|
+
else
|
|
218
|
+
pttl = redis.call('PTTL', key)
|
|
219
|
+
if pttl < 0 then
|
|
220
|
+
redis.call('PEXPIRE', key, ttl)
|
|
221
|
+
pttl = ttl
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
return { count, pttl }
|
|
225
|
+
`.trim();
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* @typedef {object} RedisIncrStoreOptions
|
|
229
|
+
* @property {string} [keyPrefix=''] Prepended to every key.
|
|
230
|
+
*/
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param {object} client
|
|
234
|
+
* @param {RedisIncrStoreOptions} [options]
|
|
235
|
+
* @param {(msg: string) => never} [wrap] Error factory for assertRedisClient.
|
|
236
|
+
* @returns {{ incr: (key: string, ttlMs: number) => Promise<{ count: number, expiresAt: number }> }}
|
|
237
|
+
*/
|
|
238
|
+
function createRedisIncrStore(client, options = {}, wrap) {
|
|
239
|
+
assertRedisClient(
|
|
240
|
+
client,
|
|
241
|
+
['eval'],
|
|
242
|
+
wrap ??
|
|
243
|
+
(msg => {
|
|
244
|
+
throw new TypeError(msg);
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
const keyPrefix = options.keyPrefix ?? '';
|
|
248
|
+
const k = key => `${keyPrefix}${key}`;
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
async incr(key, ttlMs) {
|
|
252
|
+
const raw = await client.eval(INCR_SCRIPT, 1, k(key), String(Math.max(1, Math.ceil(ttlMs))));
|
|
253
|
+
const arr = Array.isArray(raw) ? raw : [raw, ttlMs];
|
|
254
|
+
const count = Number(arr[0]);
|
|
255
|
+
const pttl = Number(arr[1]);
|
|
256
|
+
return { count, expiresAt: Date.now() + (Number.isFinite(pttl) && pttl > 0 ? pttl : ttlMs) };
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
62
261
|
/**
|
|
63
262
|
* Imperative single-argument guard helpers — the everyday
|
|
64
263
|
* `assertPositiveInt(x, 'name')` shape used at API boundaries.
|
|
@@ -524,121 +723,9 @@ const { invalidArgument} = defineGuards(
|
|
|
524
723
|
* @returns {import('./index.js').IncrStore & { _size: () => number, _stop: () => void }}
|
|
525
724
|
*/
|
|
526
725
|
function memoryStore(options = {}) {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
if (!isInteger(maxKeys) || maxKeys < 1) {
|
|
531
|
-
throw invalidArgument(`memoryStore.options.maxKeys must be a positive integer; got ${maxKeys}`);
|
|
532
|
-
}
|
|
533
|
-
if (!isInteger(sweepMs) || sweepMs < 1000) {
|
|
534
|
-
throw invalidArgument(`memoryStore.options.sweepMs must be an integer >= 1000; got ${sweepMs}`);
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/** @type {Map<string, { count: number, expiresAt: number }>} */
|
|
538
|
-
const map = new Map();
|
|
539
|
-
let timer = null;
|
|
540
|
-
|
|
541
|
-
function peekFresh(key) {
|
|
542
|
-
const entry = map.get(key);
|
|
543
|
-
if (!entry) {
|
|
544
|
-
return null;
|
|
545
|
-
}
|
|
546
|
-
if (entry.expiresAt <= Date.now()) {
|
|
547
|
-
map.delete(key);
|
|
548
|
-
return null;
|
|
549
|
-
}
|
|
550
|
-
return entry;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
function sweep() {
|
|
554
|
-
const t = Date.now();
|
|
555
|
-
for (const [key, entry] of map) {
|
|
556
|
-
if (entry.expiresAt <= t) {
|
|
557
|
-
map.delete(key);
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
function scheduleSweeper() {
|
|
563
|
-
if (timer) {
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
566
|
-
timer = setInterval(sweep, sweepMs);
|
|
567
|
-
if (isFunction(timer.unref)) {
|
|
568
|
-
timer.unref();
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
function evictIfFull() {
|
|
573
|
-
if (map.size < maxKeys) {
|
|
574
|
-
return;
|
|
575
|
-
}
|
|
576
|
-
const oldest = map.keys().next().value;
|
|
577
|
-
if (!isUndefined(oldest)) {
|
|
578
|
-
map.delete(oldest);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
return {
|
|
583
|
-
async incr(key, ttlMs) {
|
|
584
|
-
scheduleSweeper();
|
|
585
|
-
const existing = peekFresh(key);
|
|
586
|
-
if (existing) {
|
|
587
|
-
existing.count += 1;
|
|
588
|
-
// Refresh LRU position on activity — the replay-guard tombstone
|
|
589
|
-
// must stay warm until its TTL fires, or a third replay attempt
|
|
590
|
-
// could see the entry evicted and get a fresh count.
|
|
591
|
-
map.delete(key);
|
|
592
|
-
map.set(key, existing);
|
|
593
|
-
return { count: existing.count, expiresAt: existing.expiresAt };
|
|
594
|
-
}
|
|
595
|
-
evictIfFull();
|
|
596
|
-
const expiresAt = Date.now() + ttlMs;
|
|
597
|
-
map.set(key, { count: 1, expiresAt });
|
|
598
|
-
return { count: 1, expiresAt };
|
|
599
|
-
},
|
|
600
|
-
|
|
601
|
-
_size: () => map.size,
|
|
602
|
-
_stop: () => {
|
|
603
|
-
if (timer) {
|
|
604
|
-
clearInterval(timer);
|
|
605
|
-
timer = null;
|
|
606
|
-
}
|
|
607
|
-
},
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/**
|
|
612
|
-
* Duck-type guard for a Redis-compatible client. Every `@exortek/*`
|
|
613
|
-
* package that ships a Redis store (jwt blacklist, session store,
|
|
614
|
-
* security rate-limit) does the same "not null + required methods
|
|
615
|
-
* are functions" check up front so a missing dependency surfaces as
|
|
616
|
-
* a clean typed error instead of `TypeError: client.get is not a
|
|
617
|
-
* function` deep in a store operation.
|
|
618
|
-
*
|
|
619
|
-
* Callers pass their own `wrap` callback that throws their typed
|
|
620
|
-
* error class with the message — this file has no opinion on the
|
|
621
|
-
* error surface.
|
|
622
|
-
*/
|
|
623
|
-
|
|
624
|
-
/**
|
|
625
|
-
* @param {unknown} client
|
|
626
|
-
* @param {readonly string[]} methods Method names that must be functions on the client.
|
|
627
|
-
* @param {(message: string) => never} wrap
|
|
628
|
-
* Called with a diagnostic message when the check fails; must throw.
|
|
629
|
-
* The caller's typed error class is emitted from inside this callback.
|
|
630
|
-
* @returns {void}
|
|
631
|
-
*/
|
|
632
|
-
function assertRedisClient(client, methods, wrap) {
|
|
633
|
-
if (!client || typeof client !== 'object') {
|
|
634
|
-
wrap('client is required — pass a Redis-compatible instance (ioredis / node-redis / @upstash/redis)');
|
|
635
|
-
}
|
|
636
|
-
const c = /** @type {Record<string, unknown>} */ (client);
|
|
637
|
-
for (const method of methods) {
|
|
638
|
-
if (typeof c[method] !== 'function') {
|
|
639
|
-
wrap(`client is missing '${method}()' — is this a Redis-compatible client?`);
|
|
640
|
-
}
|
|
641
|
-
}
|
|
726
|
+
return createMemoryIncrStore(options, msg => {
|
|
727
|
+
throw invalidArgument(msg);
|
|
728
|
+
});
|
|
642
729
|
}
|
|
643
730
|
|
|
644
731
|
/**
|
|
@@ -658,24 +745,6 @@ function assertRedisClient(client, methods, wrap) {
|
|
|
658
745
|
*/
|
|
659
746
|
|
|
660
747
|
|
|
661
|
-
const INCR_SCRIPT = `
|
|
662
|
-
local key = KEYS[1]
|
|
663
|
-
local ttl = tonumber(ARGV[1])
|
|
664
|
-
local count = redis.call('INCR', key)
|
|
665
|
-
local pttl
|
|
666
|
-
if count == 1 then
|
|
667
|
-
redis.call('PEXPIRE', key, ttl)
|
|
668
|
-
pttl = ttl
|
|
669
|
-
else
|
|
670
|
-
pttl = redis.call('PTTL', key)
|
|
671
|
-
if pttl < 0 then
|
|
672
|
-
redis.call('PEXPIRE', key, ttl)
|
|
673
|
-
pttl = ttl
|
|
674
|
-
end
|
|
675
|
-
end
|
|
676
|
-
return { count, pttl }
|
|
677
|
-
`.trim();
|
|
678
|
-
|
|
679
748
|
/**
|
|
680
749
|
* @typedef {object} RedisStoreOptions
|
|
681
750
|
* @property {string} [keyPrefix='chall:'] Prepended to every key.
|
|
@@ -687,23 +756,9 @@ return { count, pttl }
|
|
|
687
756
|
* @returns {import('./index.js').IncrStore}
|
|
688
757
|
*/
|
|
689
758
|
function redisStore(client, options = {}) {
|
|
690
|
-
|
|
759
|
+
return createRedisIncrStore(client, { keyPrefix: options.keyPrefix ?? 'chall:' }, msg => {
|
|
691
760
|
throw invalidArgument(`redisStore.client: ${msg}`);
|
|
692
761
|
});
|
|
693
|
-
const keyPrefix = options.keyPrefix ?? 'chall:';
|
|
694
|
-
const k = key => `${keyPrefix}${key}`;
|
|
695
|
-
|
|
696
|
-
return {
|
|
697
|
-
async incr(key, ttlMs) {
|
|
698
|
-
const raw = await client.eval(INCR_SCRIPT, 1, k(key), String(Math.max(1, Math.ceil(ttlMs))));
|
|
699
|
-
// ioredis returns [count, pttl] as numbers; node-redis v4 does too;
|
|
700
|
-
// Upstash HTTP driver returns strings. Coerce defensively.
|
|
701
|
-
const arr = Array.isArray(raw) ? raw : [raw, ttlMs];
|
|
702
|
-
const count = Number(arr[0]);
|
|
703
|
-
const pttl = Number(arr[1]);
|
|
704
|
-
return { count, expiresAt: Date.now() + (Number.isFinite(pttl) && pttl > 0 ? pttl : ttlMs) };
|
|
705
|
-
},
|
|
706
|
-
};
|
|
707
762
|
}
|
|
708
763
|
|
|
709
764
|
exports.memoryStore = memoryStore;
|
package/dist/stores.mjs
CHANGED
|
@@ -57,6 +57,205 @@ function isInteger(value) {
|
|
|
57
57
|
return Number.isSafeInteger(value);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Duck-type guard for a Redis-compatible client. Every `@exortek/*`
|
|
62
|
+
* package that ships a Redis store (jwt blacklist, session store,
|
|
63
|
+
* security rate-limit) does the same "not null + required methods
|
|
64
|
+
* are functions" check up front so a missing dependency surfaces as
|
|
65
|
+
* a clean typed error instead of `TypeError: client.get is not a
|
|
66
|
+
* function` deep in a store operation.
|
|
67
|
+
*
|
|
68
|
+
* Callers pass their own `wrap` callback that throws their typed
|
|
69
|
+
* error class with the message — this file has no opinion on the
|
|
70
|
+
* error surface.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {unknown} client
|
|
75
|
+
* @param {readonly string[]} methods Method names that must be functions on the client.
|
|
76
|
+
* @param {(message: string) => never} wrap
|
|
77
|
+
* Called with a diagnostic message when the check fails; must throw.
|
|
78
|
+
* The caller's typed error class is emitted from inside this callback.
|
|
79
|
+
* @returns {void}
|
|
80
|
+
*/
|
|
81
|
+
function assertRedisClient(client, methods, wrap) {
|
|
82
|
+
if (!client || typeof client !== 'object') {
|
|
83
|
+
wrap('client is required — pass a Redis-compatible instance (ioredis / node-redis / @upstash/redis)');
|
|
84
|
+
}
|
|
85
|
+
const c = /** @type {Record<string, unknown>} */ (client);
|
|
86
|
+
for (const method of methods) {
|
|
87
|
+
if (typeof c[method] !== 'function') {
|
|
88
|
+
wrap(`client is missing '${method}()' — is this a Redis-compatible client?`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Shared IncrStore — atomic TTL counter for replay guards and rate
|
|
95
|
+
* limiting. Memory and Redis factories.
|
|
96
|
+
*
|
|
97
|
+
* Interface: `incr(key, ttlMs) → { count, expiresAt }`.
|
|
98
|
+
* First call arms a TTL and returns `{ count: 1 }`; subsequent calls
|
|
99
|
+
* before expiry increment the counter.
|
|
100
|
+
*
|
|
101
|
+
* Consumers: `@exortek/challenge`, `@exortek/magic-link` (rate limiter).
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
// Memory
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @typedef {object} MemoryIncrStoreOptions
|
|
109
|
+
* @property {number} [maxKeys=10000] Hard cap; oldest entry dropped when exceeded.
|
|
110
|
+
* @property {number} [sweepMs=60000] Interval for the background TTL sweep.
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {MemoryIncrStoreOptions} [options]
|
|
115
|
+
* @param {(msg: string) => never} [wrap] Error factory for validation.
|
|
116
|
+
* @returns {{ incr: (key: string, ttlMs: number) => Promise<{ count: number, expiresAt: number }>, _size: () => number, _stop: () => void }}
|
|
117
|
+
*/
|
|
118
|
+
function createMemoryIncrStore(options = {}, wrap) {
|
|
119
|
+
const fail =
|
|
120
|
+
wrap ??
|
|
121
|
+
(msg => {
|
|
122
|
+
throw new TypeError(msg);
|
|
123
|
+
});
|
|
124
|
+
const maxKeys = options.maxKeys ?? 10_000;
|
|
125
|
+
const sweepMs = options.sweepMs ?? 60_000;
|
|
126
|
+
|
|
127
|
+
if (!isInteger(maxKeys) || maxKeys < 1) {
|
|
128
|
+
fail(`memoryIncrStore.maxKeys must be a positive integer; got ${maxKeys}`);
|
|
129
|
+
}
|
|
130
|
+
if (!isInteger(sweepMs) || sweepMs < 1000) {
|
|
131
|
+
fail(`memoryIncrStore.sweepMs must be an integer >= 1000; got ${sweepMs}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** @type {Map<string, { count: number, expiresAt: number }>} */
|
|
135
|
+
const map = new Map();
|
|
136
|
+
let timer = null;
|
|
137
|
+
|
|
138
|
+
function peekFresh(key) {
|
|
139
|
+
const entry = map.get(key);
|
|
140
|
+
if (!entry) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
if (entry.expiresAt <= Date.now()) {
|
|
144
|
+
map.delete(key);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return entry;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sweep() {
|
|
151
|
+
const t = Date.now();
|
|
152
|
+
for (const [key, entry] of map) {
|
|
153
|
+
if (entry.expiresAt <= t) {
|
|
154
|
+
map.delete(key);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function scheduleSweeper() {
|
|
160
|
+
if (timer) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
timer = setInterval(sweep, sweepMs);
|
|
164
|
+
if (isFunction(timer.unref)) {
|
|
165
|
+
timer.unref();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function evictIfFull() {
|
|
170
|
+
if (map.size < maxKeys) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const oldest = map.keys().next().value;
|
|
174
|
+
if (!isUndefined(oldest)) {
|
|
175
|
+
map.delete(oldest);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
async incr(key, ttlMs) {
|
|
181
|
+
scheduleSweeper();
|
|
182
|
+
const existing = peekFresh(key);
|
|
183
|
+
if (existing) {
|
|
184
|
+
existing.count += 1;
|
|
185
|
+
map.delete(key);
|
|
186
|
+
map.set(key, existing);
|
|
187
|
+
return { count: existing.count, expiresAt: existing.expiresAt };
|
|
188
|
+
}
|
|
189
|
+
evictIfFull();
|
|
190
|
+
const expiresAt = Date.now() + ttlMs;
|
|
191
|
+
map.set(key, { count: 1, expiresAt });
|
|
192
|
+
return { count: 1, expiresAt };
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
_size: () => map.size,
|
|
196
|
+
_stop: () => {
|
|
197
|
+
if (timer) {
|
|
198
|
+
clearInterval(timer);
|
|
199
|
+
timer = null;
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Redis
|
|
206
|
+
|
|
207
|
+
const INCR_SCRIPT = `
|
|
208
|
+
local key = KEYS[1]
|
|
209
|
+
local ttl = tonumber(ARGV[1])
|
|
210
|
+
local count = redis.call('INCR', key)
|
|
211
|
+
local pttl
|
|
212
|
+
if count == 1 then
|
|
213
|
+
redis.call('PEXPIRE', key, ttl)
|
|
214
|
+
pttl = ttl
|
|
215
|
+
else
|
|
216
|
+
pttl = redis.call('PTTL', key)
|
|
217
|
+
if pttl < 0 then
|
|
218
|
+
redis.call('PEXPIRE', key, ttl)
|
|
219
|
+
pttl = ttl
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
return { count, pttl }
|
|
223
|
+
`.trim();
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* @typedef {object} RedisIncrStoreOptions
|
|
227
|
+
* @property {string} [keyPrefix=''] Prepended to every key.
|
|
228
|
+
*/
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @param {object} client
|
|
232
|
+
* @param {RedisIncrStoreOptions} [options]
|
|
233
|
+
* @param {(msg: string) => never} [wrap] Error factory for assertRedisClient.
|
|
234
|
+
* @returns {{ incr: (key: string, ttlMs: number) => Promise<{ count: number, expiresAt: number }> }}
|
|
235
|
+
*/
|
|
236
|
+
function createRedisIncrStore(client, options = {}, wrap) {
|
|
237
|
+
assertRedisClient(
|
|
238
|
+
client,
|
|
239
|
+
['eval'],
|
|
240
|
+
wrap ??
|
|
241
|
+
(msg => {
|
|
242
|
+
throw new TypeError(msg);
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
const keyPrefix = options.keyPrefix ?? '';
|
|
246
|
+
const k = key => `${keyPrefix}${key}`;
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
async incr(key, ttlMs) {
|
|
250
|
+
const raw = await client.eval(INCR_SCRIPT, 1, k(key), String(Math.max(1, Math.ceil(ttlMs))));
|
|
251
|
+
const arr = Array.isArray(raw) ? raw : [raw, ttlMs];
|
|
252
|
+
const count = Number(arr[0]);
|
|
253
|
+
const pttl = Number(arr[1]);
|
|
254
|
+
return { count, expiresAt: Date.now() + (Number.isFinite(pttl) && pttl > 0 ? pttl : ttlMs) };
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
60
259
|
/**
|
|
61
260
|
* Imperative single-argument guard helpers — the everyday
|
|
62
261
|
* `assertPositiveInt(x, 'name')` shape used at API boundaries.
|
|
@@ -522,121 +721,9 @@ const { invalidArgument} = defineGuards(
|
|
|
522
721
|
* @returns {import('./index.js').IncrStore & { _size: () => number, _stop: () => void }}
|
|
523
722
|
*/
|
|
524
723
|
function memoryStore(options = {}) {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
if (!isInteger(maxKeys) || maxKeys < 1) {
|
|
529
|
-
throw invalidArgument(`memoryStore.options.maxKeys must be a positive integer; got ${maxKeys}`);
|
|
530
|
-
}
|
|
531
|
-
if (!isInteger(sweepMs) || sweepMs < 1000) {
|
|
532
|
-
throw invalidArgument(`memoryStore.options.sweepMs must be an integer >= 1000; got ${sweepMs}`);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
/** @type {Map<string, { count: number, expiresAt: number }>} */
|
|
536
|
-
const map = new Map();
|
|
537
|
-
let timer = null;
|
|
538
|
-
|
|
539
|
-
function peekFresh(key) {
|
|
540
|
-
const entry = map.get(key);
|
|
541
|
-
if (!entry) {
|
|
542
|
-
return null;
|
|
543
|
-
}
|
|
544
|
-
if (entry.expiresAt <= Date.now()) {
|
|
545
|
-
map.delete(key);
|
|
546
|
-
return null;
|
|
547
|
-
}
|
|
548
|
-
return entry;
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
function sweep() {
|
|
552
|
-
const t = Date.now();
|
|
553
|
-
for (const [key, entry] of map) {
|
|
554
|
-
if (entry.expiresAt <= t) {
|
|
555
|
-
map.delete(key);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
function scheduleSweeper() {
|
|
561
|
-
if (timer) {
|
|
562
|
-
return;
|
|
563
|
-
}
|
|
564
|
-
timer = setInterval(sweep, sweepMs);
|
|
565
|
-
if (isFunction(timer.unref)) {
|
|
566
|
-
timer.unref();
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
function evictIfFull() {
|
|
571
|
-
if (map.size < maxKeys) {
|
|
572
|
-
return;
|
|
573
|
-
}
|
|
574
|
-
const oldest = map.keys().next().value;
|
|
575
|
-
if (!isUndefined(oldest)) {
|
|
576
|
-
map.delete(oldest);
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
return {
|
|
581
|
-
async incr(key, ttlMs) {
|
|
582
|
-
scheduleSweeper();
|
|
583
|
-
const existing = peekFresh(key);
|
|
584
|
-
if (existing) {
|
|
585
|
-
existing.count += 1;
|
|
586
|
-
// Refresh LRU position on activity — the replay-guard tombstone
|
|
587
|
-
// must stay warm until its TTL fires, or a third replay attempt
|
|
588
|
-
// could see the entry evicted and get a fresh count.
|
|
589
|
-
map.delete(key);
|
|
590
|
-
map.set(key, existing);
|
|
591
|
-
return { count: existing.count, expiresAt: existing.expiresAt };
|
|
592
|
-
}
|
|
593
|
-
evictIfFull();
|
|
594
|
-
const expiresAt = Date.now() + ttlMs;
|
|
595
|
-
map.set(key, { count: 1, expiresAt });
|
|
596
|
-
return { count: 1, expiresAt };
|
|
597
|
-
},
|
|
598
|
-
|
|
599
|
-
_size: () => map.size,
|
|
600
|
-
_stop: () => {
|
|
601
|
-
if (timer) {
|
|
602
|
-
clearInterval(timer);
|
|
603
|
-
timer = null;
|
|
604
|
-
}
|
|
605
|
-
},
|
|
606
|
-
};
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* Duck-type guard for a Redis-compatible client. Every `@exortek/*`
|
|
611
|
-
* package that ships a Redis store (jwt blacklist, session store,
|
|
612
|
-
* security rate-limit) does the same "not null + required methods
|
|
613
|
-
* are functions" check up front so a missing dependency surfaces as
|
|
614
|
-
* a clean typed error instead of `TypeError: client.get is not a
|
|
615
|
-
* function` deep in a store operation.
|
|
616
|
-
*
|
|
617
|
-
* Callers pass their own `wrap` callback that throws their typed
|
|
618
|
-
* error class with the message — this file has no opinion on the
|
|
619
|
-
* error surface.
|
|
620
|
-
*/
|
|
621
|
-
|
|
622
|
-
/**
|
|
623
|
-
* @param {unknown} client
|
|
624
|
-
* @param {readonly string[]} methods Method names that must be functions on the client.
|
|
625
|
-
* @param {(message: string) => never} wrap
|
|
626
|
-
* Called with a diagnostic message when the check fails; must throw.
|
|
627
|
-
* The caller's typed error class is emitted from inside this callback.
|
|
628
|
-
* @returns {void}
|
|
629
|
-
*/
|
|
630
|
-
function assertRedisClient(client, methods, wrap) {
|
|
631
|
-
if (!client || typeof client !== 'object') {
|
|
632
|
-
wrap('client is required — pass a Redis-compatible instance (ioredis / node-redis / @upstash/redis)');
|
|
633
|
-
}
|
|
634
|
-
const c = /** @type {Record<string, unknown>} */ (client);
|
|
635
|
-
for (const method of methods) {
|
|
636
|
-
if (typeof c[method] !== 'function') {
|
|
637
|
-
wrap(`client is missing '${method}()' — is this a Redis-compatible client?`);
|
|
638
|
-
}
|
|
639
|
-
}
|
|
724
|
+
return createMemoryIncrStore(options, msg => {
|
|
725
|
+
throw invalidArgument(msg);
|
|
726
|
+
});
|
|
640
727
|
}
|
|
641
728
|
|
|
642
729
|
/**
|
|
@@ -656,24 +743,6 @@ function assertRedisClient(client, methods, wrap) {
|
|
|
656
743
|
*/
|
|
657
744
|
|
|
658
745
|
|
|
659
|
-
const INCR_SCRIPT = `
|
|
660
|
-
local key = KEYS[1]
|
|
661
|
-
local ttl = tonumber(ARGV[1])
|
|
662
|
-
local count = redis.call('INCR', key)
|
|
663
|
-
local pttl
|
|
664
|
-
if count == 1 then
|
|
665
|
-
redis.call('PEXPIRE', key, ttl)
|
|
666
|
-
pttl = ttl
|
|
667
|
-
else
|
|
668
|
-
pttl = redis.call('PTTL', key)
|
|
669
|
-
if pttl < 0 then
|
|
670
|
-
redis.call('PEXPIRE', key, ttl)
|
|
671
|
-
pttl = ttl
|
|
672
|
-
end
|
|
673
|
-
end
|
|
674
|
-
return { count, pttl }
|
|
675
|
-
`.trim();
|
|
676
|
-
|
|
677
746
|
/**
|
|
678
747
|
* @typedef {object} RedisStoreOptions
|
|
679
748
|
* @property {string} [keyPrefix='chall:'] Prepended to every key.
|
|
@@ -685,23 +754,9 @@ return { count, pttl }
|
|
|
685
754
|
* @returns {import('./index.js').IncrStore}
|
|
686
755
|
*/
|
|
687
756
|
function redisStore(client, options = {}) {
|
|
688
|
-
|
|
757
|
+
return createRedisIncrStore(client, { keyPrefix: options.keyPrefix ?? 'chall:' }, msg => {
|
|
689
758
|
throw invalidArgument(`redisStore.client: ${msg}`);
|
|
690
759
|
});
|
|
691
|
-
const keyPrefix = options.keyPrefix ?? 'chall:';
|
|
692
|
-
const k = key => `${keyPrefix}${key}`;
|
|
693
|
-
|
|
694
|
-
return {
|
|
695
|
-
async incr(key, ttlMs) {
|
|
696
|
-
const raw = await client.eval(INCR_SCRIPT, 1, k(key), String(Math.max(1, Math.ceil(ttlMs))));
|
|
697
|
-
// ioredis returns [count, pttl] as numbers; node-redis v4 does too;
|
|
698
|
-
// Upstash HTTP driver returns strings. Coerce defensively.
|
|
699
|
-
const arr = Array.isArray(raw) ? raw : [raw, ttlMs];
|
|
700
|
-
const count = Number(arr[0]);
|
|
701
|
-
const pttl = Number(arr[1]);
|
|
702
|
-
return { count, expiresAt: Date.now() + (Number.isFinite(pttl) && pttl > 0 ? pttl : ttlMs) };
|
|
703
|
-
},
|
|
704
|
-
};
|
|
705
760
|
}
|
|
706
761
|
|
|
707
762
|
export { memoryStore, redisStore };
|
package/dist/token.d.ts
CHANGED
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Token codec for `@exortek/challenge`.
|
|
3
|
-
*
|
|
4
|
-
* Format:
|
|
5
|
-
*
|
|
6
|
-
* <prefix>.<base64url(JSON payload)>.<base64url(HMAC-SHA256 tag)>
|
|
7
|
-
*
|
|
8
|
-
* The default prefix is {@link DEFAULT_PREFIX} (`chall_v1`) — deliberately
|
|
9
|
-
* unlike a JWT so the two token families cannot be confused at a call
|
|
10
|
-
* site. Callers may override it (e.g. `'server_challenge'`,
|
|
11
|
-
* `'myapp_v1'`) to brand the wire format for a specific service; the
|
|
12
|
-
* same prefix must be used at create and verify time, or verification
|
|
13
|
-
* returns `reason: 'malformed'`.
|
|
14
|
-
*
|
|
15
|
-
* HMAC covers `<prefix>.<b64u payload>` — the same string the caller
|
|
16
|
-
* received minus the trailing tag. Any change to prefix or payload
|
|
17
|
-
* invalidates the signature; a token minted with one prefix cannot be
|
|
18
|
-
* accepted under another even by the same secret.
|
|
19
|
-
*/
|
|
20
|
-
export declare const DEFAULT_PREFIX = "chall_v1";
|
|
21
1
|
/**
|
|
22
2
|
* Assert a prefix is well-shaped. Throws via the caller's `invalidArg`
|
|
23
3
|
* function so `createChallenge` / `verifyChallenge` can raise
|
|
@@ -29,7 +9,7 @@ export declare const DEFAULT_PREFIX = "chall_v1";
|
|
|
29
9
|
* @param {(msg: string) => Error} invalidArg
|
|
30
10
|
* @returns {string}
|
|
31
11
|
*/
|
|
32
|
-
export
|
|
12
|
+
export function assertPrefix(prefix: string, name: string, invalidArg: (msg: string) => Error): string;
|
|
33
13
|
/**
|
|
34
14
|
* Random ID for the token's `jti` claim — 128 bits of entropy, encoded
|
|
35
15
|
* as 22 base64url characters. Used as the store key for single-use
|
|
@@ -37,7 +17,7 @@ export declare function assertPrefix(prefix: string, name: string, invalidArg: (
|
|
|
37
17
|
*
|
|
38
18
|
* @returns {string}
|
|
39
19
|
*/
|
|
40
|
-
export
|
|
20
|
+
export function newJti(): string;
|
|
41
21
|
/**
|
|
42
22
|
* Sign a payload with `secret` and return the compact token string.
|
|
43
23
|
*
|
|
@@ -46,7 +26,7 @@ export declare function newJti(): string;
|
|
|
46
26
|
* @param {string} [prefix] Wire prefix; defaults to {@link DEFAULT_PREFIX}.
|
|
47
27
|
* @returns {string}
|
|
48
28
|
*/
|
|
49
|
-
export
|
|
29
|
+
export function sign(payload: object, secret: Buffer, prefix?: string): string;
|
|
50
30
|
/**
|
|
51
31
|
* Parse + HMAC-verify a token. Returns the decoded payload on success,
|
|
52
32
|
* or a reason string on any failure. Never throws on user-input
|
|
@@ -57,8 +37,9 @@ export declare function sign(payload: object, secret: Buffer, prefix?: string):
|
|
|
57
37
|
* @param {string} [prefix] Expected prefix; defaults to {@link DEFAULT_PREFIX}.
|
|
58
38
|
* @returns {{ payload: object } | { reason: 'malformed' | 'bad_signature' }}
|
|
59
39
|
*/
|
|
60
|
-
export
|
|
40
|
+
export function decode(token: string, secret: Buffer, prefix?: string): {
|
|
61
41
|
payload: object;
|
|
62
42
|
} | {
|
|
63
|
-
reason:
|
|
43
|
+
reason: "malformed" | "bad_signature";
|
|
64
44
|
};
|
|
45
|
+
export const DEFAULT_PREFIX: "chall_v1";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exortek/challenge",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Signed, single-use challenge tokens for multi-step auth flows on Node.js — carries userId / method / step / metadata across a redirect or a second request without a server-side session; HMAC-signed, expiring, optional IP-binding, single-use enforcement via any @exortek/security store.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"dist",
|
|
24
24
|
"README.md",
|
|
25
|
+
"CHANGELOG.md",
|
|
25
26
|
"LICENSE"
|
|
26
27
|
],
|
|
27
28
|
"scripts": {
|
|
@@ -62,9 +63,6 @@
|
|
|
62
63
|
"engines": {
|
|
63
64
|
"node": ">=22.0.0"
|
|
64
65
|
},
|
|
65
|
-
"devDependencies": {
|
|
66
|
-
"@exortek/shared": "0.0.0"
|
|
67
|
-
},
|
|
68
66
|
"publishConfig": {
|
|
69
67
|
"access": "public"
|
|
70
68
|
}
|