@exortek/apikey 1.0.2 → 1.1.1

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 CHANGED
@@ -1,5 +1,39 @@
1
1
  # @exortek/apikey
2
2
 
3
+ ## 1.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - e53ae64: Fix polynomial-ReDoS (quadratic regex backtracking) surfaced by CodeQL, plus small code-quality fixes.
8
+ Bundled `@exortek/shared` utilities (base32, base64, duration) are inlined into the packages listed above, so each
9
+ ships the fix.
10
+
11
+ - Replace `/=+$/` trailing-padding strips (base32 / base64 decode, OTP provisioning URI) with linear scans —
12
+ `"=".repeat(n) + "x"` was O(n²).
13
+ - Rework the duration parser regex so surrounding whitespace can no longer cause O(n²) matching (trim first, single
14
+ interior `\s*`).
15
+ - Strip trailing spaces/dots in filename sanitisation with a linear scan — upload filenames are attacker-controlled.
16
+ - Cap email length before the regex in magic-link.
17
+ - Thread the JWT decode label into error messages (removes dead arguments and gives failures which segment broke).
18
+ - Drop a duplicate character in a UA browser-detection regex character class.
19
+
20
+ DoS-hardening and hygiene only — no API or behavioural change for valid input.
21
+
22
+ ## 1.1.0
23
+
24
+ ### Minor Changes
25
+
26
+ - b1f5d6e: Add `customStore(impl)` — wrap your own `ApiKeyStore` implementation with validation (fails at construction
27
+ time if a required method is missing) and transparent sync/async wrapping, instead of hand-assembling the interface
28
+ yourself.
29
+
30
+ ### Patch Changes
31
+
32
+ - 40fc707: Docs: add a "Rate limiting" section explaining why `@exortek/apikey` ships no built-in limiter and how to
33
+ front the mint/verify routes with `@exortek/security`'s rate-limit (with a runnable demo in both example servers);
34
+ document the previously-undocumented `revokeAllForUser` and `listApiKeys` functions; align README structure (badges,
35
+ `Why`, `Highlights`, `Links`) with the rest of the `@exortek/*` packages.
36
+
3
37
  ## 1.0.2
4
38
 
5
39
  ### Patch Changes
package/README.md CHANGED
@@ -3,10 +3,15 @@
3
3
  > Stripe-style prefixed API keys for Node.js 22+ — 3-segment tokens (`sk_live_<id>_<secret>`), HMAC-SHA256 storage hash with optional pepper rotation, scope allowlists, memory + Redis stores, Express + Fastify middleware. Zero non-`@exortek/*` runtime dependencies.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@exortek/apikey.svg?color=cb3837)](https://www.npmjs.com/package/@exortek/apikey)
6
+ [![tests](https://img.shields.io/badge/tests-passing-brightgreen)](https://github.com/ExorTek/auth/actions/workflows/ci.yml)
6
7
  [![node](https://img.shields.io/node/v/@exortek/apikey.svg?color=339933)](https://nodejs.org)
7
8
  [![install size](https://packagephobia.com/badge?p=@exortek/apikey)](https://packagephobia.com/result?p=@exortek/apikey)
9
+ [![types](https://img.shields.io/badge/types-included-3178C6)](./dist/index.d.ts)
8
10
  [![license](https://img.shields.io/npm/l/@exortek/apikey.svg?color=blue)](./LICENSE)
9
- [![tests](https://img.shields.io/badge/tests-passing-brightgreen)](https://github.com/ExorTek/auth/actions/workflows/ci.yml)
11
+
12
+ 📖 **Docs:** [**auth.memet.dev/apikey**](https://auth.memet.dev/apikey)
13
+
14
+ ## Why
10
15
 
11
16
  Every API vendor rebuilds the same six things: a prefixed token format so keys are recognisable in logs, hashed storage so a leaked DB doesn't leak secrets, scope allowlists so a machine token can't accidentally admin your infrastructure, revocation, a per-user list for the "manage API keys" UI, and a middleware that turns `Authorization: Bearer <key>` into a verified caller identity. `@exortek/apikey` ships all six.
12
17
 
@@ -124,6 +129,27 @@ Never throws on a bad key. Failure reasons:
124
129
  | `missing_scope` | `requiredScopes` not covered by the record's `scopes`. |
125
130
  | `store_unavailable` | `store.getById` threw. |
126
131
 
132
+ ### `revokeAllForUser(userId, options)`
133
+
134
+ ```ts
135
+ revokeAllForUser(userId: string, {
136
+ store: ApiKeyStore,
137
+ reason?: string,
138
+ }): Promise<number> // count of records actually revoked
139
+ ```
140
+
141
+ Revoke every non-revoked key belonging to `userId` in one call — password reset, account termination, etc.
142
+
143
+ ### `listApiKeys(userId, options)`
144
+
145
+ ```ts
146
+ listApiKeys(userId: string, {
147
+ store: ApiKeyStore,
148
+ }): Promise<ApiKeyRecord[]> // most-recently-used first
149
+ ```
150
+
151
+ For a "manage API keys" UI. Hide `record.hash` before rendering — only the plaintext `id` is safe to display.
152
+
127
153
  ### Scopes: `covers` / `hasAll` / `hasAny`
128
154
 
129
155
  ```js
@@ -221,12 +247,37 @@ app.get('/v1/whoami', async req => req.apiKey);
221
247
 
222
248
  Invalid keys respond with 401 `{ error: 'invalid_api_key', reason }`; missing scope responds 403; store failure responds 503.
223
249
 
250
+ ## Rate limiting
251
+
252
+ `@exortek/apikey` doesn't ship a built-in limiter, and deliberately so: `verifyApiKey` is an O(1) store lookup plus a fast HMAC compare, and the secret half is 256-bit random — there's no brute-force cost here for a limiter to blunt (see [Token format](#token-format)). What you actually want to bound is generic request volume, which isn't specific to this package.
253
+
254
+ Put [`@exortek/security`](https://www.npmjs.com/package/@exortek/security)'s rate-limit in front of the routes that matter:
255
+
256
+ - **Key-minting route** (`POST /keys` or wherever `createApiKey` is called) — this one has no key-based auth yet, so limit by IP.
257
+ - **Guarded routes** — if you want to slow down credential stuffing beyond a firehose, limit by the `id` segment (plaintext, already in your logs) instead of IP, so one leaked key can't be used to exhaust another caller's budget.
258
+
259
+ ```js
260
+ import { rateLimit } from '@exortek/security';
261
+ import { rateLimitMiddleware } from '@exortek/security/express';
262
+
263
+ const mintLimiter = rateLimitMiddleware({
264
+ limiter: rateLimit.sliding({ requests: 5, window: '1m', store: rateLimit.stores.memory() }),
265
+ });
266
+
267
+ app.post('/keys', mintLimiter, async (req, res) => {
268
+ /* createApiKey(...) */
269
+ });
270
+ ```
271
+
272
+ See `@exortek/apikey/examples/express-server.js` and `fastify-server.js` for a runnable version.
273
+
224
274
  ## Stores
225
275
 
226
276
  `@exortek/apikey/stores` ships two implementations. Any object matching the `ApiKeyStore` interface works — bring your own if you already have a keys table in your DB.
227
277
 
228
278
  - **`memoryStore()`** — in-process Map with a reverse index by `userId`. Not cluster-safe. Fine for dev / single-node prod / tests.
229
279
  - **`redisStore(client, { keyPrefix? })`** — layout: `<keyPrefix><id>` (JSON blob) + `<keyPrefix>u:<userId>` (SADD-set of ids). Cluster-safe. Works with `ioredis`, `node-redis@4+`, `@upstash/redis`.
280
+ - **`customStore(impl)`** — wrap your own DB-backed implementation. Validates the required methods exist at construction time (not on the first `createApiKey` call) and wraps sync return values in a Promise, so `impl` can be sync or async.
230
281
 
231
282
  ### The interface
232
283
 
@@ -258,6 +309,20 @@ import { ApiKeyError, ErrorCode } from '@exortek/apikey';
258
309
 
259
310
  Expected verify failures do NOT throw — they surface as `{ valid: false, reason }`. See the failure-reasons table above.
260
311
 
312
+ ## Highlights
313
+
314
+ - Stripe-style 3-segment tokens (`prefix_id_secret`) — recognisable in logs, O(1) lookup by id.
315
+ - HMAC-SHA256 storage hash with optional pepper rotation — never store the raw secret.
316
+ - Scope allowlists with namespace/super wildcards (`covers` / `hasAll` / `hasAny`).
317
+ - Revocation, per-user listing, and last-used tracking for a "manage API keys" UI.
318
+ - Express + Fastify middleware, memory + Redis stores, zero non-`@exortek/*` runtime dependencies.
319
+
320
+ ## Links
321
+
322
+ - **Source:** [github.com/ExorTek/auth](https://github.com/ExorTek/auth)
323
+ - **Issues & discussions:** [github.com/ExorTek/auth/issues](https://github.com/ExorTek/auth/issues)
324
+ - **Changelog:** [CHANGELOG.md](./CHANGELOG.md)
325
+
261
326
  ## License
262
327
 
263
- MIT
328
+ MIT © ExorTek — see [LICENSE](./LICENSE).
package/dist/index.cjs CHANGED
@@ -65,7 +65,11 @@ const UNIT_MS = Object.freeze({
65
65
  years: 31_536_000_000,
66
66
  });
67
67
 
68
- const DURATION_RE = /^\s*(-?\d+(?:\.\d+)?)\s*([a-z]+)?\s*$/i;
68
+ // Note: no leading/trailing `\s*` — the caller trims first. Two `\s*` runs
69
+ // around the optional unit (`\s*([a-z]+)?\s*`) made this O(n^2) on hostile
70
+ // input like `"1" + " ".repeat(n) + "!"`; trimming plus a single interior
71
+ // `\s*` keeps matching linear.
72
+ const DURATION_RE = /^(-?\d+(?:\.\d+)?)\s*([a-z]+)?$/i;
69
73
 
70
74
  const SUPPORTED_UNITS = 'ms, s, m, h, d, w, y (and long/plural forms)';
71
75
 
@@ -95,7 +99,7 @@ function parseDuration(input) {
95
99
  throw new TypeError(`parseDuration: expected string or number; got ${typeof input}`);
96
100
  }
97
101
 
98
- const match = DURATION_RE.exec(input);
102
+ const match = DURATION_RE.exec(input.trim());
99
103
  if (!match) {
100
104
  throw new Error(
101
105
  `parseDuration: could not parse ${JSON.stringify(input)}. Examples: 900 (ms), '15m', '2h', '7d', '500ms', '30s'.`,
package/dist/index.mjs CHANGED
@@ -63,7 +63,11 @@ const UNIT_MS = Object.freeze({
63
63
  years: 31_536_000_000,
64
64
  });
65
65
 
66
- const DURATION_RE = /^\s*(-?\d+(?:\.\d+)?)\s*([a-z]+)?\s*$/i;
66
+ // Note: no leading/trailing `\s*` — the caller trims first. Two `\s*` runs
67
+ // around the optional unit (`\s*([a-z]+)?\s*`) made this O(n^2) on hostile
68
+ // input like `"1" + " ".repeat(n) + "!"`; trimming plus a single interior
69
+ // `\s*` keeps matching linear.
70
+ const DURATION_RE = /^(-?\d+(?:\.\d+)?)\s*([a-z]+)?$/i;
67
71
 
68
72
  const SUPPORTED_UNITS = 'ms, s, m, h, d, w, y (and long/plural forms)';
69
73
 
@@ -93,7 +97,7 @@ function parseDuration(input) {
93
97
  throw new TypeError(`parseDuration: expected string or number; got ${typeof input}`);
94
98
  }
95
99
 
96
- const match = DURATION_RE.exec(input);
100
+ const match = DURATION_RE.exec(input.trim());
97
101
  if (!match) {
98
102
  throw new Error(
99
103
  `parseDuration: could not parse ${JSON.stringify(input)}. Examples: 900 (ms), '15m', '2h', '7d', '500ms', '30s'.`,
@@ -0,0 +1,8 @@
1
+ export function customStore(impl: any): {
2
+ put: (record: any) => Promise<any>;
3
+ getById: (id: any) => Promise<any>;
4
+ update: (id: any, patch: any) => Promise<any>;
5
+ revoke: (id: any, reason: any) => Promise<any>;
6
+ revokeAllForUser: (userId: any, reason: any) => Promise<any>;
7
+ listByUser: (userId: any) => Promise<any>;
8
+ };
@@ -1,2 +1,3 @@
1
1
  export { memoryStore } from "./memory.js";
2
2
  export { redisStore } from "./redis.js";
3
+ export { customStore } from "./custom.js";
package/dist/stores.cjs CHANGED
@@ -25,6 +25,27 @@
25
25
  * duplicates work the assert family already handles — don't.
26
26
  */
27
27
 
28
+ /**
29
+ * `true` when `value` is a plain object *or* a class instance — anything
30
+ * whose property access is safe. Rejects `null`, arrays, and every
31
+ * primitive.
32
+ *
33
+ * The Node convention `typeof x === 'object' && x !== null` accepts
34
+ * arrays, which almost never matches caller intent (an array is not a
35
+ * "config object"). This predicate additionally rules arrays out, so
36
+ * `isObject(x)` really means "I can read `x.foo` without exploding on
37
+ * a numeric index".
38
+ *
39
+ * Class instances (Node `KeyObject`, `URL`, user-defined classes) pass
40
+ * because they carry `typeof === 'object'` — the check is duck-typed,
41
+ * not prototype-restricted.
42
+ *
43
+ * @param {unknown} value
44
+ * @returns {boolean}
45
+ */
46
+ function isObject(value) {
47
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
48
+ }
28
49
 
29
50
  /**
30
51
  * `true` when `value` is a string primitive. `String` boxed objects
@@ -1090,5 +1111,40 @@ function redisStore(client, options = {}) {
1090
1111
  };
1091
1112
  }
1092
1113
 
1114
+ /**
1115
+ * Wrap a user-supplied store into the `ApiKeyStore` interface.
1116
+ * Validates that the required methods exist so a misconfiguration
1117
+ * surfaces at construction time, not on the first `createApiKey` /
1118
+ * `verifyApiKey` call. Sync implementations are wrapped transparently
1119
+ * — return a plain value or a Promise, either works.
1120
+ *
1121
+ * Required: `put`, `getById`, `update`, `revoke`, `revokeAllForUser`, `listByUser`.
1122
+ *
1123
+ * @param {Partial<import('../index.js').ApiKeyStore>} impl
1124
+ * @returns {import('../index.js').ApiKeyStore}
1125
+ */
1126
+
1127
+ const REQUIRED = ['put', 'getById', 'update', 'revoke', 'revokeAllForUser', 'listByUser'];
1128
+
1129
+ function customStore(impl) {
1130
+ if (!isObject(impl)) {
1131
+ throw invalidArgument(`customStore(impl) requires an object with { ${REQUIRED.join(', ')} } methods`);
1132
+ }
1133
+ for (const name of REQUIRED) {
1134
+ if (!isFunction(impl[name])) {
1135
+ throw invalidArgument(`customStore: impl.${name} is required and must be a function`);
1136
+ }
1137
+ }
1138
+ return {
1139
+ put: record => Promise.resolve(impl.put(record)),
1140
+ getById: id => Promise.resolve(impl.getById(id)),
1141
+ update: (id, patch) => Promise.resolve(impl.update(id, patch)),
1142
+ revoke: (id, reason) => Promise.resolve(impl.revoke(id, reason)),
1143
+ revokeAllForUser: (userId, reason) => Promise.resolve(impl.revokeAllForUser(userId, reason)),
1144
+ listByUser: userId => Promise.resolve(impl.listByUser(userId)),
1145
+ };
1146
+ }
1147
+
1148
+ exports.customStore = customStore;
1093
1149
  exports.memoryStore = memoryStore;
1094
1150
  exports.redisStore = redisStore;
package/dist/stores.mjs CHANGED
@@ -23,6 +23,27 @@
23
23
  * duplicates work the assert family already handles — don't.
24
24
  */
25
25
 
26
+ /**
27
+ * `true` when `value` is a plain object *or* a class instance — anything
28
+ * whose property access is safe. Rejects `null`, arrays, and every
29
+ * primitive.
30
+ *
31
+ * The Node convention `typeof x === 'object' && x !== null` accepts
32
+ * arrays, which almost never matches caller intent (an array is not a
33
+ * "config object"). This predicate additionally rules arrays out, so
34
+ * `isObject(x)` really means "I can read `x.foo` without exploding on
35
+ * a numeric index".
36
+ *
37
+ * Class instances (Node `KeyObject`, `URL`, user-defined classes) pass
38
+ * because they carry `typeof === 'object'` — the check is duck-typed,
39
+ * not prototype-restricted.
40
+ *
41
+ * @param {unknown} value
42
+ * @returns {boolean}
43
+ */
44
+ function isObject(value) {
45
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
46
+ }
26
47
 
27
48
  /**
28
49
  * `true` when `value` is a string primitive. `String` boxed objects
@@ -1088,4 +1109,38 @@ function redisStore(client, options = {}) {
1088
1109
  };
1089
1110
  }
1090
1111
 
1091
- export { memoryStore, redisStore };
1112
+ /**
1113
+ * Wrap a user-supplied store into the `ApiKeyStore` interface.
1114
+ * Validates that the required methods exist so a misconfiguration
1115
+ * surfaces at construction time, not on the first `createApiKey` /
1116
+ * `verifyApiKey` call. Sync implementations are wrapped transparently
1117
+ * — return a plain value or a Promise, either works.
1118
+ *
1119
+ * Required: `put`, `getById`, `update`, `revoke`, `revokeAllForUser`, `listByUser`.
1120
+ *
1121
+ * @param {Partial<import('../index.js').ApiKeyStore>} impl
1122
+ * @returns {import('../index.js').ApiKeyStore}
1123
+ */
1124
+
1125
+ const REQUIRED = ['put', 'getById', 'update', 'revoke', 'revokeAllForUser', 'listByUser'];
1126
+
1127
+ function customStore(impl) {
1128
+ if (!isObject(impl)) {
1129
+ throw invalidArgument(`customStore(impl) requires an object with { ${REQUIRED.join(', ')} } methods`);
1130
+ }
1131
+ for (const name of REQUIRED) {
1132
+ if (!isFunction(impl[name])) {
1133
+ throw invalidArgument(`customStore: impl.${name} is required and must be a function`);
1134
+ }
1135
+ }
1136
+ return {
1137
+ put: record => Promise.resolve(impl.put(record)),
1138
+ getById: id => Promise.resolve(impl.getById(id)),
1139
+ update: (id, patch) => Promise.resolve(impl.update(id, patch)),
1140
+ revoke: (id, reason) => Promise.resolve(impl.revoke(id, reason)),
1141
+ revokeAllForUser: (userId, reason) => Promise.resolve(impl.revokeAllForUser(userId, reason)),
1142
+ listByUser: userId => Promise.resolve(impl.listByUser(userId)),
1143
+ };
1144
+ }
1145
+
1146
+ export { customStore, memoryStore, redisStore };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exortek/apikey",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "description": "Stripe-style prefixed API keys for Node.js — 3-segment tokens (prefix_id_secret), HMAC-SHA256 storage hash with pepper rotation, scope allowlists with wildcard match, opt-in lastUsedAt tracking, memory + Redis stores, Express + Fastify middleware. Server-only. Zero non-@exortek runtime dependencies.",
5
5
  "type": "module",
6
6
  "sideEffects": false,