@exortek/apikey 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 +38 -0
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/dist/errors.d.ts +20 -0
- package/dist/index.cjs +1632 -0
- package/dist/index.d.ts +278 -0
- package/dist/index.mjs +1617 -0
- package/dist/internal/guards.d.ts +4 -0
- package/dist/middleware/core.d.ts +86 -0
- package/dist/middleware/express.cjs +1363 -0
- package/dist/middleware/express.d.ts +4 -0
- package/dist/middleware/express.mjs +1361 -0
- package/dist/middleware/fastify.cjs +1374 -0
- package/dist/middleware/fastify.d.ts +1 -0
- package/dist/middleware/fastify.mjs +1372 -0
- package/dist/scopes.d.ts +21 -0
- package/dist/stores/index.d.ts +2 -0
- package/dist/stores/memory.d.ts +6 -0
- package/dist/stores/redis.d.ts +13 -0
- package/dist/stores.cjs +880 -0
- package/dist/stores.mjs +877 -0
- package/dist/token.d.ts +88 -0
- package/package.json +98 -0
|
@@ -0,0 +1,1372 @@
|
|
|
1
|
+
import fp from 'fastify-plugin';
|
|
2
|
+
import { timingSafeEqual as timingSafeEqual$1, createHmac, createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Non-throwing type predicates. Companion to `asserts.js`.
|
|
6
|
+
*
|
|
7
|
+
* ### When to reach for which
|
|
8
|
+
*
|
|
9
|
+
* - Writing a **throw-guard** at a function boundary — a bad shape
|
|
10
|
+
* should fail with the caller's typed error class? Use
|
|
11
|
+
* `asserts` + `defineGuards` (`assertObject`, `assertString`, …).
|
|
12
|
+
* The `assert*` family bundles the check with the correctly-bound
|
|
13
|
+
* throw and preserves the "one error class per package" contract.
|
|
14
|
+
*
|
|
15
|
+
* - Writing a **dispatch / branch / fallback** where the wrong shape
|
|
16
|
+
* is meant to be handled, not thrown? Use these predicates.
|
|
17
|
+
* Typical shapes:
|
|
18
|
+
*
|
|
19
|
+
* if (isObject(config.store)) { ... } else { ... }
|
|
20
|
+
*
|
|
21
|
+
* const isManager = isObject(v) && isFunction(v.issue);
|
|
22
|
+
*
|
|
23
|
+
* The two APIs deliberately do not overlap in intent: `assert*` throws,
|
|
24
|
+
* `is*` never does. Bypassing an `assert*` with an `if (!is*(x)) throw
|
|
25
|
+
* new PkgError(...)` pattern skips the package's guards binding and
|
|
26
|
+
* duplicates work the assert family already handles — don't.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `true` when `value` is a plain object *or* a class instance — anything
|
|
31
|
+
* whose property access is safe. Rejects `null`, arrays, and every
|
|
32
|
+
* primitive.
|
|
33
|
+
*
|
|
34
|
+
* The Node convention `typeof x === 'object' && x !== null` accepts
|
|
35
|
+
* arrays, which almost never matches caller intent (an array is not a
|
|
36
|
+
* "config object"). This predicate additionally rules arrays out, so
|
|
37
|
+
* `isObject(x)` really means "I can read `x.foo` without exploding on
|
|
38
|
+
* a numeric index".
|
|
39
|
+
*
|
|
40
|
+
* Class instances (Node `KeyObject`, `URL`, user-defined classes) pass
|
|
41
|
+
* because they carry `typeof === 'object'` — the check is duck-typed,
|
|
42
|
+
* not prototype-restricted.
|
|
43
|
+
*
|
|
44
|
+
* @param {unknown} value
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
function isObject(value) {
|
|
48
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `true` when `value` is a string primitive. `String` boxed objects
|
|
53
|
+
* (from `new String('x')`) are refused — those are a footgun almost no
|
|
54
|
+
* one wants, and every place in this codebase reads strings by
|
|
55
|
+
* primitive access.
|
|
56
|
+
*
|
|
57
|
+
* @param {unknown} value
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
function isString(value) {
|
|
61
|
+
return typeof value === 'string';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `true` when `value` is a function. Class constructors, arrow
|
|
66
|
+
* functions, and generator functions all pass — `typeof` on any
|
|
67
|
+
* callable is `'function'`.
|
|
68
|
+
*
|
|
69
|
+
* @param {unknown} value
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
function isFunction(value) {
|
|
73
|
+
return typeof value === 'function';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `true` when `value` is a `Buffer` or a `Uint8Array` — the two byte
|
|
78
|
+
* shapes every crypto surface in this repo accepts interchangeably.
|
|
79
|
+
* Rejects strings so dispatch code can decide whether a string input
|
|
80
|
+
* means "already-encoded bytes" (decode it) or "wrong type" (branch).
|
|
81
|
+
*
|
|
82
|
+
* @param {unknown} value
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
function isBytes(value) {
|
|
86
|
+
return Buffer.isBuffer(value) || value instanceof Uint8Array;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `true` when `value` is a number primitive. Rejects `NaN`, which is
|
|
91
|
+
* technically a number but almost never what the caller intends.
|
|
92
|
+
*
|
|
93
|
+
* @param {unknown} value
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
function isNumber(value) {
|
|
97
|
+
return typeof value === 'number' && !Number.isNaN(value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `true` when `value` is `undefined`.
|
|
102
|
+
*
|
|
103
|
+
* @param {unknown} value
|
|
104
|
+
* @returns {boolean}
|
|
105
|
+
*/
|
|
106
|
+
function isUndefined(value) {
|
|
107
|
+
return typeof value === 'undefined';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `true` for arrays. Thin wrapper over `Array.isArray` so predicates
|
|
112
|
+
* live under one import; identical behaviour.
|
|
113
|
+
*
|
|
114
|
+
* @param {unknown} value
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
function isArray(value) {
|
|
118
|
+
return Array.isArray(value);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `true` when `value` is a Node.js `Buffer`. Uint8Array-only shapes are
|
|
123
|
+
* refused — use {@link isBytes} when either satisfies the contract.
|
|
124
|
+
*
|
|
125
|
+
* @param {unknown} value
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
*/
|
|
128
|
+
function isBuffer(value) {
|
|
129
|
+
return Buffer.isBuffer(value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Imperative single-argument guard helpers — the everyday
|
|
134
|
+
* `assertPositiveInt(x, 'name')` shape used at API boundaries.
|
|
135
|
+
* Companion to the compound schema builder in
|
|
136
|
+
* `@exortek/shared/validate`: **schema** for whole options objects,
|
|
137
|
+
* **asserts** for one-liner argument guards at the call site.
|
|
138
|
+
*
|
|
139
|
+
* The consumer surface is {@link defineGuards} (or the raw
|
|
140
|
+
* {@link bindAsserts} for full control). Each package binds the assert
|
|
141
|
+
* set to its own typed error class once (in `internal/guards.js`), so
|
|
142
|
+
* every argument failure throws that package's class —
|
|
143
|
+
* `err instanceof CryptoError` holds and users see at a glance which
|
|
144
|
+
* package raised the error. The bound `parse` bridges
|
|
145
|
+
* `@exortek/shared/validate` schemas to the same error class.
|
|
146
|
+
*
|
|
147
|
+
* **Path naming convention** (used as the `name` argument):
|
|
148
|
+
*
|
|
149
|
+
* `<publicFunction>[.options|.config][.<field>]`
|
|
150
|
+
*
|
|
151
|
+
* - `assertString(name, 'createUser.name')` — top-level arg
|
|
152
|
+
* - `assertPositiveInt(n, 'scrypt.options.r')` — nested option
|
|
153
|
+
* - `assertBytesOrString(pwd, 'pepper.wrap.password')` — method arg
|
|
154
|
+
*
|
|
155
|
+
* Keep the path short: the emitted error is `"<name> must be <desc>"`,
|
|
156
|
+
* so a stack-line grep already tells the caller which function fired.
|
|
157
|
+
* Reserve `hint` for actionable follow-ups ("pass the bytes returned by
|
|
158
|
+
* encryptSymmetric()"), not for restating the field.
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @typedef {{ cause?: unknown }} WrapExtra
|
|
163
|
+
* Extra options forwarded to the wrap function, so `invalidArgument`
|
|
164
|
+
* sites that catch an underlying error can preserve the `cause` chain
|
|
165
|
+
* without falling back to a raw `new PackageError(...)`.
|
|
166
|
+
*
|
|
167
|
+
* @typedef {(message: string, extra?: WrapExtra) => Error} WrapFn
|
|
168
|
+
* Constructs (never throws) the binding package's error, e.g.
|
|
169
|
+
* `(m, { cause } = {}) => new CryptoError(ErrorCode.INVALID_ARGUMENT, m, { cause })`.
|
|
170
|
+
*
|
|
171
|
+
* @typedef {{ hint?: string }} AssertOptions
|
|
172
|
+
* `hint` is appended to the message after an em-dash — use it for
|
|
173
|
+
* actionable guidance ("pass the exact bytes returned by …").
|
|
174
|
+
*
|
|
175
|
+
* @typedef {{ safeParse: (input: unknown, path?: string) => { ok: true, value: unknown } | { ok: false, errors: string[] } }} ParseableSchema
|
|
176
|
+
*/
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Build the failure message. `description` completes the sentence
|
|
180
|
+
* `"<name> must be <description>"`; `hint` (optional) follows an
|
|
181
|
+
* em-dash.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} name
|
|
184
|
+
* @param {string} description
|
|
185
|
+
* @param {string} [hint]
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
function message(name, description, hint) {
|
|
189
|
+
return `${name} must be ${description}${hint ? ` — ${hint}` : ''}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const ENCODINGS = new Set(['hex', 'base64', 'base64url']);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Bind the assert set to a package's error class. Call once per
|
|
196
|
+
* package (conventionally in `internal/guards.js`) and import the
|
|
197
|
+
* bound object everywhere:
|
|
198
|
+
*
|
|
199
|
+
* import { bindAsserts } from '@exortek/shared/asserts';
|
|
200
|
+
* import { CryptoError, ErrorCode } from '../errors.js';
|
|
201
|
+
* export const g = bindAsserts((m) => new CryptoError(ErrorCode.INVALID_ARGUMENT, m));
|
|
202
|
+
*
|
|
203
|
+
* // call sites:
|
|
204
|
+
* g.assertPositiveInt(options.iterations, 'options.iterations');
|
|
205
|
+
* const cfg = g.parse(OptionsSchema, options, 'options');
|
|
206
|
+
*
|
|
207
|
+
* @param {WrapFn} wrap
|
|
208
|
+
*/
|
|
209
|
+
function bindAsserts(wrap) {
|
|
210
|
+
/**
|
|
211
|
+
* @param {string} name
|
|
212
|
+
* @param {string} description
|
|
213
|
+
* @param {AssertOptions} [opts]
|
|
214
|
+
* @returns {never}
|
|
215
|
+
*/
|
|
216
|
+
function fail(name, description, opts) {
|
|
217
|
+
throw wrap(message(name, description, opts?.hint));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Assert that `value` is a plain object (not `null`, not an array,
|
|
222
|
+
* not a primitive).
|
|
223
|
+
* @param {unknown} value
|
|
224
|
+
* @param {string} name
|
|
225
|
+
* @param {AssertOptions} [opts]
|
|
226
|
+
*/
|
|
227
|
+
function assertObject(value, name, opts) {
|
|
228
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
229
|
+
fail(name, 'an object', opts);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
/**
|
|
235
|
+
* Construct (not throw) the bound error with a free-form message —
|
|
236
|
+
* for `throw g.invalidArgument('…')` sites that don't fit the
|
|
237
|
+
* `X must be Y` shape (canonicalisation errors, cross-field
|
|
238
|
+
* constraint failures). Pass `{ cause }` when the argument-shape
|
|
239
|
+
* failure was triggered by an underlying throw whose chain matters
|
|
240
|
+
* (e.g. `JSON.stringify` on a bigint).
|
|
241
|
+
*
|
|
242
|
+
* @param {string} msg
|
|
243
|
+
* @param {WrapExtra} [extra]
|
|
244
|
+
* @returns {Error}
|
|
245
|
+
*/
|
|
246
|
+
invalidArgument(msg, extra) {
|
|
247
|
+
return wrap(msg, extra);
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Validate `input` against a `@exortek/shared/validate` schema;
|
|
252
|
+
* failures throw the bound error carrying every collected message.
|
|
253
|
+
* The bridge that keeps schema validation on the package's own
|
|
254
|
+
* error surface.
|
|
255
|
+
*
|
|
256
|
+
* @param {ParseableSchema} schema
|
|
257
|
+
* @param {unknown} input
|
|
258
|
+
* @param {string} [path='options']
|
|
259
|
+
* @returns {unknown} the parsed (possibly defaulted) value
|
|
260
|
+
*/
|
|
261
|
+
parse(schema, input, path = 'options') {
|
|
262
|
+
const r = schema.safeParse(input, path);
|
|
263
|
+
if (!r.ok) {
|
|
264
|
+
throw wrap(r.errors.join('; '));
|
|
265
|
+
}
|
|
266
|
+
return r.value;
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Assert that `value` is a non-negative safe integer (`0, 1, 2, …`).
|
|
271
|
+
* @param {unknown} value
|
|
272
|
+
* @param {string} name Argument name to include in the error message.
|
|
273
|
+
* @param {AssertOptions} [opts]
|
|
274
|
+
*/
|
|
275
|
+
assertNonNegativeInt(value, name, opts) {
|
|
276
|
+
if (!Number.isSafeInteger(value) || /** @type {number} */ (value) < 0) {
|
|
277
|
+
fail(name, 'a non-negative safe integer', opts);
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Assert that `value` is a strictly positive safe integer (`1, 2, 3, …`).
|
|
283
|
+
* @param {unknown} value
|
|
284
|
+
* @param {string} name
|
|
285
|
+
* @param {AssertOptions} [opts]
|
|
286
|
+
*/
|
|
287
|
+
assertPositiveInt(value, name, opts) {
|
|
288
|
+
if (!Number.isSafeInteger(value) || /** @type {number} */ (value) <= 0) {
|
|
289
|
+
fail(name, 'a positive integer', opts);
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Assert that `value` fits in a 48-bit unsigned integer (`0 … 2^48 − 1`).
|
|
295
|
+
* Used for Unix millisecond timestamps in UUID v7 / ULID.
|
|
296
|
+
* @param {unknown} value
|
|
297
|
+
* @param {string} name
|
|
298
|
+
* @param {AssertOptions} [opts]
|
|
299
|
+
*/
|
|
300
|
+
assertUint48(value, name, opts) {
|
|
301
|
+
if (
|
|
302
|
+
!Number.isSafeInteger(value) ||
|
|
303
|
+
/** @type {number} */ (value) < 0 ||
|
|
304
|
+
/** @type {number} */ (value) > 0xffffffffffff
|
|
305
|
+
) {
|
|
306
|
+
fail(name, 'a non-negative safe integer ≤ 2^48 − 1 (Unix ms since epoch)', opts);
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Assert that `value` is a string (may be empty).
|
|
312
|
+
* @param {unknown} value
|
|
313
|
+
* @param {string} name
|
|
314
|
+
* @param {AssertOptions} [opts]
|
|
315
|
+
*/
|
|
316
|
+
assertString(value, name, opts) {
|
|
317
|
+
if (typeof value !== 'string') {
|
|
318
|
+
fail(name, 'a string', opts);
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Assert that `value` is a non-empty string.
|
|
324
|
+
* @param {unknown} value
|
|
325
|
+
* @param {string} name
|
|
326
|
+
* @param {AssertOptions} [opts]
|
|
327
|
+
*/
|
|
328
|
+
assertNonEmptyString(value, name, opts) {
|
|
329
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
330
|
+
fail(name, 'a non-empty string', opts);
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Assert that `value` is a boolean.
|
|
336
|
+
* @param {unknown} value
|
|
337
|
+
* @param {string} name
|
|
338
|
+
* @param {AssertOptions} [opts]
|
|
339
|
+
*/
|
|
340
|
+
assertBoolean(value, name, opts) {
|
|
341
|
+
if (typeof value !== 'boolean') {
|
|
342
|
+
fail(name, 'a boolean', opts);
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Assert that `value` is a function.
|
|
348
|
+
* @param {unknown} value
|
|
349
|
+
* @param {string} name
|
|
350
|
+
* @param {AssertOptions} [opts]
|
|
351
|
+
*/
|
|
352
|
+
assertFunction(value, name, opts) {
|
|
353
|
+
if (typeof value !== 'function') {
|
|
354
|
+
fail(name, 'a function', opts);
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
assertObject,
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Assert that `value` is either `undefined` or a plain object —
|
|
362
|
+
* the "optional options object" pattern.
|
|
363
|
+
* @param {unknown} value
|
|
364
|
+
* @param {string} name
|
|
365
|
+
* @param {AssertOptions} [opts]
|
|
366
|
+
*/
|
|
367
|
+
assertOptionalObject(value, name, opts) {
|
|
368
|
+
if (value === undefined) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
assertObject(value, name, opts);
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Assert that `value` is a byte buffer (`Buffer` or `Uint8Array`) —
|
|
376
|
+
* strings are NOT accepted. For already-encoded material where a
|
|
377
|
+
* string would be ambiguous (ciphertext, signatures, raw key bytes).
|
|
378
|
+
* @param {unknown} value
|
|
379
|
+
* @param {string} name
|
|
380
|
+
* @param {AssertOptions} [opts]
|
|
381
|
+
*/
|
|
382
|
+
assertBytes(value, name, opts) {
|
|
383
|
+
if (!Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
|
|
384
|
+
fail(name, 'a Buffer or Uint8Array', opts);
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Assert that `value` is either a string or a byte buffer (`Buffer`
|
|
390
|
+
* or `Uint8Array`).
|
|
391
|
+
* @param {unknown} value
|
|
392
|
+
* @param {string} name
|
|
393
|
+
* @param {AssertOptions} [opts]
|
|
394
|
+
*/
|
|
395
|
+
assertBytesOrString(value, name, opts) {
|
|
396
|
+
if (typeof value !== 'string' && !Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
|
|
397
|
+
fail(name, 'a string or Buffer', opts);
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Assert that `encoding` is one of the accepted output/input
|
|
403
|
+
* encodings. Pass `allowBuffer: false` where a `Buffer` output makes
|
|
404
|
+
* no sense (verifying a string signature, decoding a token payload).
|
|
405
|
+
*
|
|
406
|
+
* @param {unknown} encoding
|
|
407
|
+
* @param {string} name
|
|
408
|
+
* @param {AssertOptions & { allowBuffer?: boolean }} [opts]
|
|
409
|
+
*/
|
|
410
|
+
assertEncoding(encoding, name, opts) {
|
|
411
|
+
const allowBuffer = opts?.allowBuffer !== false;
|
|
412
|
+
const valid = (typeof encoding === 'string' && ENCODINGS.has(encoding)) || (allowBuffer && encoding === 'buffer');
|
|
413
|
+
if (!valid) {
|
|
414
|
+
const list = allowBuffer ? "'hex', 'base64', 'base64url', or 'buffer'" : "'hex', 'base64', or 'base64url'";
|
|
415
|
+
fail(name, list, opts);
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* One-line sugar over {@link bindAsserts} — the overwhelmingly common
|
|
423
|
+
* pattern where a package binds every argument-shape failure to its
|
|
424
|
+
* own error class + `INVALID_ARGUMENT` code. Emits the standard
|
|
425
|
+
* `new ErrorClass(code, message, { cause })` shape, so the underlying
|
|
426
|
+
* `BaseError` `{ cause, status, details }` options object is respected.
|
|
427
|
+
*
|
|
428
|
+
* // packages/<pkg>/src/internal/guards.js
|
|
429
|
+
* import { defineGuards } from '@exortek/shared/asserts';
|
|
430
|
+
* import { PasswordError, ErrorCode } from '../errors.js';
|
|
431
|
+
*
|
|
432
|
+
* export const { assertString, invalidArgument, parse } =
|
|
433
|
+
* defineGuards(PasswordError, ErrorCode.INVALID_ARGUMENT);
|
|
434
|
+
*
|
|
435
|
+
* Reach for the raw {@link bindAsserts} when you need a different
|
|
436
|
+
* mapping (an alt code, a decorated message) — it stays available.
|
|
437
|
+
*
|
|
438
|
+
* @param {new (code: string, message: string, options?: { cause?: unknown }) => Error} ErrorClass
|
|
439
|
+
* @param {string} code
|
|
440
|
+
*/
|
|
441
|
+
function defineGuards(ErrorClass, code) {
|
|
442
|
+
return bindAsserts((message, extra) => new ErrorClass(code, message, extra));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Shared base error class — the single error structure behind every
|
|
447
|
+
* `@exortek/*` package's `errors.js`.
|
|
448
|
+
*
|
|
449
|
+
* Every package keeps its own class identity with a one-liner subclass;
|
|
450
|
+
* codes stay per-package frozen maps, status mapping is declared as a
|
|
451
|
+
* static field:
|
|
452
|
+
*
|
|
453
|
+
* import { BaseError } from '@exortek/shared/errors';
|
|
454
|
+
*
|
|
455
|
+
* export const ErrorCode = Object.freeze({
|
|
456
|
+
* INVALID_ARGUMENT: 'INVALID_ARGUMENT',
|
|
457
|
+
* INVALID_TOKEN: 'INVALID_TOKEN',
|
|
458
|
+
* });
|
|
459
|
+
*
|
|
460
|
+
* export class JwtError extends BaseError {
|
|
461
|
+
* static statuses = { INVALID_ARGUMENT: 400, INVALID_TOKEN: 401 };
|
|
462
|
+
* static defaultStatus = 500;
|
|
463
|
+
* }
|
|
464
|
+
*
|
|
465
|
+
* Instances carry a stable machine-readable `code` (branch on this,
|
|
466
|
+
* never on the message), an optional HTTP `status`, an optional
|
|
467
|
+
* `details` object, and the standard `cause` chain.
|
|
468
|
+
*/
|
|
469
|
+
class BaseError extends Error {
|
|
470
|
+
/**
|
|
471
|
+
* Optional `code → HTTP status` map declared on the subclass. When
|
|
472
|
+
* absent the instance carries no `status` at all — for HTTP-agnostic
|
|
473
|
+
* packages like `@exortek/crypto`.
|
|
474
|
+
*
|
|
475
|
+
* @type {Record<string, number> | undefined}
|
|
476
|
+
*/
|
|
477
|
+
static statuses = undefined;
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Fallback status for codes missing from `statuses`.
|
|
481
|
+
*
|
|
482
|
+
* @type {number}
|
|
483
|
+
*/
|
|
484
|
+
static defaultStatus = 500;
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* @param {string} code Stable machine-readable code; branch on this.
|
|
488
|
+
* @param {string} message Human-readable diagnostic. Free-form; may
|
|
489
|
+
* change across versions.
|
|
490
|
+
* @param {{ cause?: unknown, status?: number, details?: Record<string, unknown> }} [options]
|
|
491
|
+
*/
|
|
492
|
+
constructor(code, message, options = {}) {
|
|
493
|
+
super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
|
|
494
|
+
this.name = new.target.name;
|
|
495
|
+
/** @type {string} */
|
|
496
|
+
this.code = code;
|
|
497
|
+
const statuses = /** @type {typeof BaseError} */ (new.target).statuses;
|
|
498
|
+
if (options.status !== undefined) {
|
|
499
|
+
/** @type {number | undefined} */
|
|
500
|
+
this.status = options.status;
|
|
501
|
+
} else if (statuses !== undefined) {
|
|
502
|
+
// Object.hasOwn guards against inherited-property lookups for
|
|
503
|
+
// exotic code values ('toString', 'constructor', …).
|
|
504
|
+
this.status = Object.hasOwn(statuses, code)
|
|
505
|
+
? statuses[code]
|
|
506
|
+
: /** @type {typeof BaseError} */ (new.target).defaultStatus;
|
|
507
|
+
}
|
|
508
|
+
if (options.details) {
|
|
509
|
+
/** @type {Record<string, unknown> | undefined} */
|
|
510
|
+
this.details = options.details;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Stable machine-readable codes for every programmer-error failure that
|
|
517
|
+
* `@exortek/apikey` can raise. Branch on `code`, never on the message.
|
|
518
|
+
* Note: expected verify failures (bad secret, expired, revoked,
|
|
519
|
+
* missing scope) are NOT thrown — `verifyApiKey` returns
|
|
520
|
+
* `{ valid: false, reason }` so a wrong or stale key is a normal auth
|
|
521
|
+
* outcome, not an exception. Errors below fire only when the caller
|
|
522
|
+
* configured something wrong or the store misbehaved.
|
|
523
|
+
*/
|
|
524
|
+
|
|
525
|
+
const ErrorCode = Object.freeze({
|
|
526
|
+
INVALID_ARGUMENT: 'INVALID_ARGUMENT',
|
|
527
|
+
INVALID_PREFIX: 'INVALID_PREFIX',
|
|
528
|
+
INVALID_PEPPER: 'INVALID_PEPPER',
|
|
529
|
+
STORE_ERROR: 'STORE_ERROR',
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Every recoverable failure raised by this package. Carries a stable
|
|
534
|
+
* `code` (from {@link ErrorCode}) and a `status` — the HTTP response
|
|
535
|
+
* status a middleware layer would use when translating the error.
|
|
536
|
+
*/
|
|
537
|
+
class ApiKeyError extends BaseError {
|
|
538
|
+
static statuses = {
|
|
539
|
+
[ErrorCode.INVALID_ARGUMENT]: 400,
|
|
540
|
+
[ErrorCode.INVALID_PREFIX]: 400,
|
|
541
|
+
[ErrorCode.INVALID_PEPPER]: 400,
|
|
542
|
+
[ErrorCode.STORE_ERROR]: 500,
|
|
543
|
+
};
|
|
544
|
+
static defaultStatus = 500;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Argument guards bound to `ApiKeyError` — the package-wide binding of
|
|
549
|
+
* `@exortek/shared/asserts`. Import guards from here, never from the
|
|
550
|
+
* shared module directly.
|
|
551
|
+
*/
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
const { invalidArgument} = defineGuards(
|
|
555
|
+
ApiKeyError,
|
|
556
|
+
ErrorCode.INVALID_ARGUMENT,
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Scope match helpers.
|
|
561
|
+
*
|
|
562
|
+
* A **granted** scope authorises access. A **required** scope names the
|
|
563
|
+
* access an endpoint needs. `hasAll` / `hasAny` decide whether the
|
|
564
|
+
* granted set covers the requirement, with wildcard support:
|
|
565
|
+
*
|
|
566
|
+
* - `'*'` in the granted list → matches every required scope.
|
|
567
|
+
* - A `'ns:*'` suffix wildcard → matches any scope under the namespace
|
|
568
|
+
* (`'read:*'` covers `'read:users'` and `'read:posts'`).
|
|
569
|
+
* - Exact string match otherwise.
|
|
570
|
+
*
|
|
571
|
+
* Wildcards apply only on the granted side. `required` values must be
|
|
572
|
+
* concrete — an API cannot demand "some read:* scope"; it must state
|
|
573
|
+
* exactly which one it needs.
|
|
574
|
+
*/
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Does `granted` cover a single `required` scope?
|
|
579
|
+
* @param {string[]} granted
|
|
580
|
+
* @param {string} required
|
|
581
|
+
* @returns {boolean}
|
|
582
|
+
*/
|
|
583
|
+
function covers(granted, required) {
|
|
584
|
+
if (!isArray(granted) || !isString(required) || required.length === 0) {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
for (const g of granted) {
|
|
588
|
+
if (!isString(g)) {
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
if (g === '*' || g === required) {
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
// Suffix wildcard: `read:*` matches `read:users`.
|
|
595
|
+
if (g.endsWith(':*')) {
|
|
596
|
+
const ns = g.slice(0, -1); // 'read:'
|
|
597
|
+
if (required.startsWith(ns) && required.length > ns.length) {
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* All required scopes must be covered by granted.
|
|
607
|
+
* @param {string[]} granted
|
|
608
|
+
* @param {string[]} required
|
|
609
|
+
* @returns {boolean}
|
|
610
|
+
*/
|
|
611
|
+
function hasAll(granted, required) {
|
|
612
|
+
if (!isArray(required)) {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
if (required.length === 0) {
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
for (const r of required) {
|
|
619
|
+
if (!covers(granted, r)) {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* RFC 4648 §5 base64url codec.
|
|
628
|
+
*
|
|
629
|
+
* Node's `Buffer.from(str, 'base64url')` is lenient: it accepts
|
|
630
|
+
* padding, `+`, `/`, and whitespace. Wire-level use (JWS / JWT
|
|
631
|
+
* compact serialisation, JWK ingest, etc.) requires strict rejection
|
|
632
|
+
* of everything that isn't a canonical unpadded encoding — hence the
|
|
633
|
+
* roundtrip check in `decode`.
|
|
634
|
+
*
|
|
635
|
+
* Throws plain `Error` / `TypeError` on malformed input. Callers that
|
|
636
|
+
* need typed errors wrap the call at their surface boundary.
|
|
637
|
+
*/
|
|
638
|
+
|
|
639
|
+
const ALPHABET$1 = /^[A-Za-z0-9_-]*$/;
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* @param {Buffer | Uint8Array} bytes
|
|
643
|
+
* @returns {string}
|
|
644
|
+
*/
|
|
645
|
+
function encode(bytes) {
|
|
646
|
+
if (bytes == null || (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array))) {
|
|
647
|
+
throw new TypeError('base64url.encode: expected Buffer or Uint8Array');
|
|
648
|
+
}
|
|
649
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64url');
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* @param {string} text
|
|
654
|
+
* @returns {Buffer}
|
|
655
|
+
*/
|
|
656
|
+
function decode$1(text) {
|
|
657
|
+
if (typeof text !== 'string') {
|
|
658
|
+
throw new TypeError('base64url.decode: expected a string');
|
|
659
|
+
}
|
|
660
|
+
if (!ALPHABET$1.test(text)) {
|
|
661
|
+
throw new Error('base64url.decode: input contains characters outside the RFC 4648 §5 alphabet');
|
|
662
|
+
}
|
|
663
|
+
const bytes = Buffer.from(text, 'base64url');
|
|
664
|
+
if (bytes.toString('base64url') !== text) {
|
|
665
|
+
throw new Error('base64url.decode: input is not a canonical encoding');
|
|
666
|
+
}
|
|
667
|
+
return bytes;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Crockford Base32 codec — RFC 4648-style but with the four look-alike
|
|
672
|
+
* glyphs `I`, `L`, `O`, `U` dropped for human legibility. Uppercase on
|
|
673
|
+
* encode, case-insensitive on decode with Crockford check aliases:
|
|
674
|
+
* `I` / `L` decode as `1`, `O` decodes as `0`. The four uppercase check
|
|
675
|
+
* symbols (`*`, `~`, `$`, `=`) that some tooling appends are **not**
|
|
676
|
+
* supported.
|
|
677
|
+
*
|
|
678
|
+
* Consumers (crypto public codec, otp backup codes, jwt polymorphic
|
|
679
|
+
* encoding) all reach for the same alphabet + encode loop; this file
|
|
680
|
+
* is the single implementation. Errors here are plain `Error` /
|
|
681
|
+
* `TypeError`; each consumer wraps them into its own typed error at
|
|
682
|
+
* the surface boundary.
|
|
683
|
+
*/
|
|
684
|
+
|
|
685
|
+
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
686
|
+
|
|
687
|
+
const DECODE_TABLE = new Int8Array(128).fill(-1);
|
|
688
|
+
for (let i = 0; i < ALPHABET.length; i++) {
|
|
689
|
+
DECODE_TABLE[ALPHABET.charCodeAt(i)] = i;
|
|
690
|
+
const c = ALPHABET[i];
|
|
691
|
+
if (c >= 'A' && c <= 'Z') {
|
|
692
|
+
// Lowercase alias for letters only. Digits share code point rows
|
|
693
|
+
// that would collide with the +32 shift used for uppercase.
|
|
694
|
+
DECODE_TABLE[ALPHABET.charCodeAt(i) + 32] = i;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
// Crockford check aliases: I / L → 1, O → 0 (case-insensitive).
|
|
698
|
+
for (const [alias, canonical] of [
|
|
699
|
+
['I', '1'],
|
|
700
|
+
['i', '1'],
|
|
701
|
+
['L', '1'],
|
|
702
|
+
['l', '1'],
|
|
703
|
+
['O', '0'],
|
|
704
|
+
['o', '0'],
|
|
705
|
+
]) {
|
|
706
|
+
DECODE_TABLE[alias.charCodeAt(0)] = DECODE_TABLE[canonical.charCodeAt(0)];
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Decode a Crockford base32 string into a Buffer.
|
|
711
|
+
*
|
|
712
|
+
* Case-insensitive. `I` / `L` → `1`, `O` → `0`. Any other non-alphabet
|
|
713
|
+
* character throws.
|
|
714
|
+
*
|
|
715
|
+
* @param {string} input
|
|
716
|
+
* @returns {Buffer}
|
|
717
|
+
* @throws {TypeError} if `input` is not a string.
|
|
718
|
+
* @throws {Error} if `input` contains a character outside the alphabet.
|
|
719
|
+
*/
|
|
720
|
+
function decode(input) {
|
|
721
|
+
if (typeof input !== 'string') {
|
|
722
|
+
throw new TypeError('decode: input must be a string.');
|
|
723
|
+
}
|
|
724
|
+
if (input.length === 0) {
|
|
725
|
+
return Buffer.alloc(0);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const outLength = Math.floor((input.length * 5) / 8);
|
|
729
|
+
const out = Buffer.alloc(outLength);
|
|
730
|
+
let bits = 0;
|
|
731
|
+
let value = 0;
|
|
732
|
+
let index = 0;
|
|
733
|
+
for (let i = 0; i < input.length; i++) {
|
|
734
|
+
const code = input.charCodeAt(i);
|
|
735
|
+
const v = code < 128 ? DECODE_TABLE[code] : -1;
|
|
736
|
+
if (v === -1) {
|
|
737
|
+
throw new Error(
|
|
738
|
+
`input contains a non-Crockford character '${input[i]}' at index ${i}. Crockford base32 alphabet is 0-9 A-Z minus I L O U (case-insensitive); I/L decode as 1, O decodes as 0.`,
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
value = (value << 5) | v;
|
|
742
|
+
bits += 5;
|
|
743
|
+
if (bits >= 8) {
|
|
744
|
+
bits -= 8;
|
|
745
|
+
out[index++] = (value >>> bits) & 0xff;
|
|
746
|
+
value &= (1 << bits) - 1;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return out;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Length-safe `timingSafeEqual`.
|
|
754
|
+
*
|
|
755
|
+
* `crypto.timingSafeEqual` throws when its two arguments have
|
|
756
|
+
* different lengths — the exception path is itself a side channel
|
|
757
|
+
* that leaks "lengths differ" to a timing attacker even when the
|
|
758
|
+
* caller catches the throw. This wrapper closes that channel by
|
|
759
|
+
* always running a `timingSafeEqual` (against the shorter buffer
|
|
760
|
+
* padded to itself) and returning `false` on length mismatch, so the
|
|
761
|
+
* caller sees a boolean either way and total runtime does not
|
|
762
|
+
* short-circuit on length alone.
|
|
763
|
+
*/
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* @param {Buffer | Uint8Array} a
|
|
768
|
+
* @param {Buffer | Uint8Array} b
|
|
769
|
+
* @returns {boolean}
|
|
770
|
+
*/
|
|
771
|
+
function timingSafeEqual(a, b) {
|
|
772
|
+
const aBuf = Buffer.isBuffer(a) ? a : Buffer.from(a.buffer, a.byteOffset, a.byteLength);
|
|
773
|
+
const bBuf = Buffer.isBuffer(b) ? b : Buffer.from(b.buffer, b.byteOffset, b.byteLength);
|
|
774
|
+
if (aBuf.length !== bBuf.length) {
|
|
775
|
+
// Burn a comparison so total time doesn't short-circuit on the
|
|
776
|
+
// length gate — the attacker can't distinguish "length mismatch"
|
|
777
|
+
// from "content mismatch" by observed runtime.
|
|
778
|
+
timingSafeEqual$1(aBuf, aBuf);
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
return timingSafeEqual$1(aBuf, bBuf);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Token codec for `@exortek/apikey`.
|
|
786
|
+
*
|
|
787
|
+
* **Wire format:**
|
|
788
|
+
*
|
|
789
|
+
* <prefix>_<base64url(16 random bytes)>_<base64url(32 random bytes)>
|
|
790
|
+
* ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
791
|
+
* type id (128-bit — plaintext DB key) secret (256-bit — HMAC'd)
|
|
792
|
+
*
|
|
793
|
+
* sk_live_QVI0RExDX01Fbg_pTk9UX0FfUkVBTF9LRVlfNDJfQVJUSVNBTkFMX1BJWk
|
|
794
|
+
*
|
|
795
|
+
* Stripe / GitHub / Anthropic all use this three-segment shape. The
|
|
796
|
+
* split matters:
|
|
797
|
+
*
|
|
798
|
+
* - The **id** is stored in the clear so a request's incoming key can
|
|
799
|
+
* be found in the DB with a single O(1) primary-key lookup. It is not
|
|
800
|
+
* secret: an attacker who guesses an id alone cannot verify — the
|
|
801
|
+
* secret half is still gated by the HMAC compare.
|
|
802
|
+
* - The **secret** is never stored in the clear. Storage keeps only
|
|
803
|
+
* `HMAC-SHA256(secret, pepper)` (or plain SHA-256 when no pepper is
|
|
804
|
+
* configured). API keys are random 256-bit values — a fast hash is
|
|
805
|
+
* fine for storage; slow KDFs like Argon2 exist for low-entropy
|
|
806
|
+
* passwords, not for cryptographically random secrets.
|
|
807
|
+
* - The **pepper** (optional) is a server-side secret held in the app
|
|
808
|
+
* env, not the DB. A stolen database on its own does not let an
|
|
809
|
+
* attacker offline-brute the stored hashes; they also need the
|
|
810
|
+
* pepper. Peppers rotate via a newest-first array.
|
|
811
|
+
*
|
|
812
|
+
* The whole key is opaque to holders — do not parse the segments in
|
|
813
|
+
* client code; only the server's `parseApiKey` sees them.
|
|
814
|
+
*/
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
// Base32-Crockford alphabet is [0-9A-HJKMNP-TV-Z] — no ambiguous
|
|
818
|
+
// glyphs (`I`, `L`, `O`, `U` excluded), no separators (`_` reserved as
|
|
819
|
+
// our own delimiter), URL-safe, human-transcribable. 16 bytes → 26
|
|
820
|
+
// chars for the id; 32 bytes → 52 chars for the secret. The wire key
|
|
821
|
+
// is unambiguously three underscore-separated segments.
|
|
822
|
+
const ID_CHARS = 26;
|
|
823
|
+
const SECRET_CHARS = 52;
|
|
824
|
+
const CROCKFORD_RE = /^[0-9A-HJKMNP-TV-Z]+$/;
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Prefix grammar — Stripe-style: lowercase alpha, digits, underscore
|
|
828
|
+
* separators between 1-4 lower-alphanumeric segments. Keeps the token
|
|
829
|
+
* unambiguously parseable and safe to embed in URLs / headers.
|
|
830
|
+
*
|
|
831
|
+
* sk_live ✓
|
|
832
|
+
* pk_test ✓
|
|
833
|
+
* svc_prod_v2 ✓
|
|
834
|
+
* sk__live ✗ (empty segment)
|
|
835
|
+
* sk_live_ ✗ (trailing underscore)
|
|
836
|
+
* Sk_Live ✗ (uppercase)
|
|
837
|
+
*/
|
|
838
|
+
const PREFIX_RE = /^[a-z][a-z0-9]{0,15}(_[a-z0-9]{1,15}){0,3}$/;
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* @param {string} prefix
|
|
842
|
+
* @param {string} name
|
|
843
|
+
* @param {(msg: string) => Error} invalidArg
|
|
844
|
+
* @returns {string}
|
|
845
|
+
*/
|
|
846
|
+
function assertPrefix(prefix, name, invalidArg) {
|
|
847
|
+
if (!isString(prefix) || !PREFIX_RE.test(prefix)) {
|
|
848
|
+
throw invalidArg(
|
|
849
|
+
`${name} must match /^[a-z][a-z0-9]{0,15}(_[a-z0-9]{1,15}){0,3}$/ — Stripe-style lowercase with underscore-separated segments (e.g. 'sk_live', 'pk_test'); got ${JSON.stringify(prefix)}`,
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
return prefix;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Compute the storage hash of a raw secret. With a pepper: HMAC-SHA256
|
|
857
|
+
* keyed by the pepper. Without: plain SHA-256. Both return the digest
|
|
858
|
+
* as a 43-char base64url string.
|
|
859
|
+
*
|
|
860
|
+
* @param {Buffer} secretBytes
|
|
861
|
+
* @param {Buffer | null} pepper
|
|
862
|
+
* @returns {string}
|
|
863
|
+
*/
|
|
864
|
+
function hashSecret(secretBytes, pepper) {
|
|
865
|
+
const digest = pepper
|
|
866
|
+
? createHmac('sha256', pepper).update(secretBytes).digest()
|
|
867
|
+
: createHash('sha256').update(secretBytes).digest();
|
|
868
|
+
return encode(digest);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Timing-safe compare of a candidate hash against a stored hash. Both
|
|
873
|
+
* inputs are base64url strings of equal length — a length mismatch is
|
|
874
|
+
* treated as a mismatch after a fixed decode step, so an outside
|
|
875
|
+
* observer cannot learn the stored hash's length via timing.
|
|
876
|
+
*
|
|
877
|
+
* @param {string} candidate
|
|
878
|
+
* @param {string} stored
|
|
879
|
+
* @returns {boolean}
|
|
880
|
+
*/
|
|
881
|
+
function hashesMatch(candidate, stored) {
|
|
882
|
+
if (!isString(candidate) || !isString(stored) || candidate.length !== stored.length) {
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
let a;
|
|
886
|
+
let b;
|
|
887
|
+
try {
|
|
888
|
+
a = decode$1(candidate);
|
|
889
|
+
b = decode$1(stored);
|
|
890
|
+
} catch {
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
return timingSafeEqual(a, b);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Parse a raw key without verifying it. Returns the segments, or
|
|
898
|
+
* `null` when the shape is wrong. Never throws.
|
|
899
|
+
*
|
|
900
|
+
* Callers can use `parseApiKey(key).prefix` to route by key family
|
|
901
|
+
* in a middleware before hitting the store — but the parse result
|
|
902
|
+
* MUST NOT be trusted as authenticated. Only `verifyApiKey` proves
|
|
903
|
+
* the holder possesses the real secret.
|
|
904
|
+
*
|
|
905
|
+
* @param {string} key
|
|
906
|
+
* @returns {{ prefix: string, id: string, secret: string } | null}
|
|
907
|
+
*/
|
|
908
|
+
function parseApiKey(key) {
|
|
909
|
+
if (!isString(key)) {
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
// The prefix may contain '_' (`sk_live`, `svc_prod_v2`), but id and
|
|
913
|
+
// secret use the base32-crockford alphabet which contains no
|
|
914
|
+
// underscores — so the last two '_' cleanly bound them.
|
|
915
|
+
const lastUnderscore = key.lastIndexOf('_');
|
|
916
|
+
if (lastUnderscore <= 0) {return null;}
|
|
917
|
+
const prevUnderscore = key.lastIndexOf('_', lastUnderscore - 1);
|
|
918
|
+
if (prevUnderscore <= 0) {return null;}
|
|
919
|
+
const prefix = key.slice(0, prevUnderscore);
|
|
920
|
+
const id = key.slice(prevUnderscore + 1, lastUnderscore);
|
|
921
|
+
const secret = key.slice(lastUnderscore + 1);
|
|
922
|
+
if (!PREFIX_RE.test(prefix)) {return null;}
|
|
923
|
+
if (id.length !== ID_CHARS || !CROCKFORD_RE.test(id)) {return null;}
|
|
924
|
+
if (secret.length !== SECRET_CHARS || !CROCKFORD_RE.test(secret)) {return null;}
|
|
925
|
+
return { prefix, id, secret };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Compute the storage hash for a candidate raw key's secret half,
|
|
930
|
+
* trying peppers newest-first. Returns the hash + which pepper matched
|
|
931
|
+
* (or null if no pepper array was supplied).
|
|
932
|
+
*
|
|
933
|
+
* Used by `verifyApiKey` — it hashes the incoming secret with each
|
|
934
|
+
* pepper in the rotation and lets the caller decide which one to
|
|
935
|
+
* compare against. The needsRehash signal (secret matched an older
|
|
936
|
+
* pepper) is exposed to consumers so they can silently upgrade
|
|
937
|
+
* storage on the next successful auth.
|
|
938
|
+
*
|
|
939
|
+
* @param {string} rawSecret The base64url secret segment.
|
|
940
|
+
* @param {Buffer[] | null} peppers
|
|
941
|
+
* @returns {{ candidateHashes: string[] }}
|
|
942
|
+
*/
|
|
943
|
+
function candidateHashesFor(rawSecret, peppers) {
|
|
944
|
+
let secretBytes;
|
|
945
|
+
try {
|
|
946
|
+
secretBytes = decode(rawSecret);
|
|
947
|
+
} catch {
|
|
948
|
+
return { candidateHashes: [] };
|
|
949
|
+
}
|
|
950
|
+
if (!peppers || peppers.length === 0) {
|
|
951
|
+
return { candidateHashes: [hashSecret(secretBytes, null)] };
|
|
952
|
+
}
|
|
953
|
+
return {
|
|
954
|
+
candidateHashes: peppers.map(p => hashSecret(secretBytes, p)),
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* `@exortek/apikey` — Stripe-style prefixed API keys with HMAC-hashed
|
|
960
|
+
* storage, scope allowlists, and opt-in pepper rotation.
|
|
961
|
+
*
|
|
962
|
+
* See the README for the full API and worked examples.
|
|
963
|
+
*/
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
/** Prefix-specific factory so `assertPrefix` throws `INVALID_PREFIX`,
|
|
967
|
+
* not the generic `INVALID_ARGUMENT`. Callers can `catch (err)` and
|
|
968
|
+
* branch on `err.code === ErrorCode.INVALID_PREFIX` — a specific
|
|
969
|
+
* code beats a generic one for pinpointing config drift. */
|
|
970
|
+
const invalidPrefix = msg => new ApiKeyError(ErrorCode.INVALID_PREFIX, msg);
|
|
971
|
+
|
|
972
|
+
const MIN_PEPPER_BYTES = 16;
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Verify a raw API key. Returns `{ valid: true, ... }` on success, or
|
|
976
|
+
* `{ valid: false, reason }` on any expected failure. Never throws on
|
|
977
|
+
* a bad key — a wrong or stale key is a normal auth outcome.
|
|
978
|
+
*
|
|
979
|
+
* On success, callers get the stored `userId` / `scopes` / `metadata`
|
|
980
|
+
* and can pass them straight to their app's auth context.
|
|
981
|
+
* `needsRehash: true` in the success result signals the secret matched
|
|
982
|
+
* an older pepper — call `rehashApiKey(id, options)` to silently
|
|
983
|
+
* migrate storage to the newest pepper on the next natural chance.
|
|
984
|
+
*
|
|
985
|
+
* @param {string} rawKey
|
|
986
|
+
* @param {VerifyApiKeyOptions} options
|
|
987
|
+
* @returns {Promise<VerifyApiKeyResult>}
|
|
988
|
+
*/
|
|
989
|
+
async function verifyApiKey(rawKey, options) {
|
|
990
|
+
if (!isObject(options)) {
|
|
991
|
+
throw invalidArgument('verifyApiKey.options must be an object');
|
|
992
|
+
}
|
|
993
|
+
_assertStore(options.store, 'verifyApiKey.options.store');
|
|
994
|
+
const peppers = _normalizePeppers(options.peppers, 'verifyApiKey.options.peppers');
|
|
995
|
+
const requiredScopes = options.requiredScopes;
|
|
996
|
+
if (!isUndefined(requiredScopes) && !_isStringArray(requiredScopes)) {
|
|
997
|
+
throw invalidArgument('verifyApiKey.options.requiredScopes must be a string[] when provided');
|
|
998
|
+
}
|
|
999
|
+
if (!isUndefined(options.expectedPrefix)) {
|
|
1000
|
+
assertPrefix(options.expectedPrefix, 'verifyApiKey.options.expectedPrefix', invalidPrefix);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const parsed = parseApiKey(rawKey);
|
|
1004
|
+
if (!parsed) {
|
|
1005
|
+
return { valid: false, reason: 'malformed' };
|
|
1006
|
+
}
|
|
1007
|
+
if (!isUndefined(options.expectedPrefix) && parsed.prefix !== options.expectedPrefix) {
|
|
1008
|
+
return { valid: false, reason: 'prefix_mismatch' };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
let record;
|
|
1012
|
+
try {
|
|
1013
|
+
record = await options.store.getById(parsed.id);
|
|
1014
|
+
} catch {
|
|
1015
|
+
return { valid: false, reason: 'store_unavailable' };
|
|
1016
|
+
}
|
|
1017
|
+
if (!record) {
|
|
1018
|
+
return { valid: false, reason: 'not_found' };
|
|
1019
|
+
}
|
|
1020
|
+
const now = _now(options.now);
|
|
1021
|
+
if (record.revokedAt) {
|
|
1022
|
+
return { valid: false, reason: 'revoked' };
|
|
1023
|
+
}
|
|
1024
|
+
if (record.expiresAt && record.expiresAt <= now) {
|
|
1025
|
+
return { valid: false, reason: 'expired' };
|
|
1026
|
+
}
|
|
1027
|
+
if (parsed.prefix !== record.prefix) {
|
|
1028
|
+
// Someone submitted a key whose id belongs to a different prefix —
|
|
1029
|
+
// treat as bad, not merely 'prefix_mismatch', because the id half
|
|
1030
|
+
// was correct: the attacker guessed the id and swapped the prefix.
|
|
1031
|
+
return { valid: false, reason: 'prefix_mismatch' };
|
|
1032
|
+
}
|
|
1033
|
+
const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
|
|
1034
|
+
let matched = false;
|
|
1035
|
+
let matchedIndex = -1;
|
|
1036
|
+
for (let i = 0; i < candidateHashes.length; i++) {
|
|
1037
|
+
if (hashesMatch(candidateHashes[i], record.hash)) {
|
|
1038
|
+
matched = true;
|
|
1039
|
+
matchedIndex = i;
|
|
1040
|
+
break;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (!matched) {
|
|
1044
|
+
return { valid: false, reason: 'bad_secret' };
|
|
1045
|
+
}
|
|
1046
|
+
if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
|
|
1047
|
+
return { valid: false, reason: 'missing_scope' };
|
|
1048
|
+
}
|
|
1049
|
+
if (options.updateLastUsed === true) {
|
|
1050
|
+
// Fire-and-forget on the store side — failure to bump lastUsedAt
|
|
1051
|
+
// must not fail the verify itself. Await it so callers can wait
|
|
1052
|
+
// for consistency if they want.
|
|
1053
|
+
try {
|
|
1054
|
+
await options.store.update(parsed.id, { lastUsedAt: now });
|
|
1055
|
+
} catch {
|
|
1056
|
+
// Ignore — lastUsedAt is telemetry, not correctness.
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** @type {VerifyApiKeyResult} */
|
|
1061
|
+
const result = {
|
|
1062
|
+
valid: true,
|
|
1063
|
+
id: record.id,
|
|
1064
|
+
userId: record.userId,
|
|
1065
|
+
scopes: record.scopes,
|
|
1066
|
+
prefix: record.prefix,
|
|
1067
|
+
};
|
|
1068
|
+
if (record.name) {
|
|
1069
|
+
result.name = record.name;
|
|
1070
|
+
}
|
|
1071
|
+
if (record.environment) {
|
|
1072
|
+
result.environment = record.environment;
|
|
1073
|
+
}
|
|
1074
|
+
if (record.metadata) {
|
|
1075
|
+
result.metadata = record.metadata;
|
|
1076
|
+
}
|
|
1077
|
+
// Peppers rotate newest-first; non-zero index means the secret
|
|
1078
|
+
// matched an older pepper — the storage hash should migrate.
|
|
1079
|
+
if (peppers && matchedIndex > 0) {
|
|
1080
|
+
result.needsRehash = true;
|
|
1081
|
+
}
|
|
1082
|
+
return result;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// ---------------------------------------------------------------------
|
|
1086
|
+
// internals
|
|
1087
|
+
|
|
1088
|
+
function _now(override) {
|
|
1089
|
+
return isNumber(override) ? override : Date.now();
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function _isStringArray(v) {
|
|
1093
|
+
if (!isArray(v)) {
|
|
1094
|
+
return false;
|
|
1095
|
+
}
|
|
1096
|
+
for (const item of v) {
|
|
1097
|
+
if (!isString(item)) {
|
|
1098
|
+
return false;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return true;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function _assertStore(v, name) {
|
|
1105
|
+
if (
|
|
1106
|
+
!isObject(v) ||
|
|
1107
|
+
!isFunction(v.put) ||
|
|
1108
|
+
!isFunction(v.getById) ||
|
|
1109
|
+
!isFunction(v.update) ||
|
|
1110
|
+
!isFunction(v.revoke) ||
|
|
1111
|
+
!isFunction(v.revokeAllForUser) ||
|
|
1112
|
+
!isFunction(v.listByUser)
|
|
1113
|
+
) {
|
|
1114
|
+
throw invalidArgument(
|
|
1115
|
+
`${name} must be an object with put / getById / update / revoke / revokeAllForUser / listByUser methods`,
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function _normalizePeppers(input, name) {
|
|
1121
|
+
if (isUndefined(input)) {
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
1124
|
+
if (!isArray(input) || input.length === 0) {
|
|
1125
|
+
throw new ApiKeyError(ErrorCode.INVALID_PEPPER, `${name} must be a non-empty array when provided (newest first)`);
|
|
1126
|
+
}
|
|
1127
|
+
const out = [];
|
|
1128
|
+
for (let i = 0; i < input.length; i++) {
|
|
1129
|
+
const p = input[i];
|
|
1130
|
+
const buf = isString(p) ? Buffer.from(p, 'utf8') : isBytes(p) ? (isBuffer(p) ? p : Buffer.from(p)) : null;
|
|
1131
|
+
if (!buf) {
|
|
1132
|
+
throw new ApiKeyError(ErrorCode.INVALID_PEPPER, `${name}[${i}] must be a string / Buffer / Uint8Array`);
|
|
1133
|
+
}
|
|
1134
|
+
if (buf.length < MIN_PEPPER_BYTES) {
|
|
1135
|
+
throw new ApiKeyError(
|
|
1136
|
+
ErrorCode.INVALID_PEPPER,
|
|
1137
|
+
`${name}[${i}] must be at least ${MIN_PEPPER_BYTES} bytes; got ${buf.length}`,
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
out.push(buf);
|
|
1141
|
+
}
|
|
1142
|
+
return out;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Adapter-kit core for `@exortek/apikey` middleware.
|
|
1147
|
+
*
|
|
1148
|
+
* The framework-neutral logic lives here. The Express and Fastify
|
|
1149
|
+
* adapters (`middleware/express.js`, `middleware/fastify.js`) are
|
|
1150
|
+
* thin translators: request → `AdapterContext`, then hand off to
|
|
1151
|
+
* `runApiKey`, then translate the returned `AdapterResult` back into
|
|
1152
|
+
* the framework's own reply / next call.
|
|
1153
|
+
*
|
|
1154
|
+
* External integrators writing their own framework binding only need
|
|
1155
|
+
* to implement the two-line translation — every auth decision,
|
|
1156
|
+
* error-code, and header choice already lives here.
|
|
1157
|
+
*/
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* @typedef {object} AdapterContext
|
|
1162
|
+
* @property {(name: string) => string | undefined} getHeader
|
|
1163
|
+
* @property {string} [method]
|
|
1164
|
+
* @property {string} [ip]
|
|
1165
|
+
* @property {Record<string, unknown>} [query]
|
|
1166
|
+
*/
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* @typedef {object} ApiKeyMiddlewareOptions
|
|
1170
|
+
* @property {import('../index.js').ApiKeyStore} store
|
|
1171
|
+
* @property {(Buffer | Uint8Array | string)[]} [peppers]
|
|
1172
|
+
* @property {string[]} [requiredScopes]
|
|
1173
|
+
* @property {string} [expectedPrefix]
|
|
1174
|
+
* @property {boolean} [updateLastUsed=false]
|
|
1175
|
+
* @property {string} [headerName='authorization']
|
|
1176
|
+
* Case-insensitive. The default reads `Authorization: Bearer <key>`;
|
|
1177
|
+
* a `x-api-key` config reads `X-API-Key: <key>` directly.
|
|
1178
|
+
* @property {'bearer' | 'raw'} [scheme='bearer']
|
|
1179
|
+
* `'bearer'` expects `Bearer <key>`; `'raw'` uses the header value as-is.
|
|
1180
|
+
* @property {boolean} [allowQueryParam=false]
|
|
1181
|
+
* When true, falls back to `?api_key=<key>` if no matching header is
|
|
1182
|
+
* found. Discouraged because query strings leak into access logs and
|
|
1183
|
+
* referer headers — off by default.
|
|
1184
|
+
* @property {string} [queryParamName='api_key']
|
|
1185
|
+
* @property {string} [attach='apiKey']
|
|
1186
|
+
* Property name attached to the request object on success.
|
|
1187
|
+
* @property {(ctx: AdapterContext) => string | undefined} [tokenFromRequest]
|
|
1188
|
+
* Override the default extraction entirely.
|
|
1189
|
+
*/
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* @typedef {{
|
|
1193
|
+
* verifyResult: import('../index.js').VerifyApiKeyResult,
|
|
1194
|
+
* response?: { status: number, body: { error: string, reason?: string } }
|
|
1195
|
+
* }} AdapterResult
|
|
1196
|
+
*/
|
|
1197
|
+
|
|
1198
|
+
const DEFAULT_HEADER = 'authorization';
|
|
1199
|
+
const DEFAULT_QUERY = 'api_key';
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Normalize + validate middleware options. Throws `ApiKeyError` on
|
|
1203
|
+
* misconfig so the mistake surfaces at boot, not per-request.
|
|
1204
|
+
*
|
|
1205
|
+
* @param {ApiKeyMiddlewareOptions} options
|
|
1206
|
+
*/
|
|
1207
|
+
function normalizeOptions(options) {
|
|
1208
|
+
if (!options || typeof options !== 'object') {
|
|
1209
|
+
throw invalidArgument('apiKey middleware options must be an object');
|
|
1210
|
+
}
|
|
1211
|
+
if (!options.store) {
|
|
1212
|
+
throw invalidArgument('apiKey middleware options.store is required');
|
|
1213
|
+
}
|
|
1214
|
+
if (!isUndefined(options.tokenFromRequest) && !isFunction(options.tokenFromRequest)) {
|
|
1215
|
+
throw invalidArgument('apiKey middleware options.tokenFromRequest must be a function when provided');
|
|
1216
|
+
}
|
|
1217
|
+
const attach = options.attach ?? 'apiKey';
|
|
1218
|
+
if (!isString(attach) || attach.length === 0) {
|
|
1219
|
+
throw invalidArgument('apiKey middleware options.attach must be a non-empty string');
|
|
1220
|
+
}
|
|
1221
|
+
return {
|
|
1222
|
+
store: options.store,
|
|
1223
|
+
peppers: options.peppers,
|
|
1224
|
+
requiredScopes: options.requiredScopes,
|
|
1225
|
+
expectedPrefix: options.expectedPrefix,
|
|
1226
|
+
updateLastUsed: options.updateLastUsed === true,
|
|
1227
|
+
headerName: (options.headerName ?? DEFAULT_HEADER).toLowerCase(),
|
|
1228
|
+
scheme: options.scheme ?? 'bearer',
|
|
1229
|
+
allowQueryParam: options.allowQueryParam === true,
|
|
1230
|
+
queryParamName: options.queryParamName ?? DEFAULT_QUERY,
|
|
1231
|
+
attach,
|
|
1232
|
+
tokenFromRequest: options.tokenFromRequest,
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Extract the raw key from a request context per the configured
|
|
1238
|
+
* scheme. Returns `null` when no candidate was found (the caller then
|
|
1239
|
+
* responds with 401 `missing_key`).
|
|
1240
|
+
*
|
|
1241
|
+
* @param {AdapterContext} ctx
|
|
1242
|
+
* @param {ReturnType<typeof normalizeOptions>} config
|
|
1243
|
+
* @returns {string | null}
|
|
1244
|
+
*/
|
|
1245
|
+
function extractKey(ctx, config) {
|
|
1246
|
+
if (config.tokenFromRequest) {
|
|
1247
|
+
const custom = config.tokenFromRequest(ctx);
|
|
1248
|
+
return isString(custom) && custom.length > 0 ? custom : null;
|
|
1249
|
+
}
|
|
1250
|
+
const raw = ctx.getHeader(config.headerName);
|
|
1251
|
+
if (isString(raw) && raw.length > 0) {
|
|
1252
|
+
if (config.scheme === 'bearer') {
|
|
1253
|
+
const m = /^bearer\s+(.+)$/i.exec(raw.trim());
|
|
1254
|
+
if (m) {
|
|
1255
|
+
return m[1].trim();
|
|
1256
|
+
}
|
|
1257
|
+
} else {
|
|
1258
|
+
return raw.trim();
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
if (config.allowQueryParam && ctx.query) {
|
|
1262
|
+
const q = ctx.query[config.queryParamName];
|
|
1263
|
+
if (isString(q) && q.length > 0) {
|
|
1264
|
+
return q;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
return null;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Run the verify against the request. Returns a discriminated result:
|
|
1272
|
+
* on success the caller attaches `verifyResult` to the request and
|
|
1273
|
+
* proceeds; on failure the caller sends the returned `response`.
|
|
1274
|
+
*
|
|
1275
|
+
* @param {AdapterContext} ctx
|
|
1276
|
+
* @param {ReturnType<typeof normalizeOptions>} config
|
|
1277
|
+
* @returns {Promise<AdapterResult>}
|
|
1278
|
+
*/
|
|
1279
|
+
async function runApiKey(ctx, config) {
|
|
1280
|
+
const raw = extractKey(ctx, config);
|
|
1281
|
+
if (!raw) {
|
|
1282
|
+
return {
|
|
1283
|
+
verifyResult: { valid: false, reason: 'malformed' },
|
|
1284
|
+
response: { status: 401, body: { error: 'missing_api_key' } },
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
const verifyResult = await verifyApiKey(raw, {
|
|
1288
|
+
store: config.store,
|
|
1289
|
+
peppers: config.peppers,
|
|
1290
|
+
requiredScopes: config.requiredScopes,
|
|
1291
|
+
expectedPrefix: config.expectedPrefix,
|
|
1292
|
+
updateLastUsed: config.updateLastUsed,
|
|
1293
|
+
});
|
|
1294
|
+
if (!verifyResult.valid) {
|
|
1295
|
+
const status = _statusFor(verifyResult.reason);
|
|
1296
|
+
return {
|
|
1297
|
+
verifyResult,
|
|
1298
|
+
response: { status, body: { error: 'invalid_api_key', reason: verifyResult.reason } },
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
return { verifyResult };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function _statusFor(reason) {
|
|
1305
|
+
switch (reason) {
|
|
1306
|
+
case 'missing_scope':
|
|
1307
|
+
return 403;
|
|
1308
|
+
case 'store_unavailable':
|
|
1309
|
+
return 503;
|
|
1310
|
+
default:
|
|
1311
|
+
return 401;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Fastify plugin for `@exortek/apikey`.
|
|
1317
|
+
*
|
|
1318
|
+
* import Fastify from 'fastify';
|
|
1319
|
+
* import { apiKeyPlugin } from '@exortek/apikey/middleware/fastify';
|
|
1320
|
+
* import { memoryStore } from '@exortek/apikey/stores';
|
|
1321
|
+
*
|
|
1322
|
+
* const app = Fastify();
|
|
1323
|
+
* await app.register(apiKeyPlugin, {
|
|
1324
|
+
* store: memoryStore(),
|
|
1325
|
+
* requiredScopes: ['read'],
|
|
1326
|
+
* updateLastUsed: true,
|
|
1327
|
+
* });
|
|
1328
|
+
*
|
|
1329
|
+
* app.get('/v1/whoami', async req => req.apiKey);
|
|
1330
|
+
*
|
|
1331
|
+
* Wrapped with `fastify-plugin` so the `preHandler` hook and the
|
|
1332
|
+
* `req.apiKey` decorator escape encapsulation and apply to routes
|
|
1333
|
+
* registered as siblings in the parent scope. Register the plugin
|
|
1334
|
+
* inside a scoped `app.register(async scope => …)` block to control
|
|
1335
|
+
* which route group needs an API key — the usual Fastify pattern for
|
|
1336
|
+
* "protect only these routes with auth".
|
|
1337
|
+
*
|
|
1338
|
+
* `fastify-plugin` is an **optional peer** — install it with
|
|
1339
|
+
* `npm i fastify fastify-plugin` (or `yarn add`) alongside the fastify
|
|
1340
|
+
* runtime. Not a hard dependency because Express-only consumers of
|
|
1341
|
+
* `@exortek/apikey` should not have to install it.
|
|
1342
|
+
*/
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
async function apiKeyPluginFn(app, options) {
|
|
1346
|
+
const config = normalizeOptions(options);
|
|
1347
|
+
|
|
1348
|
+
app.decorateRequest(config.attach, null);
|
|
1349
|
+
|
|
1350
|
+
app.addHook('preHandler', async (req, reply) => {
|
|
1351
|
+
const ctx = {
|
|
1352
|
+
getHeader: name => req.headers?.[name],
|
|
1353
|
+
method: req.method,
|
|
1354
|
+
ip: req.ip,
|
|
1355
|
+
query: req.query,
|
|
1356
|
+
};
|
|
1357
|
+
const result = await runApiKey(ctx, config);
|
|
1358
|
+
if (result.response) {
|
|
1359
|
+
reply.code(result.response.status).send(result.response.body);
|
|
1360
|
+
return reply;
|
|
1361
|
+
}
|
|
1362
|
+
req[config.attach] = result.verifyResult;
|
|
1363
|
+
return undefined;
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const apiKeyPlugin = fp(apiKeyPluginFn, {
|
|
1368
|
+
fastify: '4.x || 5.x',
|
|
1369
|
+
name: '@exortek/apikey',
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
export { apiKeyPlugin };
|