@exortek/challenge 1.0.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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/dist/errors.d.ts +26 -0
- package/dist/index.cjs +1250 -0
- package/dist/index.d.ts +252 -0
- package/dist/index.mjs +1245 -0
- package/dist/internal/guards.d.ts +8 -0
- package/dist/stores/index.d.ts +28 -0
- package/dist/stores/memory.d.ts +50 -0
- package/dist/stores/redis.d.ts +31 -0
- package/dist/stores.cjs +710 -0
- package/dist/stores.mjs +707 -0
- package/dist/token.d.ts +64 -0
- package/package.json +71 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
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
|
+
/**
|
|
129
|
+
* @typedef {'totp' | 'hotp' | 'email_otp' | 'sms_otp' | 'backup_code'
|
|
130
|
+
* | 'passkey' | 'magic_link' | 'password' | 'webauthn' | 'oauth' | 'oidc'
|
|
131
|
+
* | string} ChallengeMethod
|
|
132
|
+
*/
|
|
133
|
+
/**
|
|
134
|
+
* @typedef {object} IncrStore
|
|
135
|
+
* @property {(key: string, ttlMs: number) => Promise<{ count: number }>} incr
|
|
136
|
+
* Atomic increment-with-expiry. First call returns `{ count: 1 }` and
|
|
137
|
+
* arms a TTL; subsequent calls before expiry return the incremented
|
|
138
|
+
* count. Used as compare-and-set for single-use enforcement.
|
|
139
|
+
*/
|
|
140
|
+
/**
|
|
141
|
+
* @typedef {object} ChallengePayload
|
|
142
|
+
* @property {string} jti
|
|
143
|
+
* @property {number} iat
|
|
144
|
+
* @property {number} exp
|
|
145
|
+
* @property {string} [userId]
|
|
146
|
+
* @property {ChallengeMethod} [method]
|
|
147
|
+
* @property {string} [step]
|
|
148
|
+
* @property {string} [nextStep]
|
|
149
|
+
* @property {string} [ip] Only set when `ipBinding: true`.
|
|
150
|
+
* @property {string} [ua] Only set when `ua` supplied.
|
|
151
|
+
* @property {Record<string, unknown>} [meta]
|
|
152
|
+
*/
|
|
153
|
+
/**
|
|
154
|
+
* @typedef {object} CreateChallengeOptions
|
|
155
|
+
* @property {string | Buffer | Uint8Array} secret
|
|
156
|
+
* HMAC-SHA256 secret. **Must be at least 32 raw bytes.** A string is
|
|
157
|
+
* interpreted as UTF-8 — for a hex or base64 secret, decode to
|
|
158
|
+
* Buffer first.
|
|
159
|
+
* @property {string} [userId]
|
|
160
|
+
* @property {ChallengeMethod} [method]
|
|
161
|
+
* @property {string} [step]
|
|
162
|
+
* @property {string} [nextStep]
|
|
163
|
+
* @property {string} [ip]
|
|
164
|
+
* @property {string} [ua]
|
|
165
|
+
* @property {Record<string, unknown>} [metadata]
|
|
166
|
+
* @property {string | number} expiresIn Duration string (`'5m'`) or ms integer.
|
|
167
|
+
* @property {boolean} [singleUse=false]
|
|
168
|
+
* When true, the returned token can only be verified once — subsequent
|
|
169
|
+
* verifies with `consume: true` fail with `reason: 'replay'`. Requires
|
|
170
|
+
* `store` to be supplied.
|
|
171
|
+
* @property {IncrStore} [store]
|
|
172
|
+
* Any object exposing `incr(key, ttlMs) → { count }`. Compatible with
|
|
173
|
+
* `@exortek/security`'s rate-limit stores; also easy to wrap Redis.
|
|
174
|
+
* @property {boolean} [ipBinding=false]
|
|
175
|
+
* When true, the caller-supplied `ip` is stamped into the payload and
|
|
176
|
+
* `verifyChallenge` will reject a request whose `ip` differs.
|
|
177
|
+
* @property {string} [prefix='chall_v1']
|
|
178
|
+
* Wire-format prefix. Defaults to `'chall_v1'` — the value shipped
|
|
179
|
+
* with this package. Callers can override to brand the token
|
|
180
|
+
* family (e.g. `'server_challenge'`, `'myapp_v1'`); must match
|
|
181
|
+
* `/^[A-Za-z0-9_-]{1,32}$/`. The same prefix must be passed at
|
|
182
|
+
* verify time or verification returns `reason: 'malformed'`.
|
|
183
|
+
* @property {number} [now] Override `Date.now()` for testing.
|
|
184
|
+
*/
|
|
185
|
+
/**
|
|
186
|
+
* @typedef {object} VerifyChallengeOptions
|
|
187
|
+
* @property {string | Buffer | Uint8Array} secret
|
|
188
|
+
* @property {boolean} [consume=false]
|
|
189
|
+
* Enforce single-use. Requires `store` (typically the same one used
|
|
190
|
+
* at create time).
|
|
191
|
+
* @property {IncrStore} [store]
|
|
192
|
+
* @property {string} [expectedUserId]
|
|
193
|
+
* @property {ChallengeMethod} [expectedMethod]
|
|
194
|
+
* @property {string} [expectedStep]
|
|
195
|
+
* @property {string} [expectedNextStep]
|
|
196
|
+
* @property {string} [ip]
|
|
197
|
+
* The current request's IP. Required to verify a token that was
|
|
198
|
+
* created with `ipBinding: true`; ignored otherwise.
|
|
199
|
+
* @property {string} [prefix='chall_v1']
|
|
200
|
+
* Wire-format prefix. Must match the value passed to
|
|
201
|
+
* `createChallenge` — a token minted with a different prefix will
|
|
202
|
+
* fail with `reason: 'malformed'`.
|
|
203
|
+
* @property {number} [now] Override `Date.now()` for testing.
|
|
204
|
+
*/
|
|
205
|
+
/**
|
|
206
|
+
* @typedef {'malformed' | 'bad_signature' | 'expired' | 'not_yet_valid'
|
|
207
|
+
* | 'user_mismatch' | 'method_mismatch' | 'step_mismatch'
|
|
208
|
+
* | 'next_step_mismatch' | 'ip_mismatch' | 'ip_missing' | 'replay'
|
|
209
|
+
* | 'store_unavailable'} VerifyFailureReason
|
|
210
|
+
*/
|
|
211
|
+
/**
|
|
212
|
+
* @typedef {{ valid: true, payload: ChallengePayload }
|
|
213
|
+
* | { valid: false, reason: VerifyFailureReason }} VerifyChallengeResult
|
|
214
|
+
*/
|
|
215
|
+
/**
|
|
216
|
+
* Create a signed challenge token.
|
|
217
|
+
*
|
|
218
|
+
* const token = await createChallenge({
|
|
219
|
+
* secret: process.env.CHALLENGE_SECRET,
|
|
220
|
+
* userId: 'usr_123',
|
|
221
|
+
* method: 'totp',
|
|
222
|
+
* step: 'mfa_verified',
|
|
223
|
+
* nextStep: 'login',
|
|
224
|
+
* expiresIn: '5m',
|
|
225
|
+
* singleUse: true,
|
|
226
|
+
* store,
|
|
227
|
+
* })
|
|
228
|
+
*
|
|
229
|
+
* @param {CreateChallengeOptions} options
|
|
230
|
+
* @returns {Promise<string>}
|
|
231
|
+
*/
|
|
232
|
+
export declare function createChallenge(options: CreateChallengeOptions): Promise<string>;
|
|
233
|
+
/**
|
|
234
|
+
* Verify a challenge token. Returns `{ valid: true, payload }` on
|
|
235
|
+
* success or `{ valid: false, reason }` on any expected failure. Only
|
|
236
|
+
* throws on programmer errors (bad options, missing secret).
|
|
237
|
+
*
|
|
238
|
+
* const res = await verifyChallenge(token, {
|
|
239
|
+
* secret: process.env.CHALLENGE_SECRET,
|
|
240
|
+
* consume: true,
|
|
241
|
+
* store,
|
|
242
|
+
* expectedUserId: pendingUserId,
|
|
243
|
+
* expectedMethod: 'totp',
|
|
244
|
+
* ip: req.ip,
|
|
245
|
+
* })
|
|
246
|
+
* if (!res.valid) return reply.code(401).send({ error: res.reason })
|
|
247
|
+
*
|
|
248
|
+
* @param {string} token
|
|
249
|
+
* @param {VerifyChallengeOptions} options
|
|
250
|
+
* @returns {Promise<VerifyChallengeResult>}
|
|
251
|
+
*/
|
|
252
|
+
export declare function verifyChallenge(token: string, options: VerifyChallengeOptions): Promise<VerifyChallengeResult>;
|