@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 ADDED
@@ -0,0 +1,38 @@
1
+ # @exortek/apikey
2
+
3
+ ## 1.0.0
4
+
5
+ ### Initial release
6
+
7
+ - **`createApiKey(options)`** — mint a Stripe-style 3-segment token
8
+ `<prefix>_<id>_<secret>` (base32-crockford, no ambiguous glyphs).
9
+ Stores an HMAC-SHA256 hash of the secret plus a plaintext id for
10
+ O(1) lookup. Optional peppers (newest-first array, each ≥16 bytes)
11
+ turn a stolen DB into a non-crackable artifact.
12
+ - **`verifyApiKey(rawKey, options)`** — parse, look up by id, timing-safe
13
+ hash compare, expiry / revocation / scope enforcement. Returns
14
+ `{ valid: true, userId, scopes, id, prefix, name?, environment?, metadata?, needsRehash? }`
15
+ or `{ valid: false, reason }` — never throws on a bad key.
16
+ - **Scopes:** `hasAll` / `hasAny` / `covers` with `*` super wildcard and
17
+ `namespace:*` suffix wildcard.
18
+ - **Pepper rotation:** verify against every pepper, mint with the
19
+ newest; `needsRehash: true` in the verify result signals the secret
20
+ matched an older pepper so `rehashApiKey` can silently migrate
21
+ storage.
22
+ - **`revokeApiKey`** (by key or id), **`revokeAllForUser`**,
23
+ **`listApiKeys`** (sorted most-recently-used first).
24
+ - **`mask(key)`** log-safe display, **`parseApiKey(key)`** unverified
25
+ segment extraction.
26
+ - **Stores:** `memoryStore()` and `redisStore(client, options?)` under
27
+ the `@exortek/apikey/stores` subpath. Standard CRUD (`put` /
28
+ `getById` / `update` / `revoke` / `revokeAllForUser` / `listByUser`)
29
+ — bring your own by implementing the interface.
30
+ - **Middleware:** Express (`@exortek/apikey/middleware/express`) and
31
+ Fastify (`@exortek/apikey/middleware/fastify`) adapters over a
32
+ shared `middleware/core.js` — `Authorization: Bearer <key>` by
33
+ default, configurable header + raw scheme + opt-in query-param
34
+ fallback. Attaches the verify result to `req.apiKey`.
35
+ - **Errors:** `ApiKeyError` + `ErrorCode` catalogue
36
+ (`INVALID_ARGUMENT` / `INVALID_PREFIX` / `INVALID_PEPPER` /
37
+ `STORE_ERROR`). Expected verify failures surface as
38
+ `{ valid: false, reason }`, not exceptions.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ExorTek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,263 @@
1
+ # @exortek/apikey
2
+
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
+
5
+ [![npm](https://img.shields.io/npm/v/@exortek/apikey.svg?color=cb3837)](https://www.npmjs.com/package/@exortek/apikey)
6
+ [![node](https://img.shields.io/node/v/@exortek/apikey.svg?color=339933)](https://nodejs.org)
7
+ [![install size](https://packagephobia.com/badge?p=@exortek/apikey)](https://packagephobia.com/result?p=@exortek/apikey)
8
+ [![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)
10
+
11
+ 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
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm i @exortek/apikey
17
+ # or
18
+ yarn add @exortek/apikey
19
+ ```
20
+
21
+ Node.js 22 LTS or newer. Optional: `express` or `fastify` peer deps for the shipped middleware.
22
+
23
+ ## Token format
24
+
25
+ ```
26
+ sk_live_A2X4KJRK5HYFR3E7CBEQZP9WTN_QM5K3EHRSPMY8TDN4GRFB2CGY6VJE1BW7FRXK8ZHDNA9TPY5CV6QM
27
+ ^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
28
+ type id — 128-bit, plaintext secret — 256-bit, HMAC-SHA256 for storage
29
+ (O(1) DB primary-key lookup)
30
+ ```
31
+
32
+ Three underscore-separated segments. **id** and **secret** use base32-crockford (`[0-9A-HJKMNP-TV-Z]`) — no ambiguous glyphs, no separators, safe in URLs and audit logs. **prefix** is Stripe-style: lowercase alpha + digits + underscore-separated segments (`sk_live`, `pk_test`, `svc_prod_v2`).
33
+
34
+ The **id** is plaintext because it isn't a secret — an attacker who guesses an id alone still fails the HMAC compare on the secret half. The **secret** is never stored in the clear; the DB keeps only `HMAC-SHA256(secret, pepper)` or plain `SHA-256(secret)` when no pepper is configured.
35
+
36
+ **Why fast SHA-256 and not Argon2?** API keys are random 256-bit values. Argon2 / bcrypt / scrypt slow down brute force on **low-entropy** passwords. A 256-bit random secret has no brute-force attack to slow — a fast hash is correct here.
37
+
38
+ ## Quick start
39
+
40
+ ```js
41
+ import { createApiKey, verifyApiKey, revokeApiKey } from '@exortek/apikey';
42
+ import { memoryStore } from '@exortek/apikey/stores';
43
+
44
+ const store = memoryStore();
45
+ const peppers = [Buffer.from(process.env.APIKEY_PEPPER, 'base64url')]; // optional
46
+
47
+ // Issue a key to a user
48
+ const { key, id, record } = await createApiKey({
49
+ store,
50
+ peppers,
51
+ prefix: 'sk_live',
52
+ userId: 'usr_123',
53
+ scopes: ['read', 'write:posts'],
54
+ name: 'Production Backend',
55
+ environment: 'live',
56
+ expiresIn: '1y',
57
+ });
58
+ // → key = 'sk_live_A2X4…6QM' — SHOW ONCE, never persist in the clear.
59
+ // → id = 'A2X4KJRK5HYFR3E7CBEQZP9WTN' — safe to log/display; primary key.
60
+
61
+ // Verify one on an incoming request
62
+ const res = await verifyApiKey(incomingKey, {
63
+ store,
64
+ peppers,
65
+ requiredScopes: ['read'],
66
+ updateLastUsed: true,
67
+ });
68
+ if (!res.valid) return reply.code(401).send({ error: res.reason });
69
+ // res = { valid: true, userId, scopes, id, prefix, name?, environment?, metadata?, needsRehash? }
70
+
71
+ // Revoke — by full key or by id
72
+ await revokeApiKey(incomingKey, { store, reason: 'user_dashboard' });
73
+ ```
74
+
75
+ ## API
76
+
77
+ ### `createApiKey(options)`
78
+
79
+ ```ts
80
+ createApiKey({
81
+ store: ApiKeyStore,
82
+ prefix: string, // Stripe-style — see /^[a-z][a-z0-9]{0,15}(_[a-z0-9]{1,15}){0,3}$/
83
+ userId: string,
84
+ scopes: string[],
85
+
86
+ name?: string, // Human label — 'Production Backend'
87
+ environment?: string, // 'live' / 'test' / caller-defined
88
+ metadata?: Record<string, unknown>,
89
+ expiresIn?: string | number, // '1y' / '30d' / ms integer; omit for no expiry
90
+
91
+ peppers?: (Buffer | Uint8Array | string)[], // Newest first, each ≥16 bytes
92
+ now?: number, // Override Date.now() for testing
93
+ }): Promise<{ key: string, id: string, record: ApiKeyRecord }>
94
+ ```
95
+
96
+ The wire `key` is returned once. Show it to the end user immediately (a dashboard modal, an email attachment) and never persist the raw value — only `record.hash` is safe to keep.
97
+
98
+ ### `verifyApiKey(rawKey, options)`
99
+
100
+ ```ts
101
+ verifyApiKey(rawKey, {
102
+ store: ApiKeyStore,
103
+ peppers?: (Buffer | Uint8Array | string)[],
104
+ requiredScopes?: string[],
105
+ expectedPrefix?: string,
106
+ updateLastUsed?: boolean,
107
+ now?: number,
108
+ }): Promise<
109
+ | { valid: true, id, userId, scopes, prefix, name?, environment?, metadata?, needsRehash? }
110
+ | { valid: false, reason }
111
+ >
112
+ ```
113
+
114
+ Never throws on a bad key. Failure reasons:
115
+
116
+ | Reason | Meaning |
117
+ | ------------------- | --------------------------------------------------------- |
118
+ | `malformed` | Wrong shape, wrong length, non-crockford chars. |
119
+ | `not_found` | Well-shaped but the id has never been issued (or was purged). |
120
+ | `expired` | `expiresAt` has passed. |
121
+ | `revoked` | Record has a `revokedAt` timestamp. |
122
+ | `bad_secret` | HMAC compare failed — wrong secret or wrong pepper. |
123
+ | `prefix_mismatch` | `expectedPrefix` set and doesn't match; or the record's stored prefix differs from what was submitted (attacker guessed the id and swapped the prefix). |
124
+ | `missing_scope` | `requiredScopes` not covered by the record's `scopes`. |
125
+ | `store_unavailable` | `store.getById` threw. |
126
+
127
+ ### Scopes: `covers` / `hasAll` / `hasAny`
128
+
129
+ ```js
130
+ import { covers, hasAll, hasAny } from '@exortek/apikey';
131
+
132
+ covers(['read:*'], 'read:users'); // true — namespace wildcard
133
+ covers(['*'], 'anything'); // true — super wildcard
134
+ hasAll(['read:*'], ['read:users', 'read:posts']); // true
135
+ hasAll(['read:users'], ['read:*']); // false — required is more specific than granted
136
+ hasAny(['read'], ['read', 'write']); // true
137
+ ```
138
+
139
+ Wildcards only apply on the **granted** side. `required` values are always concrete — an endpoint declares exactly which scope it needs.
140
+
141
+ ### Pepper rotation
142
+
143
+ ```js
144
+ const oldPepper = Buffer.from(process.env.APIKEY_PEPPER_V1, 'base64url');
145
+ const newPepper = Buffer.from(process.env.APIKEY_PEPPER_V2, 'base64url');
146
+
147
+ // Verify against both, mint with the newest.
148
+ const res = await verifyApiKey(key, { store, peppers: [newPepper, oldPepper] });
149
+ if (res.valid && res.needsRehash) {
150
+ await rehashApiKey(key, { store, peppers: [newPepper, oldPepper] });
151
+ }
152
+ ```
153
+
154
+ Rotate a pepper by prepending the new one and keeping the old until every key has drifted to the new hash (either via `rehashApiKey` on natural verifies or via a batch job on a maintenance window). Then drop the old pepper.
155
+
156
+ ### `mask(key)` / `parseApiKey(key)`
157
+
158
+ ```js
159
+ import { mask, parseApiKey } from '@exortek/apikey';
160
+
161
+ mask('sk_live_A2X4KJRK…_QM5K…6QM');
162
+ // → 'sk_live_A2X4KJ…6QM' — log-safe
163
+
164
+ parseApiKey('sk_live_A2X4…_QM5…');
165
+ // → { prefix: 'sk_live', id: 'A2X4…', secret: 'QM5…' } — UNVERIFIED; do not trust
166
+ ```
167
+
168
+ ## Middleware
169
+
170
+ ### Express
171
+
172
+ ```js
173
+ import express from 'express';
174
+ import { apiKeyMiddleware } from '@exortek/apikey/middleware/express';
175
+ import { memoryStore } from '@exortek/apikey/stores';
176
+
177
+ const app = express();
178
+ const store = memoryStore();
179
+
180
+ app.use('/v1', apiKeyMiddleware({
181
+ store,
182
+ requiredScopes: ['read'],
183
+ updateLastUsed: true,
184
+ }));
185
+
186
+ app.get('/v1/whoami', (req, res) => res.json(req.apiKey));
187
+ ```
188
+
189
+ ### Fastify
190
+
191
+ ```js
192
+ import Fastify from 'fastify';
193
+ import { apiKeyPlugin } from '@exortek/apikey/middleware/fastify';
194
+ import { memoryStore } from '@exortek/apikey/stores';
195
+
196
+ const app = Fastify();
197
+ await app.register(apiKeyPlugin, {
198
+ store: memoryStore(),
199
+ requiredScopes: ['read'],
200
+ updateLastUsed: true,
201
+ });
202
+
203
+ app.get('/v1/whoami', async req => req.apiKey);
204
+ ```
205
+
206
+ ### Options common to both
207
+
208
+ | Option | Default | Notes |
209
+ | ------------------- | ------------------- | ----- |
210
+ | `store` | — | Required. |
211
+ | `peppers` | none | Newest-first array. |
212
+ | `requiredScopes` | none | Union with the endpoint's own scope checks. |
213
+ | `expectedPrefix` | any | Reject a valid key whose prefix differs (e.g. lock a route to `sk_live`). |
214
+ | `updateLastUsed` | `false` | One extra store write per verify. |
215
+ | `headerName` | `'authorization'` | Case-insensitive. |
216
+ | `scheme` | `'bearer'` | `'bearer'` → `Bearer <key>`; `'raw'` → header value as-is (useful for `X-API-Key`). |
217
+ | `allowQueryParam` | `false` | Fall back to `?api_key=<key>` — discouraged (query strings leak into logs). |
218
+ | `queryParamName` | `'api_key'` | |
219
+ | `attach` | `'apiKey'` | `req[attach] = { valid, userId, scopes, … }`. |
220
+ | `tokenFromRequest` | none | Override extraction entirely. |
221
+
222
+ Invalid keys respond with 401 `{ error: 'invalid_api_key', reason }`; missing scope responds 403; store failure responds 503.
223
+
224
+ ## Stores
225
+
226
+ `@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
+
228
+ - **`memoryStore()`** — in-process Map with a reverse index by `userId`. Not cluster-safe. Fine for dev / single-node prod / tests.
229
+ - **`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`.
230
+
231
+ ### The interface
232
+
233
+ ```ts
234
+ interface ApiKeyStore {
235
+ put(record: ApiKeyRecord): Promise<void>;
236
+ getById(id: string): Promise<ApiKeyRecord | null>;
237
+ update(id: string, patch: Partial<ApiKeyRecord>): Promise<ApiKeyRecord | null>;
238
+ revoke(id: string, reason?: string): Promise<boolean>;
239
+ revokeAllForUser(userId: string, reason?: string): Promise<number>;
240
+ listByUser(userId: string): Promise<ApiKeyRecord[]>;
241
+ }
242
+ ```
243
+
244
+ `ApiKeyRecord` fields: `id`, `hash`, `prefix`, `userId`, `scopes`, `createdAt`, `expiresAt?`, `revokedAt?`, `revokedReason?`, `lastUsedAt?`, `name?`, `environment?`, `metadata?`, `pepperVersion?`.
245
+
246
+ ## Errors
247
+
248
+ ```js
249
+ import { ApiKeyError, ErrorCode } from '@exortek/apikey';
250
+ ```
251
+
252
+ | Code | HTTP | Raised when |
253
+ | ------------------- | ---- | ----------- |
254
+ | `INVALID_ARGUMENT` | 400 | Options object is missing, misshapen, or violates an invariant. |
255
+ | `INVALID_PREFIX` | 400 | Prefix doesn't match the Stripe-style grammar. |
256
+ | `INVALID_PEPPER` | 400 | Pepper is missing / under 16 bytes / wrong type. |
257
+ | `STORE_ERROR` | 500 | `store.put` threw at create time (unusual — usually a DB outage). |
258
+
259
+ Expected verify failures do NOT throw — they surface as `{ valid: false, reason }`. See the failure-reasons table above.
260
+
261
+ ## License
262
+
263
+ MIT
@@ -0,0 +1,20 @@
1
+ export const ErrorCode: Readonly<{
2
+ INVALID_ARGUMENT: "INVALID_ARGUMENT";
3
+ INVALID_PREFIX: "INVALID_PREFIX";
4
+ INVALID_PEPPER: "INVALID_PEPPER";
5
+ STORE_ERROR: "STORE_ERROR";
6
+ }>;
7
+ /**
8
+ * Every recoverable failure raised by this package. Carries a stable
9
+ * `code` (from {@link ErrorCode}) and a `status` — the HTTP response
10
+ * status a middleware layer would use when translating the error.
11
+ */
12
+ export class ApiKeyError extends BaseError {
13
+ static statuses: {
14
+ INVALID_ARGUMENT: number;
15
+ INVALID_PREFIX: number;
16
+ INVALID_PEPPER: number;
17
+ STORE_ERROR: number;
18
+ };
19
+ }
20
+ import { BaseError } from '@exortek/shared/errors';