@exortek/apikey 1.0.2 → 1.1.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 +15 -0
- package/README.md +67 -2
- package/dist/stores/custom.d.ts +8 -0
- package/dist/stores/index.d.ts +1 -0
- package/dist/stores.cjs +56 -0
- package/dist/stores.mjs +56 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @exortek/apikey
|
|
2
2
|
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b1f5d6e: Add `customStore(impl)` — wrap your own `ApiKeyStore` implementation with validation (fails at construction
|
|
8
|
+
time if a required method is missing) and transparent sync/async wrapping, instead of hand-assembling the interface
|
|
9
|
+
yourself.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 40fc707: Docs: add a "Rate limiting" section explaining why `@exortek/apikey` ships no built-in limiter and how to
|
|
14
|
+
front the mint/verify routes with `@exortek/security`'s rate-limit (with a runnable demo in both example servers);
|
|
15
|
+
document the previously-undocumented `revokeAllForUser` and `listApiKeys` functions; align README structure (badges,
|
|
16
|
+
`Why`, `Highlights`, `Links`) with the rest of the `@exortek/*` packages.
|
|
17
|
+
|
|
3
18
|
## 1.0.2
|
|
4
19
|
|
|
5
20
|
### 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
|
[](https://www.npmjs.com/package/@exortek/apikey)
|
|
6
|
+
[](https://github.com/ExorTek/auth/actions/workflows/ci.yml)
|
|
6
7
|
[](https://nodejs.org)
|
|
7
8
|
[](https://packagephobia.com/result?p=@exortek/apikey)
|
|
9
|
+
[](./dist/index.d.ts)
|
|
8
10
|
[](./LICENSE)
|
|
9
|
-
|
|
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).
|
|
@@ -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
|
+
};
|
package/dist/stores/index.d.ts
CHANGED
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
|
-
|
|
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
|
|
3
|
+
"version": "1.1.0",
|
|
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,
|