@exortek/apikey 1.0.0 → 1.0.2

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.
@@ -1,4 +1,3 @@
1
- import fp from 'fastify-plugin';
2
1
  import { timingSafeEqual as timingSafeEqual$1, createHmac, createHash } from 'node:crypto';
3
2
 
4
3
  /**
@@ -129,6 +128,67 @@ function isBuffer(value) {
129
128
  return Buffer.isBuffer(value);
130
129
  }
131
130
 
131
+ let counter = 0;
132
+
133
+ /**
134
+ * Mark a function as a Fastify plugin with encapsulation bypass.
135
+ *
136
+ * Drop-in replacement for the `fastify-plugin` npm package — covers
137
+ * the same symbols and option handling without the external dependency.
138
+ *
139
+ * @param {Function} fn Plugin function `(fastify, opts) => …`
140
+ * @param {string | PluginOptions} [options]
141
+ * A semver range string (shorthand for `{ fastify: range }`) or a
142
+ * full options object.
143
+ * @returns {Function} The same function, decorated with plugin metadata.
144
+ *
145
+ * @typedef {object} PluginOptions
146
+ * @property {string} [name] Display name. Auto-generated from
147
+ * `fn.name` when omitted.
148
+ * @property {string} [fastify] Semver range the plugin is compatible
149
+ * with (e.g. `'>=4'`). Fastify checks
150
+ * this at register time.
151
+ * @property {string[]} [decorators] Decorators the plugin expects to
152
+ * exist before it runs.
153
+ * @property {string[]} [dependencies] Plugins that must be registered
154
+ * before this one.
155
+ * @property {boolean} [encapsulate] When `true`, the plugin stays
156
+ * encapsulated (skip-override = false).
157
+ * Default `false` (skip override).
158
+ */
159
+ function fastifyPlugin(fn, options) {
160
+ if (fn && fn.default !== undefined) {
161
+ fn = fn.default;
162
+ }
163
+
164
+ if (!isFunction(fn)) {
165
+ throw new TypeError(`fastifyPlugin expects a function, instead got '${typeof fn}'`);
166
+ }
167
+
168
+ let opts;
169
+ if (isString(options)) {
170
+ opts = { fastify: options };
171
+ } else if (isObject(options)) {
172
+ opts = { ...options };
173
+ } else {
174
+ opts = {};
175
+ }
176
+
177
+ if (!opts.name) {
178
+ opts.name = (fn.name || 'anonymous') + '-auto-' + counter++;
179
+ }
180
+
181
+ fn[Symbol.for('skip-override')] = opts.encapsulate !== true;
182
+ fn[Symbol.for('fastify.display-name')] = opts.name;
183
+ fn[Symbol.for('plugin-meta')] = opts;
184
+
185
+ if (!fn.default) {
186
+ fn.default = fn;
187
+ }
188
+
189
+ return fn;
190
+ }
191
+
132
192
  /**
133
193
  * Imperative single-argument guard helpers — the everyday
134
194
  * `assertPositiveInt(x, 'name')` shape used at API boundaries.
@@ -786,11 +846,11 @@ function timingSafeEqual(a, b) {
786
846
  *
787
847
  * **Wire format:**
788
848
  *
789
- * <prefix>_<base64url(16 random bytes)>_<base64url(32 random bytes)>
790
- * ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
791
- * type id (128-bit — plaintext DB key) secret (256-bit — HMAC'd)
849
+ * <prefix>_<crockford-base32(16 random bytes)>_<crockford-base32(32 random bytes)>
850
+ * ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
851
+ * type id (128-bit, 26 chars) secret (256-bit, 52 chars)
792
852
  *
793
- * sk_live_QVI0RExDX01Fbg_pTk9UX0FfUkVBTF9LRVlfNDJfQVJUSVNBTkFMX1BJWk
853
+ * sk_live_<id-26-chars>_<secret-52-chars>
794
854
  *
795
855
  * Stripe / GitHub / Anthropic all use this three-segment shape. The
796
856
  * split matters:
@@ -870,9 +930,9 @@ function hashSecret(secretBytes, pepper) {
870
930
 
871
931
  /**
872
932
  * 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.
933
+ * inputs are base64url strings. A length mismatch early-returns `false`
934
+ * — all stored hashes are fixed-length (43-char SHA-256 digests), so
935
+ * the length check leaks no information about the stored value.
876
936
  *
877
937
  * @param {string} candidate
878
938
  * @param {string} stored
@@ -913,15 +973,25 @@ function parseApiKey(key) {
913
973
  // secret use the base32-crockford alphabet which contains no
914
974
  // underscores — so the last two '_' cleanly bound them.
915
975
  const lastUnderscore = key.lastIndexOf('_');
916
- if (lastUnderscore <= 0) {return null;}
976
+ if (lastUnderscore <= 0) {
977
+ return null;
978
+ }
917
979
  const prevUnderscore = key.lastIndexOf('_', lastUnderscore - 1);
918
- if (prevUnderscore <= 0) {return null;}
980
+ if (prevUnderscore <= 0) {
981
+ return null;
982
+ }
919
983
  const prefix = key.slice(0, prevUnderscore);
920
984
  const id = key.slice(prevUnderscore + 1, lastUnderscore);
921
985
  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;}
986
+ if (!PREFIX_RE.test(prefix)) {
987
+ return null;
988
+ }
989
+ if (id.length !== ID_CHARS || !CROCKFORD_RE.test(id)) {
990
+ return null;
991
+ }
992
+ if (secret.length !== SECRET_CHARS || !CROCKFORD_RE.test(secret)) {
993
+ return null;
994
+ }
925
995
  return { prefix, id, secret };
926
996
  }
927
997
 
@@ -1328,45 +1398,36 @@ function _statusFor(reason) {
1328
1398
  *
1329
1399
  * app.get('/v1/whoami', async req => req.apiKey);
1330
1400
  *
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.
1401
+ * The plugin bypasses Fastify's encapsulation so the `preHandler` hook
1402
+ * and the `req.apiKey` decorator apply to routes in the parent scope.
1403
+ * Register inside a scoped `app.register(async scope => …)` block to
1404
+ * control which route group needs an API key.
1342
1405
  */
1343
1406
 
1344
1407
 
1345
- async function apiKeyPluginFn(app, options) {
1346
- const config = normalizeOptions(options);
1408
+ const apiKeyPlugin = fastifyPlugin(
1409
+ async function apiKeyPluginFn(app, options) {
1410
+ const config = normalizeOptions(options);
1347
1411
 
1348
- app.decorateRequest(config.attach, null);
1412
+ app.decorateRequest(config.attach, null);
1349
1413
 
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
- });
1414
+ app.addHook('preHandler', async (req, reply) => {
1415
+ const ctx = {
1416
+ getHeader: name => req.headers?.[name],
1417
+ method: req.method,
1418
+ ip: req.ip,
1419
+ query: req.query,
1420
+ };
1421
+ const result = await runApiKey(ctx, config);
1422
+ if (result.response) {
1423
+ reply.code(result.response.status).send(result.response.body);
1424
+ return reply;
1425
+ }
1426
+ req[config.attach] = result.verifyResult;
1427
+ return undefined;
1428
+ });
1429
+ },
1430
+ { name: '@exortek/apikey' },
1431
+ );
1371
1432
 
1372
1433
  export { apiKeyPlugin };