@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/dist/index.cjs ADDED
@@ -0,0 +1,1250 @@
1
+ 'use strict';
2
+
3
+ var node_crypto = require('node:crypto');
4
+
5
+ /**
6
+ * Human-duration parser — the single source of truth across the
7
+ * `@exortek/*` stack.
8
+ *
9
+ * Accepts:
10
+ * - `number` → **milliseconds** (Node's native time unit —
11
+ * `setTimeout`, `Date.now`, `PerformanceObserver` all use ms).
12
+ * Consumer packages that historically treated bare numbers as
13
+ * seconds (jwt, session) do the conversion at their own
14
+ * surface boundary if they want to preserve that convention.
15
+ * - `string` with a unit suffix — long / plural / short forms all
16
+ * work. Whitespace and fractional values tolerated on the input
17
+ * side; the output is always integer milliseconds after rounding.
18
+ *
19
+ * Returns: **milliseconds**. Callers that need integer seconds
20
+ * (e.g. JWT `exp` claims per RFC 7519 NumericDate) divide by 1000 at
21
+ * their surface boundary.
22
+ *
23
+ * Supported units:
24
+ *
25
+ * | Unit | Short | Long / plural |
26
+ * | ---- | ------------------ | ---------------------------------- |
27
+ * | ms | `ms` | `millisecond`, `milliseconds` |
28
+ * | s | `s`, `sec` | `second`, `seconds` |
29
+ * | m | `m`, `min` | `minute`, `minutes` |
30
+ * | h | `h`, `hr` | `hour`, `hours` |
31
+ * | d | `d` | `day`, `days` |
32
+ * | w | `w`, `wk` | `week`, `weeks` |
33
+ * | y | `y`, `yr` | `year`, `years` (365 d) |
34
+ *
35
+ * Deliberately strict — anything the parser doesn't understand throws
36
+ * instead of silently defaulting.
37
+ */
38
+
39
+ const UNIT_MS = Object.freeze({
40
+ ms: 1,
41
+ millisecond: 1,
42
+ milliseconds: 1,
43
+ s: 1000,
44
+ sec: 1000,
45
+ second: 1000,
46
+ seconds: 1000,
47
+ m: 60_000,
48
+ min: 60_000,
49
+ minute: 60_000,
50
+ minutes: 60_000,
51
+ h: 3_600_000,
52
+ hr: 3_600_000,
53
+ hour: 3_600_000,
54
+ hours: 3_600_000,
55
+ d: 86_400_000,
56
+ day: 86_400_000,
57
+ days: 86_400_000,
58
+ w: 604_800_000,
59
+ wk: 604_800_000,
60
+ week: 604_800_000,
61
+ weeks: 604_800_000,
62
+ y: 31_536_000_000,
63
+ yr: 31_536_000_000,
64
+ year: 31_536_000_000,
65
+ years: 31_536_000_000,
66
+ });
67
+
68
+ const DURATION_RE = /^\s*(-?\d+(?:\.\d+)?)\s*([a-z]+)?\s*$/i;
69
+
70
+ const SUPPORTED_UNITS = 'ms, s, m, h, d, w, y (and long/plural forms)';
71
+
72
+ /**
73
+ * Parse a human duration to milliseconds.
74
+ *
75
+ * @param {string | number} input
76
+ * @returns {number} milliseconds (integer)
77
+ * @throws {Error} on malformed input
78
+ *
79
+ * @example
80
+ * parseDuration(900) // → 900 (ms)
81
+ * parseDuration('900') // → 900 (unit-less string = ms too)
82
+ * parseDuration('500ms') // → 500
83
+ * parseDuration('15m') // → 900_000
84
+ * parseDuration('2 hours') // → 7_200_000
85
+ * parseDuration('7d') // → 604_800_000
86
+ */
87
+ function parseDuration(input) {
88
+ if (typeof input === 'number') {
89
+ if (!Number.isFinite(input)) {
90
+ throw new Error(`parseDuration: numeric input must be finite; got ${input}`);
91
+ }
92
+ return Math.round(input);
93
+ }
94
+ if (typeof input !== 'string') {
95
+ throw new TypeError(`parseDuration: expected string or number; got ${typeof input}`);
96
+ }
97
+
98
+ const match = DURATION_RE.exec(input);
99
+ if (!match) {
100
+ throw new Error(
101
+ `parseDuration: could not parse ${JSON.stringify(input)}. Examples: 900 (ms), '15m', '2h', '7d', '500ms', '30s'.`,
102
+ );
103
+ }
104
+ const value = Number(match[1]);
105
+ const unit = (match[2] || 'ms').toLowerCase();
106
+ const multiplier = UNIT_MS[/** @type {keyof typeof UNIT_MS} */ (unit)];
107
+ if (multiplier === undefined) {
108
+ throw new Error(`parseDuration: unknown time unit ${JSON.stringify(unit)}. Supported: ${SUPPORTED_UNITS}.`);
109
+ }
110
+ return Math.round(value * multiplier);
111
+ }
112
+
113
+ /**
114
+ * Non-throwing type predicates. Companion to `asserts.js`.
115
+ *
116
+ * ### When to reach for which
117
+ *
118
+ * - Writing a **throw-guard** at a function boundary — a bad shape
119
+ * should fail with the caller's typed error class? Use
120
+ * `asserts` + `defineGuards` (`assertObject`, `assertString`, …).
121
+ * The `assert*` family bundles the check with the correctly-bound
122
+ * throw and preserves the "one error class per package" contract.
123
+ *
124
+ * - Writing a **dispatch / branch / fallback** where the wrong shape
125
+ * is meant to be handled, not thrown? Use these predicates.
126
+ * Typical shapes:
127
+ *
128
+ * if (isObject(config.store)) { ... } else { ... }
129
+ *
130
+ * const isManager = isObject(v) && isFunction(v.issue);
131
+ *
132
+ * The two APIs deliberately do not overlap in intent: `assert*` throws,
133
+ * `is*` never does. Bypassing an `assert*` with an `if (!is*(x)) throw
134
+ * new PkgError(...)` pattern skips the package's guards binding and
135
+ * duplicates work the assert family already handles — don't.
136
+ */
137
+
138
+ /**
139
+ * `true` when `value` is a plain object *or* a class instance — anything
140
+ * whose property access is safe. Rejects `null`, arrays, and every
141
+ * primitive.
142
+ *
143
+ * The Node convention `typeof x === 'object' && x !== null` accepts
144
+ * arrays, which almost never matches caller intent (an array is not a
145
+ * "config object"). This predicate additionally rules arrays out, so
146
+ * `isObject(x)` really means "I can read `x.foo` without exploding on
147
+ * a numeric index".
148
+ *
149
+ * Class instances (Node `KeyObject`, `URL`, user-defined classes) pass
150
+ * because they carry `typeof === 'object'` — the check is duck-typed,
151
+ * not prototype-restricted.
152
+ *
153
+ * @param {unknown} value
154
+ * @returns {boolean}
155
+ */
156
+ function isObject(value) {
157
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
158
+ }
159
+
160
+ /**
161
+ * `true` when `value` is a string primitive. `String` boxed objects
162
+ * (from `new String('x')`) are refused — those are a footgun almost no
163
+ * one wants, and every place in this codebase reads strings by
164
+ * primitive access.
165
+ *
166
+ * @param {unknown} value
167
+ * @returns {boolean}
168
+ */
169
+ function isString(value) {
170
+ return typeof value === 'string';
171
+ }
172
+
173
+ /**
174
+ * `true` when `value` is a function. Class constructors, arrow
175
+ * functions, and generator functions all pass — `typeof` on any
176
+ * callable is `'function'`.
177
+ *
178
+ * @param {unknown} value
179
+ * @returns {boolean}
180
+ */
181
+ function isFunction(value) {
182
+ return typeof value === 'function';
183
+ }
184
+
185
+ /**
186
+ * `true` when `value` is a `Buffer` or a `Uint8Array` — the two byte
187
+ * shapes every crypto surface in this repo accepts interchangeably.
188
+ * Rejects strings so dispatch code can decide whether a string input
189
+ * means "already-encoded bytes" (decode it) or "wrong type" (branch).
190
+ *
191
+ * @param {unknown} value
192
+ * @returns {boolean}
193
+ */
194
+ function isBytes(value) {
195
+ return Buffer.isBuffer(value) || value instanceof Uint8Array;
196
+ }
197
+
198
+ /**
199
+ * `true` when `value` is a number primitive. Rejects `NaN`, which is
200
+ * technically a number but almost never what the caller intends.
201
+ *
202
+ * @param {unknown} value
203
+ * @returns {boolean}
204
+ */
205
+ function isNumber(value) {
206
+ return typeof value === 'number' && !Number.isNaN(value);
207
+ }
208
+
209
+ /**
210
+ * `true` when `value` is `undefined`.
211
+ *
212
+ * @param {unknown} value
213
+ * @returns {boolean}
214
+ */
215
+ function isUndefined(value) {
216
+ return typeof value === 'undefined';
217
+ }
218
+
219
+ /**
220
+ * `true` when `value` is a Node.js `Buffer`. Uint8Array-only shapes are
221
+ * refused — use {@link isBytes} when either satisfies the contract.
222
+ *
223
+ * @param {unknown} value
224
+ * @returns {boolean}
225
+ */
226
+ function isBuffer(value) {
227
+ return Buffer.isBuffer(value);
228
+ }
229
+
230
+ /**
231
+ * `true` for a finite `number` — `NaN`, `Infinity`, and `-Infinity` all
232
+ * fail. Use when the caller needs a number they can compute with;
233
+ * mirrors `Number.isFinite` semantics under a predicate-style name.
234
+ *
235
+ * @param {unknown} value
236
+ * @returns {boolean}
237
+ */
238
+ function isFiniteNumber(value) {
239
+ return typeof value === 'number' && Number.isFinite(value);
240
+ }
241
+
242
+ /**
243
+ * Shared base error class — the single error structure behind every
244
+ * `@exortek/*` package's `errors.js`.
245
+ *
246
+ * Every package keeps its own class identity with a one-liner subclass;
247
+ * codes stay per-package frozen maps, status mapping is declared as a
248
+ * static field:
249
+ *
250
+ * import { BaseError } from '@exortek/shared/errors';
251
+ *
252
+ * export const ErrorCode = Object.freeze({
253
+ * INVALID_ARGUMENT: 'INVALID_ARGUMENT',
254
+ * INVALID_TOKEN: 'INVALID_TOKEN',
255
+ * });
256
+ *
257
+ * export class JwtError extends BaseError {
258
+ * static statuses = { INVALID_ARGUMENT: 400, INVALID_TOKEN: 401 };
259
+ * static defaultStatus = 500;
260
+ * }
261
+ *
262
+ * Instances carry a stable machine-readable `code` (branch on this,
263
+ * never on the message), an optional HTTP `status`, an optional
264
+ * `details` object, and the standard `cause` chain.
265
+ */
266
+ class BaseError extends Error {
267
+ /**
268
+ * Optional `code → HTTP status` map declared on the subclass. When
269
+ * absent the instance carries no `status` at all — for HTTP-agnostic
270
+ * packages like `@exortek/crypto`.
271
+ *
272
+ * @type {Record<string, number> | undefined}
273
+ */
274
+ static statuses = undefined;
275
+
276
+ /**
277
+ * Fallback status for codes missing from `statuses`.
278
+ *
279
+ * @type {number}
280
+ */
281
+ static defaultStatus = 500;
282
+
283
+ /**
284
+ * @param {string} code Stable machine-readable code; branch on this.
285
+ * @param {string} message Human-readable diagnostic. Free-form; may
286
+ * change across versions.
287
+ * @param {{ cause?: unknown, status?: number, details?: Record<string, unknown> }} [options]
288
+ */
289
+ constructor(code, message, options = {}) {
290
+ super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
291
+ this.name = new.target.name;
292
+ /** @type {string} */
293
+ this.code = code;
294
+ const statuses = /** @type {typeof BaseError} */ (new.target).statuses;
295
+ if (options.status !== undefined) {
296
+ /** @type {number | undefined} */
297
+ this.status = options.status;
298
+ } else if (statuses !== undefined) {
299
+ // Object.hasOwn guards against inherited-property lookups for
300
+ // exotic code values ('toString', 'constructor', …).
301
+ this.status = Object.hasOwn(statuses, code)
302
+ ? statuses[code]
303
+ : /** @type {typeof BaseError} */ (new.target).defaultStatus;
304
+ }
305
+ if (options.details) {
306
+ /** @type {Record<string, unknown> | undefined} */
307
+ this.details = options.details;
308
+ }
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Stable machine-readable codes for every programmer-error failure that
314
+ * `@exortek/challenge` can raise. Branch on `code`, never on the
315
+ * message. Note: expected verify failures (bad signature, expired,
316
+ * mismatched claim) are NOT thrown — `verifyChallenge` returns
317
+ * `{ valid: false, reason }` for those so a wrong or stale token is a
318
+ * normal auth outcome, not an exception. Errors below fire only when
319
+ * the caller configured something wrong.
320
+ */
321
+
322
+ const ErrorCode = Object.freeze({
323
+ INVALID_ARGUMENT: 'INVALID_ARGUMENT',
324
+ INVALID_SECRET: 'INVALID_SECRET',
325
+ });
326
+
327
+ /**
328
+ * Every recoverable failure raised by this package. Carries a stable
329
+ * `code` (from {@link ErrorCode}) and a `status` — the HTTP response
330
+ * status a middleware layer would use when translating the error.
331
+ */
332
+ class ChallengeError extends BaseError {
333
+ static statuses = {
334
+ [ErrorCode.INVALID_ARGUMENT]: 400,
335
+ [ErrorCode.INVALID_SECRET]: 400,
336
+ };
337
+ static defaultStatus = 500;
338
+ }
339
+
340
+ /**
341
+ * Imperative single-argument guard helpers — the everyday
342
+ * `assertPositiveInt(x, 'name')` shape used at API boundaries.
343
+ * Companion to the compound schema builder in
344
+ * `@exortek/shared/validate`: **schema** for whole options objects,
345
+ * **asserts** for one-liner argument guards at the call site.
346
+ *
347
+ * The consumer surface is {@link defineGuards} (or the raw
348
+ * {@link bindAsserts} for full control). Each package binds the assert
349
+ * set to its own typed error class once (in `internal/guards.js`), so
350
+ * every argument failure throws that package's class —
351
+ * `err instanceof CryptoError` holds and users see at a glance which
352
+ * package raised the error. The bound `parse` bridges
353
+ * `@exortek/shared/validate` schemas to the same error class.
354
+ *
355
+ * **Path naming convention** (used as the `name` argument):
356
+ *
357
+ * `<publicFunction>[.options|.config][.<field>]`
358
+ *
359
+ * - `assertString(name, 'createUser.name')` — top-level arg
360
+ * - `assertPositiveInt(n, 'scrypt.options.r')` — nested option
361
+ * - `assertBytesOrString(pwd, 'pepper.wrap.password')` — method arg
362
+ *
363
+ * Keep the path short: the emitted error is `"<name> must be <desc>"`,
364
+ * so a stack-line grep already tells the caller which function fired.
365
+ * Reserve `hint` for actionable follow-ups ("pass the bytes returned by
366
+ * encryptSymmetric()"), not for restating the field.
367
+ */
368
+
369
+ /**
370
+ * @typedef {{ cause?: unknown }} WrapExtra
371
+ * Extra options forwarded to the wrap function, so `invalidArgument`
372
+ * sites that catch an underlying error can preserve the `cause` chain
373
+ * without falling back to a raw `new PackageError(...)`.
374
+ *
375
+ * @typedef {(message: string, extra?: WrapExtra) => Error} WrapFn
376
+ * Constructs (never throws) the binding package's error, e.g.
377
+ * `(m, { cause } = {}) => new CryptoError(ErrorCode.INVALID_ARGUMENT, m, { cause })`.
378
+ *
379
+ * @typedef {{ hint?: string }} AssertOptions
380
+ * `hint` is appended to the message after an em-dash — use it for
381
+ * actionable guidance ("pass the exact bytes returned by …").
382
+ *
383
+ * @typedef {{ safeParse: (input: unknown, path?: string) => { ok: true, value: unknown } | { ok: false, errors: string[] } }} ParseableSchema
384
+ */
385
+
386
+ /**
387
+ * Build the failure message. `description` completes the sentence
388
+ * `"<name> must be <description>"`; `hint` (optional) follows an
389
+ * em-dash.
390
+ *
391
+ * @param {string} name
392
+ * @param {string} description
393
+ * @param {string} [hint]
394
+ * @returns {string}
395
+ */
396
+ function message(name, description, hint) {
397
+ return `${name} must be ${description}${hint ? ` — ${hint}` : ''}`;
398
+ }
399
+
400
+ const ENCODINGS = new Set(['hex', 'base64', 'base64url']);
401
+
402
+ /**
403
+ * Bind the assert set to a package's error class. Call once per
404
+ * package (conventionally in `internal/guards.js`) and import the
405
+ * bound object everywhere:
406
+ *
407
+ * import { bindAsserts } from '@exortek/shared/asserts';
408
+ * import { CryptoError, ErrorCode } from '../errors.js';
409
+ * export const g = bindAsserts((m) => new CryptoError(ErrorCode.INVALID_ARGUMENT, m));
410
+ *
411
+ * // call sites:
412
+ * g.assertPositiveInt(options.iterations, 'options.iterations');
413
+ * const cfg = g.parse(OptionsSchema, options, 'options');
414
+ *
415
+ * @param {WrapFn} wrap
416
+ */
417
+ function bindAsserts(wrap) {
418
+ /**
419
+ * @param {string} name
420
+ * @param {string} description
421
+ * @param {AssertOptions} [opts]
422
+ * @returns {never}
423
+ */
424
+ function fail(name, description, opts) {
425
+ throw wrap(message(name, description, opts?.hint));
426
+ }
427
+
428
+ /**
429
+ * Assert that `value` is a plain object (not `null`, not an array,
430
+ * not a primitive).
431
+ * @param {unknown} value
432
+ * @param {string} name
433
+ * @param {AssertOptions} [opts]
434
+ */
435
+ function assertObject(value, name, opts) {
436
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
437
+ fail(name, 'an object', opts);
438
+ }
439
+ }
440
+
441
+ return {
442
+ /**
443
+ * Construct (not throw) the bound error with a free-form message —
444
+ * for `throw g.invalidArgument('…')` sites that don't fit the
445
+ * `X must be Y` shape (canonicalisation errors, cross-field
446
+ * constraint failures). Pass `{ cause }` when the argument-shape
447
+ * failure was triggered by an underlying throw whose chain matters
448
+ * (e.g. `JSON.stringify` on a bigint).
449
+ *
450
+ * @param {string} msg
451
+ * @param {WrapExtra} [extra]
452
+ * @returns {Error}
453
+ */
454
+ invalidArgument(msg, extra) {
455
+ return wrap(msg, extra);
456
+ },
457
+
458
+ /**
459
+ * Validate `input` against a `@exortek/shared/validate` schema;
460
+ * failures throw the bound error carrying every collected message.
461
+ * The bridge that keeps schema validation on the package's own
462
+ * error surface.
463
+ *
464
+ * @param {ParseableSchema} schema
465
+ * @param {unknown} input
466
+ * @param {string} [path='options']
467
+ * @returns {unknown} the parsed (possibly defaulted) value
468
+ */
469
+ parse(schema, input, path = 'options') {
470
+ const r = schema.safeParse(input, path);
471
+ if (!r.ok) {
472
+ throw wrap(r.errors.join('; '));
473
+ }
474
+ return r.value;
475
+ },
476
+
477
+ /**
478
+ * Assert that `value` is a non-negative safe integer (`0, 1, 2, …`).
479
+ * @param {unknown} value
480
+ * @param {string} name Argument name to include in the error message.
481
+ * @param {AssertOptions} [opts]
482
+ */
483
+ assertNonNegativeInt(value, name, opts) {
484
+ if (!Number.isSafeInteger(value) || /** @type {number} */ (value) < 0) {
485
+ fail(name, 'a non-negative safe integer', opts);
486
+ }
487
+ },
488
+
489
+ /**
490
+ * Assert that `value` is a strictly positive safe integer (`1, 2, 3, …`).
491
+ * @param {unknown} value
492
+ * @param {string} name
493
+ * @param {AssertOptions} [opts]
494
+ */
495
+ assertPositiveInt(value, name, opts) {
496
+ if (!Number.isSafeInteger(value) || /** @type {number} */ (value) <= 0) {
497
+ fail(name, 'a positive integer', opts);
498
+ }
499
+ },
500
+
501
+ /**
502
+ * Assert that `value` fits in a 48-bit unsigned integer (`0 … 2^48 − 1`).
503
+ * Used for Unix millisecond timestamps in UUID v7 / ULID.
504
+ * @param {unknown} value
505
+ * @param {string} name
506
+ * @param {AssertOptions} [opts]
507
+ */
508
+ assertUint48(value, name, opts) {
509
+ if (
510
+ !Number.isSafeInteger(value) ||
511
+ /** @type {number} */ (value) < 0 ||
512
+ /** @type {number} */ (value) > 0xffffffffffff
513
+ ) {
514
+ fail(name, 'a non-negative safe integer ≤ 2^48 − 1 (Unix ms since epoch)', opts);
515
+ }
516
+ },
517
+
518
+ /**
519
+ * Assert that `value` is a string (may be empty).
520
+ * @param {unknown} value
521
+ * @param {string} name
522
+ * @param {AssertOptions} [opts]
523
+ */
524
+ assertString(value, name, opts) {
525
+ if (typeof value !== 'string') {
526
+ fail(name, 'a string', opts);
527
+ }
528
+ },
529
+
530
+ /**
531
+ * Assert that `value` is a non-empty string.
532
+ * @param {unknown} value
533
+ * @param {string} name
534
+ * @param {AssertOptions} [opts]
535
+ */
536
+ assertNonEmptyString(value, name, opts) {
537
+ if (typeof value !== 'string' || value.length === 0) {
538
+ fail(name, 'a non-empty string', opts);
539
+ }
540
+ },
541
+
542
+ /**
543
+ * Assert that `value` is a boolean.
544
+ * @param {unknown} value
545
+ * @param {string} name
546
+ * @param {AssertOptions} [opts]
547
+ */
548
+ assertBoolean(value, name, opts) {
549
+ if (typeof value !== 'boolean') {
550
+ fail(name, 'a boolean', opts);
551
+ }
552
+ },
553
+
554
+ /**
555
+ * Assert that `value` is a function.
556
+ * @param {unknown} value
557
+ * @param {string} name
558
+ * @param {AssertOptions} [opts]
559
+ */
560
+ assertFunction(value, name, opts) {
561
+ if (typeof value !== 'function') {
562
+ fail(name, 'a function', opts);
563
+ }
564
+ },
565
+
566
+ assertObject,
567
+
568
+ /**
569
+ * Assert that `value` is either `undefined` or a plain object —
570
+ * the "optional options object" pattern.
571
+ * @param {unknown} value
572
+ * @param {string} name
573
+ * @param {AssertOptions} [opts]
574
+ */
575
+ assertOptionalObject(value, name, opts) {
576
+ if (value === undefined) {
577
+ return;
578
+ }
579
+ assertObject(value, name, opts);
580
+ },
581
+
582
+ /**
583
+ * Assert that `value` is a byte buffer (`Buffer` or `Uint8Array`) —
584
+ * strings are NOT accepted. For already-encoded material where a
585
+ * string would be ambiguous (ciphertext, signatures, raw key bytes).
586
+ * @param {unknown} value
587
+ * @param {string} name
588
+ * @param {AssertOptions} [opts]
589
+ */
590
+ assertBytes(value, name, opts) {
591
+ if (!Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
592
+ fail(name, 'a Buffer or Uint8Array', opts);
593
+ }
594
+ },
595
+
596
+ /**
597
+ * Assert that `value` is either a string or a byte buffer (`Buffer`
598
+ * or `Uint8Array`).
599
+ * @param {unknown} value
600
+ * @param {string} name
601
+ * @param {AssertOptions} [opts]
602
+ */
603
+ assertBytesOrString(value, name, opts) {
604
+ if (typeof value !== 'string' && !Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
605
+ fail(name, 'a string or Buffer', opts);
606
+ }
607
+ },
608
+
609
+ /**
610
+ * Assert that `encoding` is one of the accepted output/input
611
+ * encodings. Pass `allowBuffer: false` where a `Buffer` output makes
612
+ * no sense (verifying a string signature, decoding a token payload).
613
+ *
614
+ * @param {unknown} encoding
615
+ * @param {string} name
616
+ * @param {AssertOptions & { allowBuffer?: boolean }} [opts]
617
+ */
618
+ assertEncoding(encoding, name, opts) {
619
+ const allowBuffer = opts?.allowBuffer !== false;
620
+ const valid = (typeof encoding === 'string' && ENCODINGS.has(encoding)) || (allowBuffer && encoding === 'buffer');
621
+ if (!valid) {
622
+ const list = allowBuffer ? "'hex', 'base64', 'base64url', or 'buffer'" : "'hex', 'base64', or 'base64url'";
623
+ fail(name, list, opts);
624
+ }
625
+ },
626
+ };
627
+ }
628
+
629
+ /**
630
+ * One-line sugar over {@link bindAsserts} — the overwhelmingly common
631
+ * pattern where a package binds every argument-shape failure to its
632
+ * own error class + `INVALID_ARGUMENT` code. Emits the standard
633
+ * `new ErrorClass(code, message, { cause })` shape, so the underlying
634
+ * `BaseError` `{ cause, status, details }` options object is respected.
635
+ *
636
+ * // packages/<pkg>/src/internal/guards.js
637
+ * import { defineGuards } from '@exortek/shared/asserts';
638
+ * import { PasswordError, ErrorCode } from '../errors.js';
639
+ *
640
+ * export const { assertString, invalidArgument, parse } =
641
+ * defineGuards(PasswordError, ErrorCode.INVALID_ARGUMENT);
642
+ *
643
+ * Reach for the raw {@link bindAsserts} when you need a different
644
+ * mapping (an alt code, a decorated message) — it stays available.
645
+ *
646
+ * @param {new (code: string, message: string, options?: { cause?: unknown }) => Error} ErrorClass
647
+ * @param {string} code
648
+ */
649
+ function defineGuards(ErrorClass, code) {
650
+ return bindAsserts((message, extra) => new ErrorClass(code, message, extra));
651
+ }
652
+
653
+ /**
654
+ * Argument guards bound to `ChallengeError` — the package-wide binding
655
+ * of `@exortek/shared/asserts`. Import guards from here, never from the
656
+ * shared module directly: every argument-shape failure must throw
657
+ * `ChallengeError` with `ErrorCode.INVALID_ARGUMENT` so callers get one
658
+ * error class for the whole package.
659
+ */
660
+
661
+
662
+ const { invalidArgument} = defineGuards(
663
+ ChallengeError,
664
+ ErrorCode.INVALID_ARGUMENT,
665
+ );
666
+
667
+ /**
668
+ * RFC 4648 §5 base64url codec.
669
+ *
670
+ * Node's `Buffer.from(str, 'base64url')` is lenient: it accepts
671
+ * padding, `+`, `/`, and whitespace. Wire-level use (JWS / JWT
672
+ * compact serialisation, JWK ingest, etc.) requires strict rejection
673
+ * of everything that isn't a canonical unpadded encoding — hence the
674
+ * roundtrip check in `decode`.
675
+ *
676
+ * Throws plain `Error` / `TypeError` on malformed input. Callers that
677
+ * need typed errors wrap the call at their surface boundary.
678
+ */
679
+
680
+ const ALPHABET = /^[A-Za-z0-9_-]*$/;
681
+
682
+ /**
683
+ * @param {Buffer | Uint8Array} bytes
684
+ * @returns {string}
685
+ */
686
+ function encode(bytes) {
687
+ if (bytes == null || (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array))) {
688
+ throw new TypeError('base64url.encode: expected Buffer or Uint8Array');
689
+ }
690
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64url');
691
+ }
692
+
693
+ /**
694
+ * @param {string} text
695
+ * @returns {string}
696
+ */
697
+ function encodeString(text) {
698
+ if (typeof text !== 'string') {
699
+ throw new TypeError('base64url.encodeString: expected a string');
700
+ }
701
+ return Buffer.from(text, 'utf8').toString('base64url');
702
+ }
703
+
704
+ /**
705
+ * @param {unknown} value
706
+ * @returns {string}
707
+ */
708
+ function encodeJson(value) {
709
+ return encodeString(JSON.stringify(value));
710
+ }
711
+
712
+ /**
713
+ * @param {string} text
714
+ * @returns {Buffer}
715
+ */
716
+ function decode$1(text) {
717
+ if (typeof text !== 'string') {
718
+ throw new TypeError('base64url.decode: expected a string');
719
+ }
720
+ if (!ALPHABET.test(text)) {
721
+ throw new Error('base64url.decode: input contains characters outside the RFC 4648 §5 alphabet');
722
+ }
723
+ const bytes = Buffer.from(text, 'base64url');
724
+ if (bytes.toString('base64url') !== text) {
725
+ throw new Error('base64url.decode: input is not a canonical encoding');
726
+ }
727
+ return bytes;
728
+ }
729
+
730
+ /**
731
+ * @param {string} text
732
+ * @returns {string}
733
+ */
734
+ function decodeToString(text) {
735
+ return decode$1(text).toString('utf8');
736
+ }
737
+
738
+ /**
739
+ * @param {string} text
740
+ * @returns {unknown}
741
+ */
742
+ function decodeJson(text) {
743
+ return JSON.parse(decodeToString(text));
744
+ }
745
+
746
+ /**
747
+ * Length-safe `timingSafeEqual`.
748
+ *
749
+ * `crypto.timingSafeEqual` throws when its two arguments have
750
+ * different lengths — the exception path is itself a side channel
751
+ * that leaks "lengths differ" to a timing attacker even when the
752
+ * caller catches the throw. This wrapper closes that channel by
753
+ * always running a `timingSafeEqual` (against the shorter buffer
754
+ * padded to itself) and returning `false` on length mismatch, so the
755
+ * caller sees a boolean either way and total runtime does not
756
+ * short-circuit on length alone.
757
+ */
758
+
759
+
760
+ /**
761
+ * @param {Buffer | Uint8Array} a
762
+ * @param {Buffer | Uint8Array} b
763
+ * @returns {boolean}
764
+ */
765
+ function timingSafeEqual(a, b) {
766
+ const aBuf = Buffer.isBuffer(a) ? a : Buffer.from(a.buffer, a.byteOffset, a.byteLength);
767
+ const bBuf = Buffer.isBuffer(b) ? b : Buffer.from(b.buffer, b.byteOffset, b.byteLength);
768
+ if (aBuf.length !== bBuf.length) {
769
+ // Burn a comparison so total time doesn't short-circuit on the
770
+ // length gate — the attacker can't distinguish "length mismatch"
771
+ // from "content mismatch" by observed runtime.
772
+ node_crypto.timingSafeEqual(aBuf, aBuf);
773
+ return false;
774
+ }
775
+ return node_crypto.timingSafeEqual(aBuf, bBuf);
776
+ }
777
+
778
+ /**
779
+ * Token codec for `@exortek/challenge`.
780
+ *
781
+ * Format:
782
+ *
783
+ * <prefix>.<base64url(JSON payload)>.<base64url(HMAC-SHA256 tag)>
784
+ *
785
+ * The default prefix is {@link DEFAULT_PREFIX} (`chall_v1`) — deliberately
786
+ * unlike a JWT so the two token families cannot be confused at a call
787
+ * site. Callers may override it (e.g. `'server_challenge'`,
788
+ * `'myapp_v1'`) to brand the wire format for a specific service; the
789
+ * same prefix must be used at create and verify time, or verification
790
+ * returns `reason: 'malformed'`.
791
+ *
792
+ * HMAC covers `<prefix>.<b64u payload>` — the same string the caller
793
+ * received minus the trailing tag. Any change to prefix or payload
794
+ * invalidates the signature; a token minted with one prefix cannot be
795
+ * accepted under another even by the same secret.
796
+ */
797
+
798
+
799
+ const DEFAULT_PREFIX = 'chall_v1';
800
+ const HMAC_ALG = 'sha256';
801
+
802
+ // Printable ASCII, no `.` (delimiter), no whitespace. Length capped so a
803
+ // misused prefix can't grow the token unboundedly.
804
+ const PREFIX_RE = /^[A-Za-z0-9_-]{1,32}$/;
805
+
806
+ /**
807
+ * Assert a prefix is well-shaped. Throws via the caller's `invalidArg`
808
+ * function so `createChallenge` / `verifyChallenge` can raise
809
+ * `ChallengeError` with the right code — this module stays free of the
810
+ * error class dependency.
811
+ *
812
+ * @param {string} prefix
813
+ * @param {string} name
814
+ * @param {(msg: string) => Error} invalidArg
815
+ * @returns {string}
816
+ */
817
+ function assertPrefix(prefix, name, invalidArg) {
818
+ if (!isString(prefix) || !PREFIX_RE.test(prefix)) {
819
+ throw invalidArg(
820
+ `${name} must match /^[A-Za-z0-9_-]{1,32}$/ (letters, digits, '_' or '-', 1-32 chars — no '.' since it's the token delimiter); got ${JSON.stringify(prefix)}`,
821
+ );
822
+ }
823
+ return prefix;
824
+ }
825
+
826
+ /**
827
+ * Random ID for the token's `jti` claim — 128 bits of entropy, encoded
828
+ * as 22 base64url characters. Used as the store key for single-use
829
+ * enforcement, so it must be unpredictable and unique per token.
830
+ *
831
+ * @returns {string}
832
+ */
833
+ function newJti() {
834
+ return encode(node_crypto.randomBytes(16));
835
+ }
836
+
837
+ /**
838
+ * Sign a payload with `secret` and return the compact token string.
839
+ *
840
+ * @param {object} payload
841
+ * @param {Buffer} secret 32+ raw bytes; caller validates length.
842
+ * @param {string} [prefix] Wire prefix; defaults to {@link DEFAULT_PREFIX}.
843
+ * @returns {string}
844
+ */
845
+ function sign(payload, secret, prefix = DEFAULT_PREFIX) {
846
+ const body = `${prefix}.${encodeJson(payload)}`;
847
+ const tag = node_crypto.createHmac(HMAC_ALG, secret).update(body).digest();
848
+ return `${body}.${encode(tag)}`;
849
+ }
850
+
851
+ /**
852
+ * Parse + HMAC-verify a token. Returns the decoded payload on success,
853
+ * or a reason string on any failure. Never throws on user-input
854
+ * problems — a wrong token is a normal auth outcome.
855
+ *
856
+ * @param {string} token
857
+ * @param {Buffer} secret
858
+ * @param {string} [prefix] Expected prefix; defaults to {@link DEFAULT_PREFIX}.
859
+ * @returns {{ payload: object } | { reason: 'malformed' | 'bad_signature' }}
860
+ */
861
+ function decode(token, secret, prefix = DEFAULT_PREFIX) {
862
+ if (!isString(token) || token.length === 0) {
863
+ return { reason: 'malformed' };
864
+ }
865
+ const parts = token.split('.');
866
+ if (parts.length !== 3 || parts[0] !== prefix) {
867
+ return { reason: 'malformed' };
868
+ }
869
+ const [, encodedPayload, encodedTag] = parts;
870
+ let tag;
871
+ try {
872
+ tag = decode$1(encodedTag);
873
+ } catch {
874
+ return { reason: 'malformed' };
875
+ }
876
+ const expected = node_crypto.createHmac(HMAC_ALG, secret).update(`${prefix}.${encodedPayload}`).digest();
877
+ if (!timingSafeEqual(tag, expected)) {
878
+ return { reason: 'bad_signature' };
879
+ }
880
+ let payload;
881
+ try {
882
+ payload = decodeJson(encodedPayload);
883
+ } catch {
884
+ return { reason: 'malformed' };
885
+ }
886
+ if (!isObject(payload)) {
887
+ return { reason: 'malformed' };
888
+ }
889
+ return { payload };
890
+ }
891
+
892
+ /**
893
+ * `@exortek/challenge` — signed, single-use challenge tokens for
894
+ * multi-step auth flows.
895
+ *
896
+ * A challenge is a small, HMAC-signed envelope that carries context
897
+ * across a redirect or a follow-up request without a server-side
898
+ * session: who is being challenged (`userId`), how they proved
899
+ * themselves so far (`method`), what step of the flow they've cleared
900
+ * (`step`), and any bespoke metadata the caller needs on the other
901
+ * side. The token is stateless by default; single-use enforcement and
902
+ * revocation are opt-in via a store the caller supplies.
903
+ *
904
+ * See the README for the full API and worked examples.
905
+ */
906
+
907
+
908
+ const MIN_SECRET_BYTES = 32;
909
+
910
+ const KNOWN_METHODS = new Set([
911
+ 'totp',
912
+ 'hotp',
913
+ 'email_otp',
914
+ 'sms_otp',
915
+ 'backup_code',
916
+ 'passkey',
917
+ 'magic_link',
918
+ 'password',
919
+ 'webauthn',
920
+ 'oauth',
921
+ 'oidc',
922
+ ]);
923
+
924
+ /**
925
+ * @typedef {'totp' | 'hotp' | 'email_otp' | 'sms_otp' | 'backup_code'
926
+ * | 'passkey' | 'magic_link' | 'password' | 'webauthn' | 'oauth' | 'oidc'
927
+ * | string} ChallengeMethod
928
+ */
929
+
930
+ /**
931
+ * @typedef {object} IncrStore
932
+ * @property {(key: string, ttlMs: number) => Promise<{ count: number }>} incr
933
+ * Atomic increment-with-expiry. First call returns `{ count: 1 }` and
934
+ * arms a TTL; subsequent calls before expiry return the incremented
935
+ * count. Used as compare-and-set for single-use enforcement.
936
+ */
937
+
938
+ /**
939
+ * @typedef {object} ChallengePayload
940
+ * @property {string} jti
941
+ * @property {number} iat
942
+ * @property {number} exp
943
+ * @property {string} [userId]
944
+ * @property {ChallengeMethod} [method]
945
+ * @property {string} [step]
946
+ * @property {string} [nextStep]
947
+ * @property {string} [ip] Only set when `ipBinding: true`.
948
+ * @property {string} [ua] Only set when `ua` supplied.
949
+ * @property {Record<string, unknown>} [meta]
950
+ */
951
+
952
+ /**
953
+ * @typedef {object} CreateChallengeOptions
954
+ * @property {string | Buffer | Uint8Array} secret
955
+ * HMAC-SHA256 secret. **Must be at least 32 raw bytes.** A string is
956
+ * interpreted as UTF-8 — for a hex or base64 secret, decode to
957
+ * Buffer first.
958
+ * @property {string} [userId]
959
+ * @property {ChallengeMethod} [method]
960
+ * @property {string} [step]
961
+ * @property {string} [nextStep]
962
+ * @property {string} [ip]
963
+ * @property {string} [ua]
964
+ * @property {Record<string, unknown>} [metadata]
965
+ * @property {string | number} expiresIn Duration string (`'5m'`) or ms integer.
966
+ * @property {boolean} [singleUse=false]
967
+ * When true, the returned token can only be verified once — subsequent
968
+ * verifies with `consume: true` fail with `reason: 'replay'`. Requires
969
+ * `store` to be supplied.
970
+ * @property {IncrStore} [store]
971
+ * Any object exposing `incr(key, ttlMs) → { count }`. Compatible with
972
+ * `@exortek/security`'s rate-limit stores; also easy to wrap Redis.
973
+ * @property {boolean} [ipBinding=false]
974
+ * When true, the caller-supplied `ip` is stamped into the payload and
975
+ * `verifyChallenge` will reject a request whose `ip` differs.
976
+ * @property {string} [prefix='chall_v1']
977
+ * Wire-format prefix. Defaults to `'chall_v1'` — the value shipped
978
+ * with this package. Callers can override to brand the token
979
+ * family (e.g. `'server_challenge'`, `'myapp_v1'`); must match
980
+ * `/^[A-Za-z0-9_-]{1,32}$/`. The same prefix must be passed at
981
+ * verify time or verification returns `reason: 'malformed'`.
982
+ * @property {number} [now] Override `Date.now()` for testing.
983
+ */
984
+
985
+ /**
986
+ * @typedef {object} VerifyChallengeOptions
987
+ * @property {string | Buffer | Uint8Array} secret
988
+ * @property {boolean} [consume=false]
989
+ * Enforce single-use. Requires `store` (typically the same one used
990
+ * at create time).
991
+ * @property {IncrStore} [store]
992
+ * @property {string} [expectedUserId]
993
+ * @property {ChallengeMethod} [expectedMethod]
994
+ * @property {string} [expectedStep]
995
+ * @property {string} [expectedNextStep]
996
+ * @property {string} [ip]
997
+ * The current request's IP. Required to verify a token that was
998
+ * created with `ipBinding: true`; ignored otherwise.
999
+ * @property {string} [prefix='chall_v1']
1000
+ * Wire-format prefix. Must match the value passed to
1001
+ * `createChallenge` — a token minted with a different prefix will
1002
+ * fail with `reason: 'malformed'`.
1003
+ * @property {number} [now] Override `Date.now()` for testing.
1004
+ */
1005
+
1006
+ /**
1007
+ * @typedef {'malformed' | 'bad_signature' | 'expired' | 'not_yet_valid'
1008
+ * | 'user_mismatch' | 'method_mismatch' | 'step_mismatch'
1009
+ * | 'next_step_mismatch' | 'ip_mismatch' | 'ip_missing' | 'replay'
1010
+ * | 'store_unavailable'} VerifyFailureReason
1011
+ */
1012
+
1013
+ /**
1014
+ * @typedef {{ valid: true, payload: ChallengePayload }
1015
+ * | { valid: false, reason: VerifyFailureReason }} VerifyChallengeResult
1016
+ */
1017
+
1018
+ /**
1019
+ * Create a signed challenge token.
1020
+ *
1021
+ * const token = await createChallenge({
1022
+ * secret: process.env.CHALLENGE_SECRET,
1023
+ * userId: 'usr_123',
1024
+ * method: 'totp',
1025
+ * step: 'mfa_verified',
1026
+ * nextStep: 'login',
1027
+ * expiresIn: '5m',
1028
+ * singleUse: true,
1029
+ * store,
1030
+ * })
1031
+ *
1032
+ * @param {CreateChallengeOptions} options
1033
+ * @returns {Promise<string>}
1034
+ */
1035
+ async function createChallenge(options) {
1036
+ if (!isObject(options)) {
1037
+ throw invalidArgument('createChallenge.options must be an object');
1038
+ }
1039
+ const secret = _coerceSecret(options.secret, 'createChallenge.options.secret');
1040
+ let ttlMs;
1041
+ try {
1042
+ ttlMs = parseDuration(options.expiresIn);
1043
+ } catch (err) {
1044
+ throw invalidArgument(`createChallenge.options.expiresIn: ${err.message}`, { cause: err });
1045
+ }
1046
+ if (!isFiniteNumber(ttlMs) || ttlMs <= 0) {
1047
+ throw invalidArgument(`createChallenge.options.expiresIn must resolve to a positive duration; got ${ttlMs}ms`);
1048
+ }
1049
+ const prefix = isUndefined(options.prefix)
1050
+ ? DEFAULT_PREFIX
1051
+ : assertPrefix(options.prefix, 'createChallenge.options.prefix', invalidArgument);
1052
+ _assertStringOrUndef(options.userId, 'createChallenge.options.userId');
1053
+ _assertMethod(options.method, 'createChallenge.options.method');
1054
+ _assertStringOrUndef(options.step, 'createChallenge.options.step');
1055
+ _assertStringOrUndef(options.nextStep, 'createChallenge.options.nextStep');
1056
+ _assertStringOrUndef(options.ua, 'createChallenge.options.ua');
1057
+
1058
+ const singleUse = options.singleUse === true;
1059
+ const ipBinding = options.ipBinding === true;
1060
+
1061
+ if (singleUse && !_isStore(options.store)) {
1062
+ throw invalidArgument(
1063
+ 'createChallenge.options.store is required when singleUse: true — pass an object exposing incr(key, ttlMs) → { count }',
1064
+ );
1065
+ }
1066
+ if (ipBinding && !isString(options.ip)) {
1067
+ throw invalidArgument('createChallenge.options.ip must be a string when ipBinding: true');
1068
+ }
1069
+ if (!isUndefined(options.metadata) && !isObject(options.metadata)) {
1070
+ throw invalidArgument('createChallenge.options.metadata must be a plain object when provided');
1071
+ }
1072
+
1073
+ const now = _now(options.now);
1074
+ const jti = newJti();
1075
+
1076
+ /** @type {ChallengePayload} */
1077
+ const payload = {
1078
+ jti,
1079
+ iat: Math.floor(now / 1000),
1080
+ exp: Math.floor((now + ttlMs) / 1000),
1081
+ };
1082
+ if (options.userId) {
1083
+ payload.userId = options.userId;
1084
+ }
1085
+ if (options.method) {
1086
+ payload.method = options.method;
1087
+ }
1088
+ if (options.step) {
1089
+ payload.step = options.step;
1090
+ }
1091
+ if (options.nextStep) {
1092
+ payload.nextStep = options.nextStep;
1093
+ }
1094
+ if (ipBinding) {
1095
+ payload.ip = options.ip;
1096
+ }
1097
+ if (isString(options.ua)) {
1098
+ payload.ua = options.ua;
1099
+ }
1100
+ if (!isUndefined(options.metadata)) {
1101
+ payload.meta = options.metadata;
1102
+ }
1103
+ if (singleUse) {
1104
+ payload.su = 1;
1105
+ }
1106
+
1107
+ return sign(payload, secret, prefix);
1108
+ }
1109
+
1110
+ /**
1111
+ * Verify a challenge token. Returns `{ valid: true, payload }` on
1112
+ * success or `{ valid: false, reason }` on any expected failure. Only
1113
+ * throws on programmer errors (bad options, missing secret).
1114
+ *
1115
+ * const res = await verifyChallenge(token, {
1116
+ * secret: process.env.CHALLENGE_SECRET,
1117
+ * consume: true,
1118
+ * store,
1119
+ * expectedUserId: pendingUserId,
1120
+ * expectedMethod: 'totp',
1121
+ * ip: req.ip,
1122
+ * })
1123
+ * if (!res.valid) return reply.code(401).send({ error: res.reason })
1124
+ *
1125
+ * @param {string} token
1126
+ * @param {VerifyChallengeOptions} options
1127
+ * @returns {Promise<VerifyChallengeResult>}
1128
+ */
1129
+ async function verifyChallenge(token, options) {
1130
+ if (!isObject(options)) {
1131
+ throw invalidArgument('verifyChallenge.options must be an object');
1132
+ }
1133
+ const secret = _coerceSecret(options.secret, 'verifyChallenge.options.secret');
1134
+ const consume = options.consume === true;
1135
+ if (consume && !_isStore(options.store)) {
1136
+ throw invalidArgument(
1137
+ 'verifyChallenge.options.store is required when consume: true — pass the same store used at create time',
1138
+ );
1139
+ }
1140
+ const prefix = isUndefined(options.prefix)
1141
+ ? DEFAULT_PREFIX
1142
+ : assertPrefix(options.prefix, 'verifyChallenge.options.prefix', invalidArgument);
1143
+
1144
+ const parsed = decode(token, secret, prefix);
1145
+ if ('reason' in parsed) {
1146
+ return { valid: false, reason: parsed.reason };
1147
+ }
1148
+ const payload = /** @type {ChallengePayload & { su?: 1 }} */ (parsed.payload);
1149
+ const now = _now(options.now);
1150
+ const nowSec = Math.floor(now / 1000);
1151
+
1152
+ if (!isNumber(payload.exp) || payload.exp <= nowSec) {
1153
+ return { valid: false, reason: 'expired' };
1154
+ }
1155
+ if (isNumber(payload.iat) && payload.iat > nowSec + 60) {
1156
+ // Small clock-skew tolerance (60s) — reject only if clearly future-dated.
1157
+ return { valid: false, reason: 'not_yet_valid' };
1158
+ }
1159
+ if (!isUndefined(options.expectedUserId) && payload.userId !== options.expectedUserId) {
1160
+ return { valid: false, reason: 'user_mismatch' };
1161
+ }
1162
+ if (!isUndefined(options.expectedMethod) && payload.method !== options.expectedMethod) {
1163
+ return { valid: false, reason: 'method_mismatch' };
1164
+ }
1165
+ if (!isUndefined(options.expectedStep) && payload.step !== options.expectedStep) {
1166
+ return { valid: false, reason: 'step_mismatch' };
1167
+ }
1168
+ if (!isUndefined(options.expectedNextStep) && payload.nextStep !== options.expectedNextStep) {
1169
+ return { valid: false, reason: 'next_step_mismatch' };
1170
+ }
1171
+ if (!isUndefined(payload.ip)) {
1172
+ if (!isString(options.ip)) {
1173
+ return { valid: false, reason: 'ip_missing' };
1174
+ }
1175
+ if (options.ip !== payload.ip) {
1176
+ return { valid: false, reason: 'ip_mismatch' };
1177
+ }
1178
+ }
1179
+ if (consume && payload.su === 1) {
1180
+ const ttlMs = Math.max(1, payload.exp * 1000 - now);
1181
+ let count;
1182
+ try {
1183
+ // Namespace lives in the store's own keyPrefix (redisStore defaults
1184
+ // to 'chall:'). verifyChallenge passes the raw jti so a store shared
1185
+ // with other purposes can prefix once — no double 'chall:chall:'.
1186
+ const res = await options.store.incr(payload.jti, ttlMs);
1187
+ count = res?.count;
1188
+ } catch {
1189
+ return { valid: false, reason: 'store_unavailable' };
1190
+ }
1191
+ if (!isNumber(count) || count > 1) {
1192
+ return { valid: false, reason: 'replay' };
1193
+ }
1194
+ }
1195
+ // Strip internal fields so callers see only their own payload.
1196
+ const { su: _su, ...clean } = payload;
1197
+ return { valid: true, payload: clean };
1198
+ }
1199
+
1200
+ function _coerceSecret(input, name) {
1201
+ if (isString(input)) {
1202
+ const buf = Buffer.from(input, 'utf8');
1203
+ if (buf.length < MIN_SECRET_BYTES) {
1204
+ throw new ChallengeError(
1205
+ ErrorCode.INVALID_SECRET,
1206
+ `${name} must be at least ${MIN_SECRET_BYTES} bytes; got ${buf.length}. Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"`,
1207
+ );
1208
+ }
1209
+ return buf;
1210
+ }
1211
+ if (isBytes(input)) {
1212
+ if (input.length < MIN_SECRET_BYTES) {
1213
+ throw new ChallengeError(
1214
+ ErrorCode.INVALID_SECRET,
1215
+ `${name} must be at least ${MIN_SECRET_BYTES} bytes; got ${input.length}`,
1216
+ );
1217
+ }
1218
+ return isBuffer(input) ? input : Buffer.from(input);
1219
+ }
1220
+ throw invalidArgument(`${name} must be a string, Buffer, or Uint8Array`);
1221
+ }
1222
+
1223
+ function _assertStringOrUndef(v, name) {
1224
+ if (!isUndefined(v) && !isString(v)) {
1225
+ throw invalidArgument(`${name} must be a string when provided`);
1226
+ }
1227
+ }
1228
+
1229
+ function _assertMethod(v, name) {
1230
+ if (isUndefined(v)) {
1231
+ return;
1232
+ }
1233
+ if (!isString(v)) {
1234
+ throw invalidArgument(`${name} must be a string when provided`);
1235
+ }
1236
+ if (!KNOWN_METHODS.has(v)) ;
1237
+ }
1238
+
1239
+ function _isStore(v) {
1240
+ return isObject(v) && isFunction(v.incr);
1241
+ }
1242
+
1243
+ function _now(override) {
1244
+ return isNumber(override) ? override : Date.now();
1245
+ }
1246
+
1247
+ exports.ChallengeError = ChallengeError;
1248
+ exports.ErrorCode = ErrorCode;
1249
+ exports.createChallenge = createChallenge;
1250
+ exports.verifyChallenge = verifyChallenge;