@exortek/apikey 1.0.1 → 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 +25 -0
- package/README.md +67 -2
- package/dist/index.cjs +7 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +7 -7
- package/dist/middleware/core.d.ts +2 -2
- package/dist/middleware/express.cjs +7 -7
- package/dist/middleware/express.mjs +7 -7
- package/dist/middleware/fastify.cjs +94 -43
- package/dist/middleware/fastify.d.ts +1 -1
- package/dist/middleware/fastify.mjs +94 -43
- package/dist/stores/custom.d.ts +8 -0
- package/dist/stores/index.d.ts +1 -0
- package/dist/stores.cjs +64 -2
- package/dist/stores.mjs +64 -3
- package/dist/token.d.ts +3 -3
- package/package.json +3 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
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
|
+
|
|
18
|
+
## 1.0.2
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- 689cf87: Remove workspace-only `devDependencies` from published package.json — `@exortek/shared` no longer appears as
|
|
23
|
+
`"0.0.0"` on the npm registry.
|
|
24
|
+
- 689cf87: Replace `fastify-plugin` npm dependency with `@exortek/shared/fastify-plugin` — a built-in drop-in that
|
|
25
|
+
covers skip-override, display-name, plugin-meta, version constraints, and encapsulate. Users no longer need to
|
|
26
|
+
`npm i fastify-plugin` alongside fastify.
|
|
27
|
+
|
|
3
28
|
## 1.0.1
|
|
4
29
|
|
|
5
30
|
### 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).
|
package/dist/index.cjs
CHANGED
|
@@ -946,11 +946,11 @@ function timingSafeEqual(a, b) {
|
|
|
946
946
|
*
|
|
947
947
|
* **Wire format:**
|
|
948
948
|
*
|
|
949
|
-
* <prefix>_<
|
|
950
|
-
* ^^^^^^
|
|
951
|
-
* type id (128-bit
|
|
949
|
+
* <prefix>_<crockford-base32(16 random bytes)>_<crockford-base32(32 random bytes)>
|
|
950
|
+
* ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
951
|
+
* type id (128-bit, 26 chars) secret (256-bit, 52 chars)
|
|
952
952
|
*
|
|
953
|
-
*
|
|
953
|
+
* sk_live_<id-26-chars>_<secret-52-chars>
|
|
954
954
|
*
|
|
955
955
|
* Stripe / GitHub / Anthropic all use this three-segment shape. The
|
|
956
956
|
* split matters:
|
|
@@ -1050,9 +1050,9 @@ function hashSecret(secretBytes, pepper) {
|
|
|
1050
1050
|
|
|
1051
1051
|
/**
|
|
1052
1052
|
* Timing-safe compare of a candidate hash against a stored hash. Both
|
|
1053
|
-
* inputs are base64url strings
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1053
|
+
* inputs are base64url strings. A length mismatch early-returns `false`
|
|
1054
|
+
* — all stored hashes are fixed-length (43-char SHA-256 digests), so
|
|
1055
|
+
* the length check leaks no information about the stored value.
|
|
1056
1056
|
*
|
|
1057
1057
|
* @param {string} candidate
|
|
1058
1058
|
* @param {string} stored
|
package/dist/index.d.ts
CHANGED
|
@@ -223,7 +223,7 @@ export type CreateApiKeyOptions = {
|
|
|
223
223
|
/**
|
|
224
224
|
* Newest first. Each ≥16 bytes. Omit for plain SHA-256 storage.
|
|
225
225
|
*/
|
|
226
|
-
peppers?:
|
|
226
|
+
peppers?: any[] | undefined;
|
|
227
227
|
/**
|
|
228
228
|
* Override `Date.now()` for testing.
|
|
229
229
|
*/
|
|
@@ -245,7 +245,7 @@ export type CreateApiKeyResult = {
|
|
|
245
245
|
};
|
|
246
246
|
export type VerifyApiKeyOptions = {
|
|
247
247
|
store: ApiKeyStore;
|
|
248
|
-
peppers?:
|
|
248
|
+
peppers?: any[] | undefined;
|
|
249
249
|
requiredScopes?: string[] | undefined;
|
|
250
250
|
/**
|
|
251
251
|
* Reject a valid key whose prefix differs.
|
package/dist/index.mjs
CHANGED
|
@@ -944,11 +944,11 @@ function timingSafeEqual(a, b) {
|
|
|
944
944
|
*
|
|
945
945
|
* **Wire format:**
|
|
946
946
|
*
|
|
947
|
-
* <prefix>_<
|
|
948
|
-
* ^^^^^^
|
|
949
|
-
* type id (128-bit
|
|
947
|
+
* <prefix>_<crockford-base32(16 random bytes)>_<crockford-base32(32 random bytes)>
|
|
948
|
+
* ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
949
|
+
* type id (128-bit, 26 chars) secret (256-bit, 52 chars)
|
|
950
950
|
*
|
|
951
|
-
*
|
|
951
|
+
* sk_live_<id-26-chars>_<secret-52-chars>
|
|
952
952
|
*
|
|
953
953
|
* Stripe / GitHub / Anthropic all use this three-segment shape. The
|
|
954
954
|
* split matters:
|
|
@@ -1048,9 +1048,9 @@ function hashSecret(secretBytes, pepper) {
|
|
|
1048
1048
|
|
|
1049
1049
|
/**
|
|
1050
1050
|
* Timing-safe compare of a candidate hash against a stored hash. Both
|
|
1051
|
-
* inputs are base64url strings
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1051
|
+
* inputs are base64url strings. A length mismatch early-returns `false`
|
|
1052
|
+
* — all stored hashes are fixed-length (43-char SHA-256 digests), so
|
|
1053
|
+
* the length check leaks no information about the stored value.
|
|
1054
1054
|
*
|
|
1055
1055
|
* @param {string} candidate
|
|
1056
1056
|
* @param {string} stored
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export function normalizeOptions(options: ApiKeyMiddlewareOptions): {
|
|
8
8
|
store: import("../index.js").ApiKeyStore;
|
|
9
|
-
peppers:
|
|
9
|
+
peppers: any[] | undefined;
|
|
10
10
|
requiredScopes: string[] | undefined;
|
|
11
11
|
expectedPrefix: string | undefined;
|
|
12
12
|
updateLastUsed: boolean;
|
|
@@ -45,7 +45,7 @@ export type AdapterContext = {
|
|
|
45
45
|
};
|
|
46
46
|
export type ApiKeyMiddlewareOptions = {
|
|
47
47
|
store: import("../index.js").ApiKeyStore;
|
|
48
|
-
peppers?:
|
|
48
|
+
peppers?: any[] | undefined;
|
|
49
49
|
requiredScopes?: string[] | undefined;
|
|
50
50
|
expectedPrefix?: string | undefined;
|
|
51
51
|
updateLastUsed?: boolean | undefined;
|
|
@@ -787,11 +787,11 @@ function timingSafeEqual(a, b) {
|
|
|
787
787
|
*
|
|
788
788
|
* **Wire format:**
|
|
789
789
|
*
|
|
790
|
-
* <prefix>_<
|
|
791
|
-
* ^^^^^^
|
|
792
|
-
* type id (128-bit
|
|
790
|
+
* <prefix>_<crockford-base32(16 random bytes)>_<crockford-base32(32 random bytes)>
|
|
791
|
+
* ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
792
|
+
* type id (128-bit, 26 chars) secret (256-bit, 52 chars)
|
|
793
793
|
*
|
|
794
|
-
*
|
|
794
|
+
* sk_live_<id-26-chars>_<secret-52-chars>
|
|
795
795
|
*
|
|
796
796
|
* Stripe / GitHub / Anthropic all use this three-segment shape. The
|
|
797
797
|
* split matters:
|
|
@@ -871,9 +871,9 @@ function hashSecret(secretBytes, pepper) {
|
|
|
871
871
|
|
|
872
872
|
/**
|
|
873
873
|
* Timing-safe compare of a candidate hash against a stored hash. Both
|
|
874
|
-
* inputs are base64url strings
|
|
875
|
-
*
|
|
876
|
-
*
|
|
874
|
+
* inputs are base64url strings. A length mismatch early-returns `false`
|
|
875
|
+
* — all stored hashes are fixed-length (43-char SHA-256 digests), so
|
|
876
|
+
* the length check leaks no information about the stored value.
|
|
877
877
|
*
|
|
878
878
|
* @param {string} candidate
|
|
879
879
|
* @param {string} stored
|
|
@@ -785,11 +785,11 @@ function timingSafeEqual(a, b) {
|
|
|
785
785
|
*
|
|
786
786
|
* **Wire format:**
|
|
787
787
|
*
|
|
788
|
-
* <prefix>_<
|
|
789
|
-
* ^^^^^^
|
|
790
|
-
* type id (128-bit
|
|
788
|
+
* <prefix>_<crockford-base32(16 random bytes)>_<crockford-base32(32 random bytes)>
|
|
789
|
+
* ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
790
|
+
* type id (128-bit, 26 chars) secret (256-bit, 52 chars)
|
|
791
791
|
*
|
|
792
|
-
*
|
|
792
|
+
* sk_live_<id-26-chars>_<secret-52-chars>
|
|
793
793
|
*
|
|
794
794
|
* Stripe / GitHub / Anthropic all use this three-segment shape. The
|
|
795
795
|
* split matters:
|
|
@@ -869,9 +869,9 @@ function hashSecret(secretBytes, pepper) {
|
|
|
869
869
|
|
|
870
870
|
/**
|
|
871
871
|
* Timing-safe compare of a candidate hash against a stored hash. Both
|
|
872
|
-
* inputs are base64url strings
|
|
873
|
-
*
|
|
874
|
-
*
|
|
872
|
+
* inputs are base64url strings. A length mismatch early-returns `false`
|
|
873
|
+
* — all stored hashes are fixed-length (43-char SHA-256 digests), so
|
|
874
|
+
* the length check leaks no information about the stored value.
|
|
875
875
|
*
|
|
876
876
|
* @param {string} candidate
|
|
877
877
|
* @param {string} stored
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var fp = require('fastify-plugin');
|
|
4
3
|
var node_crypto = require('node:crypto');
|
|
5
4
|
|
|
6
5
|
/**
|
|
@@ -131,6 +130,67 @@ function isBuffer(value) {
|
|
|
131
130
|
return Buffer.isBuffer(value);
|
|
132
131
|
}
|
|
133
132
|
|
|
133
|
+
let counter = 0;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Mark a function as a Fastify plugin with encapsulation bypass.
|
|
137
|
+
*
|
|
138
|
+
* Drop-in replacement for the `fastify-plugin` npm package — covers
|
|
139
|
+
* the same symbols and option handling without the external dependency.
|
|
140
|
+
*
|
|
141
|
+
* @param {Function} fn Plugin function `(fastify, opts) => …`
|
|
142
|
+
* @param {string | PluginOptions} [options]
|
|
143
|
+
* A semver range string (shorthand for `{ fastify: range }`) or a
|
|
144
|
+
* full options object.
|
|
145
|
+
* @returns {Function} The same function, decorated with plugin metadata.
|
|
146
|
+
*
|
|
147
|
+
* @typedef {object} PluginOptions
|
|
148
|
+
* @property {string} [name] Display name. Auto-generated from
|
|
149
|
+
* `fn.name` when omitted.
|
|
150
|
+
* @property {string} [fastify] Semver range the plugin is compatible
|
|
151
|
+
* with (e.g. `'>=4'`). Fastify checks
|
|
152
|
+
* this at register time.
|
|
153
|
+
* @property {string[]} [decorators] Decorators the plugin expects to
|
|
154
|
+
* exist before it runs.
|
|
155
|
+
* @property {string[]} [dependencies] Plugins that must be registered
|
|
156
|
+
* before this one.
|
|
157
|
+
* @property {boolean} [encapsulate] When `true`, the plugin stays
|
|
158
|
+
* encapsulated (skip-override = false).
|
|
159
|
+
* Default `false` (skip override).
|
|
160
|
+
*/
|
|
161
|
+
function fastifyPlugin(fn, options) {
|
|
162
|
+
if (fn && fn.default !== undefined) {
|
|
163
|
+
fn = fn.default;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!isFunction(fn)) {
|
|
167
|
+
throw new TypeError(`fastifyPlugin expects a function, instead got '${typeof fn}'`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let opts;
|
|
171
|
+
if (isString(options)) {
|
|
172
|
+
opts = { fastify: options };
|
|
173
|
+
} else if (isObject(options)) {
|
|
174
|
+
opts = { ...options };
|
|
175
|
+
} else {
|
|
176
|
+
opts = {};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!opts.name) {
|
|
180
|
+
opts.name = (fn.name || 'anonymous') + '-auto-' + counter++;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
fn[Symbol.for('skip-override')] = opts.encapsulate !== true;
|
|
184
|
+
fn[Symbol.for('fastify.display-name')] = opts.name;
|
|
185
|
+
fn[Symbol.for('plugin-meta')] = opts;
|
|
186
|
+
|
|
187
|
+
if (!fn.default) {
|
|
188
|
+
fn.default = fn;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return fn;
|
|
192
|
+
}
|
|
193
|
+
|
|
134
194
|
/**
|
|
135
195
|
* Imperative single-argument guard helpers — the everyday
|
|
136
196
|
* `assertPositiveInt(x, 'name')` shape used at API boundaries.
|
|
@@ -788,11 +848,11 @@ function timingSafeEqual(a, b) {
|
|
|
788
848
|
*
|
|
789
849
|
* **Wire format:**
|
|
790
850
|
*
|
|
791
|
-
* <prefix>_<
|
|
792
|
-
* ^^^^^^
|
|
793
|
-
* type id (128-bit
|
|
851
|
+
* <prefix>_<crockford-base32(16 random bytes)>_<crockford-base32(32 random bytes)>
|
|
852
|
+
* ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
853
|
+
* type id (128-bit, 26 chars) secret (256-bit, 52 chars)
|
|
794
854
|
*
|
|
795
|
-
*
|
|
855
|
+
* sk_live_<id-26-chars>_<secret-52-chars>
|
|
796
856
|
*
|
|
797
857
|
* Stripe / GitHub / Anthropic all use this three-segment shape. The
|
|
798
858
|
* split matters:
|
|
@@ -872,9 +932,9 @@ function hashSecret(secretBytes, pepper) {
|
|
|
872
932
|
|
|
873
933
|
/**
|
|
874
934
|
* Timing-safe compare of a candidate hash against a stored hash. Both
|
|
875
|
-
* inputs are base64url strings
|
|
876
|
-
*
|
|
877
|
-
*
|
|
935
|
+
* inputs are base64url strings. A length mismatch early-returns `false`
|
|
936
|
+
* — all stored hashes are fixed-length (43-char SHA-256 digests), so
|
|
937
|
+
* the length check leaks no information about the stored value.
|
|
878
938
|
*
|
|
879
939
|
* @param {string} candidate
|
|
880
940
|
* @param {string} stored
|
|
@@ -1340,45 +1400,36 @@ function _statusFor(reason) {
|
|
|
1340
1400
|
*
|
|
1341
1401
|
* app.get('/v1/whoami', async req => req.apiKey);
|
|
1342
1402
|
*
|
|
1343
|
-
*
|
|
1344
|
-
* `req.apiKey` decorator
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1347
|
-
* which route group needs an API key — the usual Fastify pattern for
|
|
1348
|
-
* "protect only these routes with auth".
|
|
1349
|
-
*
|
|
1350
|
-
* `fastify-plugin` is an **optional peer** — install it with
|
|
1351
|
-
* `npm i fastify fastify-plugin` (or `yarn add`) alongside the fastify
|
|
1352
|
-
* runtime. Not a hard dependency because Express-only consumers of
|
|
1353
|
-
* `@exortek/apikey` should not have to install it.
|
|
1403
|
+
* The plugin bypasses Fastify's encapsulation so the `preHandler` hook
|
|
1404
|
+
* and the `req.apiKey` decorator apply to routes in the parent scope.
|
|
1405
|
+
* Register inside a scoped `app.register(async scope => …)` block to
|
|
1406
|
+
* control which route group needs an API key.
|
|
1354
1407
|
*/
|
|
1355
1408
|
|
|
1356
1409
|
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
app.decorateRequest(config.attach, null);
|
|
1410
|
+
const apiKeyPlugin = fastifyPlugin(
|
|
1411
|
+
async function apiKeyPluginFn(app, options) {
|
|
1412
|
+
const config = normalizeOptions(options);
|
|
1361
1413
|
|
|
1362
|
-
|
|
1363
|
-
const ctx = {
|
|
1364
|
-
getHeader: name => req.headers?.[name],
|
|
1365
|
-
method: req.method,
|
|
1366
|
-
ip: req.ip,
|
|
1367
|
-
query: req.query,
|
|
1368
|
-
};
|
|
1369
|
-
const result = await runApiKey(ctx, config);
|
|
1370
|
-
if (result.response) {
|
|
1371
|
-
reply.code(result.response.status).send(result.response.body);
|
|
1372
|
-
return reply;
|
|
1373
|
-
}
|
|
1374
|
-
req[config.attach] = result.verifyResult;
|
|
1375
|
-
return undefined;
|
|
1376
|
-
});
|
|
1377
|
-
}
|
|
1414
|
+
app.decorateRequest(config.attach, null);
|
|
1378
1415
|
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1416
|
+
app.addHook('preHandler', async (req, reply) => {
|
|
1417
|
+
const ctx = {
|
|
1418
|
+
getHeader: name => req.headers?.[name],
|
|
1419
|
+
method: req.method,
|
|
1420
|
+
ip: req.ip,
|
|
1421
|
+
query: req.query,
|
|
1422
|
+
};
|
|
1423
|
+
const result = await runApiKey(ctx, config);
|
|
1424
|
+
if (result.response) {
|
|
1425
|
+
reply.code(result.response.status).send(result.response.body);
|
|
1426
|
+
return reply;
|
|
1427
|
+
}
|
|
1428
|
+
req[config.attach] = result.verifyResult;
|
|
1429
|
+
return undefined;
|
|
1430
|
+
});
|
|
1431
|
+
},
|
|
1432
|
+
{ name: '@exortek/apikey' },
|
|
1433
|
+
);
|
|
1383
1434
|
|
|
1384
1435
|
exports.apiKeyPlugin = apiKeyPlugin;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export const apiKeyPlugin: Function;
|
|
@@ -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>_<
|
|
790
|
-
* ^^^^^^
|
|
791
|
-
* type id (128-bit
|
|
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
|
-
*
|
|
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
|
|
874
|
-
*
|
|
875
|
-
*
|
|
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
|
|
@@ -1338,45 +1398,36 @@ function _statusFor(reason) {
|
|
|
1338
1398
|
*
|
|
1339
1399
|
* app.get('/v1/whoami', async req => req.apiKey);
|
|
1340
1400
|
*
|
|
1341
|
-
*
|
|
1342
|
-
* `req.apiKey` decorator
|
|
1343
|
-
*
|
|
1344
|
-
*
|
|
1345
|
-
* which route group needs an API key — the usual Fastify pattern for
|
|
1346
|
-
* "protect only these routes with auth".
|
|
1347
|
-
*
|
|
1348
|
-
* `fastify-plugin` is an **optional peer** — install it with
|
|
1349
|
-
* `npm i fastify fastify-plugin` (or `yarn add`) alongside the fastify
|
|
1350
|
-
* runtime. Not a hard dependency because Express-only consumers of
|
|
1351
|
-
* `@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.
|
|
1352
1405
|
*/
|
|
1353
1406
|
|
|
1354
1407
|
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
app.decorateRequest(config.attach, null);
|
|
1408
|
+
const apiKeyPlugin = fastifyPlugin(
|
|
1409
|
+
async function apiKeyPluginFn(app, options) {
|
|
1410
|
+
const config = normalizeOptions(options);
|
|
1359
1411
|
|
|
1360
|
-
|
|
1361
|
-
const ctx = {
|
|
1362
|
-
getHeader: name => req.headers?.[name],
|
|
1363
|
-
method: req.method,
|
|
1364
|
-
ip: req.ip,
|
|
1365
|
-
query: req.query,
|
|
1366
|
-
};
|
|
1367
|
-
const result = await runApiKey(ctx, config);
|
|
1368
|
-
if (result.response) {
|
|
1369
|
-
reply.code(result.response.status).send(result.response.body);
|
|
1370
|
-
return reply;
|
|
1371
|
-
}
|
|
1372
|
-
req[config.attach] = result.verifyResult;
|
|
1373
|
-
return undefined;
|
|
1374
|
-
});
|
|
1375
|
-
}
|
|
1412
|
+
app.decorateRequest(config.attach, null);
|
|
1376
1413
|
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
+
);
|
|
1381
1432
|
|
|
1382
1433
|
export { apiKeyPlugin };
|
|
@@ -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
|
|
@@ -351,7 +372,11 @@ function createMemoryRecordStore(config) {
|
|
|
351
372
|
* }}
|
|
352
373
|
*/
|
|
353
374
|
function createRedisRecordStore(client, config) {
|
|
354
|
-
const wrap =
|
|
375
|
+
const wrap =
|
|
376
|
+
config.wrap ??
|
|
377
|
+
(msg => {
|
|
378
|
+
throw new TypeError(msg);
|
|
379
|
+
});
|
|
355
380
|
assertRedisClient(client, ['get', 'set', 'del'], wrap);
|
|
356
381
|
|
|
357
382
|
const {
|
|
@@ -1039,7 +1064,9 @@ function redisStore(client, options = {}) {
|
|
|
1039
1064
|
}
|
|
1040
1065
|
return record;
|
|
1041
1066
|
},
|
|
1042
|
-
wrap: msg => {
|
|
1067
|
+
wrap: msg => {
|
|
1068
|
+
throw invalidArgument(msg);
|
|
1069
|
+
},
|
|
1043
1070
|
});
|
|
1044
1071
|
|
|
1045
1072
|
return {
|
|
@@ -1084,5 +1111,40 @@ function redisStore(client, options = {}) {
|
|
|
1084
1111
|
};
|
|
1085
1112
|
}
|
|
1086
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;
|
|
1087
1149
|
exports.memoryStore = memoryStore;
|
|
1088
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
|
|
@@ -349,7 +370,11 @@ function createMemoryRecordStore(config) {
|
|
|
349
370
|
* }}
|
|
350
371
|
*/
|
|
351
372
|
function createRedisRecordStore(client, config) {
|
|
352
|
-
const wrap =
|
|
373
|
+
const wrap =
|
|
374
|
+
config.wrap ??
|
|
375
|
+
(msg => {
|
|
376
|
+
throw new TypeError(msg);
|
|
377
|
+
});
|
|
353
378
|
assertRedisClient(client, ['get', 'set', 'del'], wrap);
|
|
354
379
|
|
|
355
380
|
const {
|
|
@@ -1037,7 +1062,9 @@ function redisStore(client, options = {}) {
|
|
|
1037
1062
|
}
|
|
1038
1063
|
return record;
|
|
1039
1064
|
},
|
|
1040
|
-
wrap: msg => {
|
|
1065
|
+
wrap: msg => {
|
|
1066
|
+
throw invalidArgument(msg);
|
|
1067
|
+
},
|
|
1041
1068
|
});
|
|
1042
1069
|
|
|
1043
1070
|
return {
|
|
@@ -1082,4 +1109,38 @@ function redisStore(client, options = {}) {
|
|
|
1082
1109
|
};
|
|
1083
1110
|
}
|
|
1084
1111
|
|
|
1085
|
-
|
|
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/dist/token.d.ts
CHANGED
|
@@ -30,9 +30,9 @@ export function mint(prefix: string, pepper: Buffer | null): {
|
|
|
30
30
|
export function hashSecret(secretBytes: Buffer, pepper: Buffer | null): string;
|
|
31
31
|
/**
|
|
32
32
|
* Timing-safe compare of a candidate hash against a stored hash. Both
|
|
33
|
-
* inputs are base64url strings
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* inputs are base64url strings. A length mismatch early-returns `false`
|
|
34
|
+
* — all stored hashes are fixed-length (43-char SHA-256 digests), so
|
|
35
|
+
* the length check leaks no information about the stored value.
|
|
36
36
|
*
|
|
37
37
|
* @param {string} candidate
|
|
38
38
|
* @param {string} stored
|
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,
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"files": [
|
|
33
33
|
"dist",
|
|
34
34
|
"README.md",
|
|
35
|
+
"CHANGELOG.md",
|
|
35
36
|
"LICENSE"
|
|
36
37
|
],
|
|
37
38
|
"scripts": {
|
|
@@ -72,8 +73,7 @@
|
|
|
72
73
|
},
|
|
73
74
|
"peerDependencies": {
|
|
74
75
|
"express": ">=4",
|
|
75
|
-
"fastify": ">=4"
|
|
76
|
-
"fastify-plugin": ">=4"
|
|
76
|
+
"fastify": ">=4"
|
|
77
77
|
},
|
|
78
78
|
"peerDependenciesMeta": {
|
|
79
79
|
"express": {
|
|
@@ -81,17 +81,8 @@
|
|
|
81
81
|
},
|
|
82
82
|
"fastify": {
|
|
83
83
|
"optional": true
|
|
84
|
-
},
|
|
85
|
-
"fastify-plugin": {
|
|
86
|
-
"optional": true
|
|
87
84
|
}
|
|
88
85
|
},
|
|
89
|
-
"devDependencies": {
|
|
90
|
-
"@exortek/shared": "0.0.0",
|
|
91
|
-
"express": "^5.2.1",
|
|
92
|
-
"fastify": "^5.10.0",
|
|
93
|
-
"fastify-plugin": "^6.0.0"
|
|
94
|
-
},
|
|
95
86
|
"publishConfig": {
|
|
96
87
|
"access": "public"
|
|
97
88
|
}
|