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