@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.
@@ -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>;