@exortek/apikey 1.1.2 → 1.2.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 +26 -0
- package/dist/index.cjs +32 -10
- package/dist/index.d.ts +145 -18
- package/dist/index.mjs +32 -10
- package/dist/middleware/express.cjs +51 -9
- package/dist/middleware/express.d.ts +96 -1
- package/dist/middleware/express.mjs +51 -9
- package/dist/middleware/fastify.cjs +51 -9
- package/dist/middleware/fastify.d.ts +3 -1
- package/dist/middleware/fastify.mjs +51 -9
- package/dist/stores/custom.d.ts +1 -8
- package/dist/stores/index.d.ts +79 -3
- package/dist/stores.cjs +133 -26
- package/dist/stores.mjs +133 -26
- package/dist/token.d.ts +2 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @exortek/apikey
|
|
2
2
|
|
|
3
|
+
## 1.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d370002: - `rehashApiKey` checks the presented secret against the stored hash before replacing it (anyone knowing a key id could overwrite its hash).
|
|
8
|
+
- `verifyApiKey` compares the secret before reporting `revoked` / `expired`, and accepts only the canonical spelling of a key (lowercase / I / O variants no longer verify).
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- 9953042: - Duration strings whose unit is the name of an `Object.prototype` member (for example `'1constructor'`) now throw instead of resolving to `NaN`.
|
|
13
|
+
- 9953042: - When a record is stored again under the same id with a different owner (`userId` / `email`), the previous owner no longer lists it, and per-owner bulk revocation (`revokeAllForUser`, `revokeByEmail`) no longer reaches it.
|
|
14
|
+
|
|
15
|
+
## 1.1.3
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- 89aea87: Ship self-contained TypeScript declarations. Every package's emitted `.d.ts` referenced `@exortek/shared`
|
|
20
|
+
(e.g. `import { BaseError } from '@exortek/shared/errors'`), but `@exortek/shared` is a private, never-published
|
|
21
|
+
workspace package that is inlined into each bundle at build time. A TypeScript consumer therefore hit
|
|
22
|
+
`Cannot find module '@exortek/shared/…'` (with `skipLibCheck` off) or silently degraded error-class types like
|
|
23
|
+
`ApiKeyError` — losing its constructor signature and `.code` / `.message` — with `skipLibCheck` on.
|
|
24
|
+
|
|
25
|
+
The build now runs a declaration-bundling pass (`rollup-plugin-dts`) after `tsc`, flattening each entry's `.d.ts` and
|
|
26
|
+
inlining the `@exortek/shared` types so the shipped declarations are fully self-contained. Runtime deps and `node:*`
|
|
27
|
+
stay external. No runtime or API change — types only.
|
|
28
|
+
|
|
3
29
|
## 1.1.2
|
|
4
30
|
|
|
5
31
|
### Patch Changes
|
package/dist/index.cjs
CHANGED
|
@@ -107,11 +107,13 @@ function parseDuration(input) {
|
|
|
107
107
|
}
|
|
108
108
|
const value = Number(match[1]);
|
|
109
109
|
const unit = (match[2] || 'ms').toLowerCase();
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
// Own keys only: a plain lookup would resolve `constructor` (and every
|
|
111
|
+
// other Object.prototype member) to a function, turning '1constructor'
|
|
112
|
+
// into NaN instead of an error.
|
|
113
|
+
if (!Object.hasOwn(UNIT_MS, unit)) {
|
|
112
114
|
throw new Error(`parseDuration: unknown time unit ${JSON.stringify(unit)}. Supported: ${SUPPORTED_UNITS}.`);
|
|
113
115
|
}
|
|
114
|
-
return Math.round(value *
|
|
116
|
+
return Math.round(value * UNIT_MS[/** @type {keyof typeof UNIT_MS} */ (unit)]);
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
/**
|
|
@@ -887,6 +889,12 @@ function candidateHashesFor(rawSecret, peppers) {
|
|
|
887
889
|
} catch {
|
|
888
890
|
return { candidateHashes: [] };
|
|
889
891
|
}
|
|
892
|
+
// Crockford decoding is forgiving (case, I/L→1, O→0), so one secret has
|
|
893
|
+
// many accepted spellings. A key must have exactly one: anything keyed on
|
|
894
|
+
// the wire string (logs, deny lists, dedupe) would otherwise miss variants.
|
|
895
|
+
if (encode(secretBytes) !== rawSecret) {
|
|
896
|
+
return { candidateHashes: [] };
|
|
897
|
+
}
|
|
890
898
|
if (!peppers || peppers.length === 0) {
|
|
891
899
|
return { candidateHashes: [hashSecret(secretBytes, null)] };
|
|
892
900
|
}
|
|
@@ -1130,19 +1138,15 @@ async function verifyApiKey(rawKey, options) {
|
|
|
1130
1138
|
if (!record) {
|
|
1131
1139
|
return { valid: false, reason: 'not_found' };
|
|
1132
1140
|
}
|
|
1133
|
-
const now = _now(options.now);
|
|
1134
|
-
if (record.revokedAt) {
|
|
1135
|
-
return { valid: false, reason: 'revoked' };
|
|
1136
|
-
}
|
|
1137
|
-
if (record.expiresAt && record.expiresAt <= now) {
|
|
1138
|
-
return { valid: false, reason: 'expired' };
|
|
1139
|
-
}
|
|
1140
1141
|
if (parsed.prefix !== record.prefix) {
|
|
1141
1142
|
// Someone submitted a key whose id belongs to a different prefix —
|
|
1142
1143
|
// treat as bad, not merely 'prefix_mismatch', because the id half
|
|
1143
1144
|
// was correct: the attacker guessed the id and swapped the prefix.
|
|
1144
1145
|
return { valid: false, reason: 'prefix_mismatch' };
|
|
1145
1146
|
}
|
|
1147
|
+
// The secret is checked before lifecycle state: the id is not secret (it
|
|
1148
|
+
// is in every masked key), so answering 'revoked' / 'expired' first
|
|
1149
|
+
// would tell anyone holding an id the key's status without its secret.
|
|
1146
1150
|
const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
|
|
1147
1151
|
let matched = false;
|
|
1148
1152
|
let matchedIndex = -1;
|
|
@@ -1156,6 +1160,13 @@ async function verifyApiKey(rawKey, options) {
|
|
|
1156
1160
|
if (!matched) {
|
|
1157
1161
|
return { valid: false, reason: 'bad_secret' };
|
|
1158
1162
|
}
|
|
1163
|
+
const now = _now(options.now);
|
|
1164
|
+
if (record.revokedAt) {
|
|
1165
|
+
return { valid: false, reason: 'revoked' };
|
|
1166
|
+
}
|
|
1167
|
+
if (record.expiresAt && record.expiresAt <= now) {
|
|
1168
|
+
return { valid: false, reason: 'expired' };
|
|
1169
|
+
}
|
|
1159
1170
|
if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
|
|
1160
1171
|
return { valid: false, reason: 'missing_scope' };
|
|
1161
1172
|
}
|
|
@@ -1280,6 +1291,17 @@ async function rehashApiKey(rawKey, options) {
|
|
|
1280
1291
|
if (!parsed) {
|
|
1281
1292
|
return false;
|
|
1282
1293
|
}
|
|
1294
|
+
// Prove the caller holds the real secret before replacing its hash —
|
|
1295
|
+
// otherwise anyone who knows a key id (it is in every masked key) could
|
|
1296
|
+
// overwrite that key's hash with one for a secret of their choosing.
|
|
1297
|
+
const record = await options.store.getById(parsed.id);
|
|
1298
|
+
if (!record) {
|
|
1299
|
+
return false;
|
|
1300
|
+
}
|
|
1301
|
+
const { candidateHashes: existing } = candidateHashesFor(parsed.secret, peppers);
|
|
1302
|
+
if (!existing.some(candidate => hashesMatch(candidate, record.hash))) {
|
|
1303
|
+
return false;
|
|
1304
|
+
}
|
|
1283
1305
|
const { candidateHashes } = candidateHashesFor(parsed.secret, [peppers[0]]);
|
|
1284
1306
|
const newHash = candidateHashes[0];
|
|
1285
1307
|
const updated = await options.store.update(parsed.id, { hash: newHash, pepperVersion: 0 });
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a raw key without verifying it. Returns the segments, or
|
|
3
|
+
* `null` when the shape is wrong. Never throws.
|
|
4
|
+
*
|
|
5
|
+
* Callers can use `parseApiKey(key).prefix` to route by key family
|
|
6
|
+
* in a middleware before hitting the store — but the parse result
|
|
7
|
+
* MUST NOT be trusted as authenticated. Only `verifyApiKey` proves
|
|
8
|
+
* the holder possesses the real secret.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} key
|
|
11
|
+
* @returns {{ prefix: string, id: string, secret: string } | null}
|
|
12
|
+
*/
|
|
13
|
+
declare function parseApiKey(key: string): {
|
|
14
|
+
prefix: string;
|
|
15
|
+
id: string;
|
|
16
|
+
secret: string;
|
|
17
|
+
} | null;
|
|
18
|
+
/**
|
|
19
|
+
* Log-safe display for a key. Returns `<prefix>_<first-N-of-id>…<last-M-of-secret>`
|
|
20
|
+
* so the value is recognisable in an audit log without disclosing the
|
|
21
|
+
* secret. The default masks all but the first 6 chars of the id and
|
|
22
|
+
* the last 4 chars of the secret.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} key
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
declare function mask(key: string): string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Shared base error class — the single error structure behind every
|
|
31
|
+
* `@exortek/*` package's `errors.js`.
|
|
32
|
+
*
|
|
33
|
+
* Every package keeps its own class identity with a one-liner subclass;
|
|
34
|
+
* codes stay per-package frozen maps, status mapping is declared as a
|
|
35
|
+
* static field:
|
|
36
|
+
*
|
|
37
|
+
* import { BaseError } from '@exortek/shared/errors';
|
|
38
|
+
*
|
|
39
|
+
* export const ErrorCode = Object.freeze({
|
|
40
|
+
* INVALID_ARGUMENT: 'INVALID_ARGUMENT',
|
|
41
|
+
* INVALID_TOKEN: 'INVALID_TOKEN',
|
|
42
|
+
* });
|
|
43
|
+
*
|
|
44
|
+
* export class JwtError extends BaseError {
|
|
45
|
+
* static statuses = { INVALID_ARGUMENT: 400, INVALID_TOKEN: 401 };
|
|
46
|
+
* static defaultStatus = 500;
|
|
47
|
+
* }
|
|
48
|
+
*
|
|
49
|
+
* Instances carry a stable machine-readable `code` (branch on this,
|
|
50
|
+
* never on the message), an optional HTTP `status`, an optional
|
|
51
|
+
* `details` object, and the standard `cause` chain.
|
|
52
|
+
*/
|
|
53
|
+
declare class BaseError extends Error {
|
|
54
|
+
/**
|
|
55
|
+
* Optional `code → HTTP status` map declared on the subclass. When
|
|
56
|
+
* absent the instance carries no `status` at all — for HTTP-agnostic
|
|
57
|
+
* packages like `@exortek/crypto`.
|
|
58
|
+
*
|
|
59
|
+
* @type {Record<string, number> | undefined}
|
|
60
|
+
*/
|
|
61
|
+
static statuses: Record<string, number> | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* Fallback status for codes missing from `statuses`.
|
|
64
|
+
*
|
|
65
|
+
* @type {number}
|
|
66
|
+
*/
|
|
67
|
+
static defaultStatus: number;
|
|
68
|
+
/**
|
|
69
|
+
* @param {string} code Stable machine-readable code; branch on this.
|
|
70
|
+
* @param {string} message Human-readable diagnostic. Free-form; may
|
|
71
|
+
* change across versions.
|
|
72
|
+
* @param {{ cause?: unknown, status?: number, details?: Record<string, unknown> }} [options]
|
|
73
|
+
*/
|
|
74
|
+
constructor(code: string, message: string, options?: {
|
|
75
|
+
cause?: unknown;
|
|
76
|
+
status?: number;
|
|
77
|
+
details?: Record<string, unknown>;
|
|
78
|
+
});
|
|
79
|
+
/** @type {string} */
|
|
80
|
+
code: string;
|
|
81
|
+
/** @type {number | undefined} */
|
|
82
|
+
status: number | undefined;
|
|
83
|
+
/** @type {Record<string, unknown> | undefined} */
|
|
84
|
+
details: Record<string, unknown> | undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
declare const ErrorCode: Readonly<{
|
|
88
|
+
INVALID_ARGUMENT: "INVALID_ARGUMENT";
|
|
89
|
+
INVALID_PREFIX: "INVALID_PREFIX";
|
|
90
|
+
INVALID_PEPPER: "INVALID_PEPPER";
|
|
91
|
+
STORE_ERROR: "STORE_ERROR";
|
|
92
|
+
}>;
|
|
93
|
+
/**
|
|
94
|
+
* Every recoverable failure raised by this package. Carries a stable
|
|
95
|
+
* `code` (from {@link ErrorCode}) and a `status` — the HTTP response
|
|
96
|
+
* status a middleware layer would use when translating the error.
|
|
97
|
+
*/
|
|
98
|
+
declare class ApiKeyError extends BaseError {
|
|
99
|
+
static statuses: {
|
|
100
|
+
INVALID_ARGUMENT: number;
|
|
101
|
+
INVALID_PREFIX: number;
|
|
102
|
+
INVALID_PEPPER: number;
|
|
103
|
+
STORE_ERROR: number;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Does `granted` cover a single `required` scope?
|
|
109
|
+
* @param {string[]} granted
|
|
110
|
+
* @param {string} required
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
declare function covers(granted: string[], required: string): boolean;
|
|
114
|
+
/**
|
|
115
|
+
* All required scopes must be covered by granted.
|
|
116
|
+
* @param {string[]} granted
|
|
117
|
+
* @param {string[]} required
|
|
118
|
+
* @returns {boolean}
|
|
119
|
+
*/
|
|
120
|
+
declare function hasAll(granted: string[], required: string[]): boolean;
|
|
121
|
+
/**
|
|
122
|
+
* At least one required scope must be covered.
|
|
123
|
+
* @param {string[]} granted
|
|
124
|
+
* @param {string[]} required
|
|
125
|
+
* @returns {boolean}
|
|
126
|
+
*/
|
|
127
|
+
declare function hasAny(granted: string[], required: string[]): boolean;
|
|
128
|
+
|
|
1
129
|
/**
|
|
2
130
|
* @typedef {object} ApiKeyRecord
|
|
3
131
|
* @property {string} id Plaintext lookup key (128-bit b64u).
|
|
@@ -80,7 +208,7 @@
|
|
|
80
208
|
* @param {CreateApiKeyOptions} options
|
|
81
209
|
* @returns {Promise<CreateApiKeyResult>}
|
|
82
210
|
*/
|
|
83
|
-
|
|
211
|
+
declare function createApiKey(options: CreateApiKeyOptions): Promise<CreateApiKeyResult>;
|
|
84
212
|
/**
|
|
85
213
|
* Verify a raw API key. Returns `{ valid: true, ... }` on success, or
|
|
86
214
|
* `{ valid: false, reason }` on any expected failure. Never throws on
|
|
@@ -96,7 +224,7 @@ export function createApiKey(options: CreateApiKeyOptions): Promise<CreateApiKey
|
|
|
96
224
|
* @param {VerifyApiKeyOptions} options
|
|
97
225
|
* @returns {Promise<VerifyApiKeyResult>}
|
|
98
226
|
*/
|
|
99
|
-
|
|
227
|
+
declare function verifyApiKey(rawKey: string, options: VerifyApiKeyOptions): Promise<VerifyApiKeyResult>;
|
|
100
228
|
/**
|
|
101
229
|
* Revoke an API key by id or by the raw wire key. Returns `true` if
|
|
102
230
|
* a record was actually revoked, `false` if the id was unknown or
|
|
@@ -106,7 +234,7 @@ export function verifyApiKey(rawKey: string, options: VerifyApiKeyOptions): Prom
|
|
|
106
234
|
* @param {{ store: ApiKeyStore, reason?: string }} options
|
|
107
235
|
* @returns {Promise<boolean>}
|
|
108
236
|
*/
|
|
109
|
-
|
|
237
|
+
declare function revokeApiKey(keyOrId: string, options: {
|
|
110
238
|
store: ApiKeyStore;
|
|
111
239
|
reason?: string;
|
|
112
240
|
}): Promise<boolean>;
|
|
@@ -119,7 +247,7 @@ export function revokeApiKey(keyOrId: string, options: {
|
|
|
119
247
|
* @param {{ store: ApiKeyStore, reason?: string }} options
|
|
120
248
|
* @returns {Promise<number>}
|
|
121
249
|
*/
|
|
122
|
-
|
|
250
|
+
declare function revokeAllForUser(userId: string, options: {
|
|
123
251
|
store: ApiKeyStore;
|
|
124
252
|
reason?: string;
|
|
125
253
|
}): Promise<number>;
|
|
@@ -133,7 +261,7 @@ export function revokeAllForUser(userId: string, options: {
|
|
|
133
261
|
* @param {{ store: ApiKeyStore }} options
|
|
134
262
|
* @returns {Promise<ApiKeyRecord[]>}
|
|
135
263
|
*/
|
|
136
|
-
|
|
264
|
+
declare function listApiKeys(userId: string, options: {
|
|
137
265
|
store: ApiKeyStore;
|
|
138
266
|
}): Promise<ApiKeyRecord[]>;
|
|
139
267
|
/**
|
|
@@ -146,12 +274,12 @@ export function listApiKeys(userId: string, options: {
|
|
|
146
274
|
* @param {{ store: ApiKeyStore, peppers: (Buffer | Uint8Array | string)[] }} options
|
|
147
275
|
* @returns {Promise<boolean>}
|
|
148
276
|
*/
|
|
149
|
-
|
|
277
|
+
declare function rehashApiKey(rawKey: string, options: {
|
|
150
278
|
store: ApiKeyStore;
|
|
151
279
|
peppers: (Buffer | Uint8Array | string)[];
|
|
152
280
|
}): Promise<boolean>;
|
|
153
|
-
|
|
154
|
-
|
|
281
|
+
|
|
282
|
+
type ApiKeyRecord = {
|
|
155
283
|
/**
|
|
156
284
|
* Plaintext lookup key (128-bit b64u).
|
|
157
285
|
*/
|
|
@@ -197,7 +325,7 @@ export type ApiKeyRecord = {
|
|
|
197
325
|
*/
|
|
198
326
|
pepperVersion?: number | undefined;
|
|
199
327
|
};
|
|
200
|
-
|
|
328
|
+
type ApiKeyStore = {
|
|
201
329
|
put: (record: ApiKeyRecord) => Promise<void>;
|
|
202
330
|
getById: (id: string) => Promise<ApiKeyRecord | null>;
|
|
203
331
|
update: (id: string, patch: Partial<ApiKeyRecord>) => Promise<ApiKeyRecord | null>;
|
|
@@ -205,7 +333,7 @@ export type ApiKeyStore = {
|
|
|
205
333
|
revokeAllForUser: (userId: string, reason?: string) => Promise<number>;
|
|
206
334
|
listByUser: (userId: string) => Promise<ApiKeyRecord[]>;
|
|
207
335
|
};
|
|
208
|
-
|
|
336
|
+
type CreateApiKeyOptions = {
|
|
209
337
|
/**
|
|
210
338
|
* Stripe-style; `sk_live`, `pk_test`, `svc_prod_v2`.
|
|
211
339
|
*/
|
|
@@ -229,7 +357,7 @@ export type CreateApiKeyOptions = {
|
|
|
229
357
|
*/
|
|
230
358
|
now?: number | undefined;
|
|
231
359
|
};
|
|
232
|
-
|
|
360
|
+
type CreateApiKeyResult = {
|
|
233
361
|
/**
|
|
234
362
|
* Wire token — show ONCE to the caller and never again.
|
|
235
363
|
*/
|
|
@@ -243,7 +371,7 @@ export type CreateApiKeyResult = {
|
|
|
243
371
|
*/
|
|
244
372
|
record: ApiKeyRecord;
|
|
245
373
|
};
|
|
246
|
-
|
|
374
|
+
type VerifyApiKeyOptions = {
|
|
247
375
|
store: ApiKeyStore;
|
|
248
376
|
peppers?: any[] | undefined;
|
|
249
377
|
requiredScopes?: string[] | undefined;
|
|
@@ -257,8 +385,8 @@ export type VerifyApiKeyOptions = {
|
|
|
257
385
|
updateLastUsed?: boolean | undefined;
|
|
258
386
|
now?: number | undefined;
|
|
259
387
|
};
|
|
260
|
-
|
|
261
|
-
|
|
388
|
+
type VerifyApiKeyFailureReason = "malformed" | "not_found" | "expired" | "revoked" | "bad_secret" | "prefix_mismatch" | "missing_scope" | "store_unavailable";
|
|
389
|
+
type VerifyApiKeyResult = {
|
|
262
390
|
valid: true;
|
|
263
391
|
id: string;
|
|
264
392
|
userId: string;
|
|
@@ -272,7 +400,6 @@ export type VerifyApiKeyResult = {
|
|
|
272
400
|
valid: false;
|
|
273
401
|
reason: VerifyApiKeyFailureReason;
|
|
274
402
|
};
|
|
275
|
-
|
|
276
|
-
export { ApiKeyError, ErrorCode
|
|
277
|
-
export {
|
|
278
|
-
export { parseApiKey, mask } from "./token.js";
|
|
403
|
+
|
|
404
|
+
export { ApiKeyError, ErrorCode, covers, createApiKey, hasAll, hasAny, listApiKeys, mask, mask as maskApiKey, parseApiKey, rehashApiKey, revokeAllForUser, revokeApiKey, verifyApiKey };
|
|
405
|
+
export type { ApiKeyRecord, ApiKeyStore, CreateApiKeyOptions, CreateApiKeyResult, VerifyApiKeyFailureReason, VerifyApiKeyOptions, VerifyApiKeyResult };
|
package/dist/index.mjs
CHANGED
|
@@ -105,11 +105,13 @@ function parseDuration(input) {
|
|
|
105
105
|
}
|
|
106
106
|
const value = Number(match[1]);
|
|
107
107
|
const unit = (match[2] || 'ms').toLowerCase();
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
// Own keys only: a plain lookup would resolve `constructor` (and every
|
|
109
|
+
// other Object.prototype member) to a function, turning '1constructor'
|
|
110
|
+
// into NaN instead of an error.
|
|
111
|
+
if (!Object.hasOwn(UNIT_MS, unit)) {
|
|
110
112
|
throw new Error(`parseDuration: unknown time unit ${JSON.stringify(unit)}. Supported: ${SUPPORTED_UNITS}.`);
|
|
111
113
|
}
|
|
112
|
-
return Math.round(value *
|
|
114
|
+
return Math.round(value * UNIT_MS[/** @type {keyof typeof UNIT_MS} */ (unit)]);
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
/**
|
|
@@ -885,6 +887,12 @@ function candidateHashesFor(rawSecret, peppers) {
|
|
|
885
887
|
} catch {
|
|
886
888
|
return { candidateHashes: [] };
|
|
887
889
|
}
|
|
890
|
+
// Crockford decoding is forgiving (case, I/L→1, O→0), so one secret has
|
|
891
|
+
// many accepted spellings. A key must have exactly one: anything keyed on
|
|
892
|
+
// the wire string (logs, deny lists, dedupe) would otherwise miss variants.
|
|
893
|
+
if (encode(secretBytes) !== rawSecret) {
|
|
894
|
+
return { candidateHashes: [] };
|
|
895
|
+
}
|
|
888
896
|
if (!peppers || peppers.length === 0) {
|
|
889
897
|
return { candidateHashes: [hashSecret(secretBytes, null)] };
|
|
890
898
|
}
|
|
@@ -1128,19 +1136,15 @@ async function verifyApiKey(rawKey, options) {
|
|
|
1128
1136
|
if (!record) {
|
|
1129
1137
|
return { valid: false, reason: 'not_found' };
|
|
1130
1138
|
}
|
|
1131
|
-
const now = _now(options.now);
|
|
1132
|
-
if (record.revokedAt) {
|
|
1133
|
-
return { valid: false, reason: 'revoked' };
|
|
1134
|
-
}
|
|
1135
|
-
if (record.expiresAt && record.expiresAt <= now) {
|
|
1136
|
-
return { valid: false, reason: 'expired' };
|
|
1137
|
-
}
|
|
1138
1139
|
if (parsed.prefix !== record.prefix) {
|
|
1139
1140
|
// Someone submitted a key whose id belongs to a different prefix —
|
|
1140
1141
|
// treat as bad, not merely 'prefix_mismatch', because the id half
|
|
1141
1142
|
// was correct: the attacker guessed the id and swapped the prefix.
|
|
1142
1143
|
return { valid: false, reason: 'prefix_mismatch' };
|
|
1143
1144
|
}
|
|
1145
|
+
// The secret is checked before lifecycle state: the id is not secret (it
|
|
1146
|
+
// is in every masked key), so answering 'revoked' / 'expired' first
|
|
1147
|
+
// would tell anyone holding an id the key's status without its secret.
|
|
1144
1148
|
const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
|
|
1145
1149
|
let matched = false;
|
|
1146
1150
|
let matchedIndex = -1;
|
|
@@ -1154,6 +1158,13 @@ async function verifyApiKey(rawKey, options) {
|
|
|
1154
1158
|
if (!matched) {
|
|
1155
1159
|
return { valid: false, reason: 'bad_secret' };
|
|
1156
1160
|
}
|
|
1161
|
+
const now = _now(options.now);
|
|
1162
|
+
if (record.revokedAt) {
|
|
1163
|
+
return { valid: false, reason: 'revoked' };
|
|
1164
|
+
}
|
|
1165
|
+
if (record.expiresAt && record.expiresAt <= now) {
|
|
1166
|
+
return { valid: false, reason: 'expired' };
|
|
1167
|
+
}
|
|
1157
1168
|
if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
|
|
1158
1169
|
return { valid: false, reason: 'missing_scope' };
|
|
1159
1170
|
}
|
|
@@ -1278,6 +1289,17 @@ async function rehashApiKey(rawKey, options) {
|
|
|
1278
1289
|
if (!parsed) {
|
|
1279
1290
|
return false;
|
|
1280
1291
|
}
|
|
1292
|
+
// Prove the caller holds the real secret before replacing its hash —
|
|
1293
|
+
// otherwise anyone who knows a key id (it is in every masked key) could
|
|
1294
|
+
// overwrite that key's hash with one for a secret of their choosing.
|
|
1295
|
+
const record = await options.store.getById(parsed.id);
|
|
1296
|
+
if (!record) {
|
|
1297
|
+
return false;
|
|
1298
|
+
}
|
|
1299
|
+
const { candidateHashes: existing } = candidateHashesFor(parsed.secret, peppers);
|
|
1300
|
+
if (!existing.some(candidate => hashesMatch(candidate, record.hash))) {
|
|
1301
|
+
return false;
|
|
1302
|
+
}
|
|
1281
1303
|
const { candidateHashes } = candidateHashesFor(parsed.secret, [peppers[0]]);
|
|
1282
1304
|
const newHash = candidateHashes[0];
|
|
1283
1305
|
const updated = await options.store.update(parsed.id, { hash: newHash, pepperVersion: 0 });
|
|
@@ -389,7 +389,7 @@ const ALPHABET$1 = /^[A-Za-z0-9_-]*$/;
|
|
|
389
389
|
* @param {Buffer | Uint8Array} bytes
|
|
390
390
|
* @returns {string}
|
|
391
391
|
*/
|
|
392
|
-
function encode(bytes) {
|
|
392
|
+
function encode$1(bytes) {
|
|
393
393
|
if (bytes == null || (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array))) {
|
|
394
394
|
throw new TypeError('base64url.encode: expected Buffer or Uint8Array');
|
|
395
395
|
}
|
|
@@ -453,6 +453,39 @@ for (const [alias, canonical] of [
|
|
|
453
453
|
DECODE_TABLE[alias.charCodeAt(0)] = DECODE_TABLE[canonical.charCodeAt(0)];
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
+
/**
|
|
457
|
+
* Encode bytes as a Crockford base32 string.
|
|
458
|
+
*
|
|
459
|
+
* @param {Buffer | Uint8Array} bytes
|
|
460
|
+
* @returns {string}
|
|
461
|
+
* @throws {TypeError} if `bytes` is not a Buffer or Uint8Array.
|
|
462
|
+
*/
|
|
463
|
+
function encode(bytes) {
|
|
464
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
465
|
+
throw new TypeError('encode: input must be a Buffer or Uint8Array.');
|
|
466
|
+
}
|
|
467
|
+
if (bytes.length === 0) {
|
|
468
|
+
return '';
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
let out = '';
|
|
472
|
+
let bits = 0;
|
|
473
|
+
let value = 0;
|
|
474
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
475
|
+
value = (value << 8) | bytes[i];
|
|
476
|
+
bits += 8;
|
|
477
|
+
while (bits >= 5) {
|
|
478
|
+
bits -= 5;
|
|
479
|
+
out += ALPHABET[(value >>> bits) & 0x1f];
|
|
480
|
+
value &= (1 << bits) - 1;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (bits > 0) {
|
|
484
|
+
out += ALPHABET[(value << (5 - bits)) & 0x1f];
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
|
|
456
489
|
/**
|
|
457
490
|
* Decode a Crockford base32 string into a Buffer.
|
|
458
491
|
*
|
|
@@ -612,7 +645,7 @@ function hashSecret(secretBytes, pepper) {
|
|
|
612
645
|
const digest = pepper
|
|
613
646
|
? node_crypto.createHmac('sha256', pepper).update(secretBytes).digest()
|
|
614
647
|
: node_crypto.createHash('sha256').update(secretBytes).digest();
|
|
615
|
-
return encode(digest);
|
|
648
|
+
return encode$1(digest);
|
|
616
649
|
}
|
|
617
650
|
|
|
618
651
|
/**
|
|
@@ -704,6 +737,12 @@ function candidateHashesFor(rawSecret, peppers) {
|
|
|
704
737
|
} catch {
|
|
705
738
|
return { candidateHashes: [] };
|
|
706
739
|
}
|
|
740
|
+
// Crockford decoding is forgiving (case, I/L→1, O→0), so one secret has
|
|
741
|
+
// many accepted spellings. A key must have exactly one: anything keyed on
|
|
742
|
+
// the wire string (logs, deny lists, dedupe) would otherwise miss variants.
|
|
743
|
+
if (encode(secretBytes) !== rawSecret) {
|
|
744
|
+
return { candidateHashes: [] };
|
|
745
|
+
}
|
|
707
746
|
if (!peppers || peppers.length === 0) {
|
|
708
747
|
return { candidateHashes: [hashSecret(secretBytes, null)] };
|
|
709
748
|
}
|
|
@@ -774,19 +813,15 @@ async function verifyApiKey(rawKey, options) {
|
|
|
774
813
|
if (!record) {
|
|
775
814
|
return { valid: false, reason: 'not_found' };
|
|
776
815
|
}
|
|
777
|
-
const now = _now(options.now);
|
|
778
|
-
if (record.revokedAt) {
|
|
779
|
-
return { valid: false, reason: 'revoked' };
|
|
780
|
-
}
|
|
781
|
-
if (record.expiresAt && record.expiresAt <= now) {
|
|
782
|
-
return { valid: false, reason: 'expired' };
|
|
783
|
-
}
|
|
784
816
|
if (parsed.prefix !== record.prefix) {
|
|
785
817
|
// Someone submitted a key whose id belongs to a different prefix —
|
|
786
818
|
// treat as bad, not merely 'prefix_mismatch', because the id half
|
|
787
819
|
// was correct: the attacker guessed the id and swapped the prefix.
|
|
788
820
|
return { valid: false, reason: 'prefix_mismatch' };
|
|
789
821
|
}
|
|
822
|
+
// The secret is checked before lifecycle state: the id is not secret (it
|
|
823
|
+
// is in every masked key), so answering 'revoked' / 'expired' first
|
|
824
|
+
// would tell anyone holding an id the key's status without its secret.
|
|
790
825
|
const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
|
|
791
826
|
let matched = false;
|
|
792
827
|
let matchedIndex = -1;
|
|
@@ -800,6 +835,13 @@ async function verifyApiKey(rawKey, options) {
|
|
|
800
835
|
if (!matched) {
|
|
801
836
|
return { valid: false, reason: 'bad_secret' };
|
|
802
837
|
}
|
|
838
|
+
const now = _now(options.now);
|
|
839
|
+
if (record.revokedAt) {
|
|
840
|
+
return { valid: false, reason: 'revoked' };
|
|
841
|
+
}
|
|
842
|
+
if (record.expiresAt && record.expiresAt <= now) {
|
|
843
|
+
return { valid: false, reason: 'expired' };
|
|
844
|
+
}
|
|
803
845
|
if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
|
|
804
846
|
return { valid: false, reason: 'missing_scope' };
|
|
805
847
|
}
|
|
@@ -1,4 +1,99 @@
|
|
|
1
|
+
type ApiKeyRecord = {
|
|
2
|
+
/**
|
|
3
|
+
* Plaintext lookup key (128-bit b64u).
|
|
4
|
+
*/
|
|
5
|
+
id: string;
|
|
6
|
+
/**
|
|
7
|
+
* Storage hash of the secret (43-char b64u).
|
|
8
|
+
*/
|
|
9
|
+
hash: string;
|
|
10
|
+
/**
|
|
11
|
+
* The prefix the key was minted with.
|
|
12
|
+
*/
|
|
13
|
+
prefix: string;
|
|
14
|
+
userId: string;
|
|
15
|
+
scopes: string[];
|
|
16
|
+
/**
|
|
17
|
+
* Human label ("Production Backend").
|
|
18
|
+
*/
|
|
19
|
+
name?: string | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* `'live'` / `'test'` / caller-defined.
|
|
22
|
+
*/
|
|
23
|
+
environment?: string | undefined;
|
|
24
|
+
metadata?: Record<string, unknown> | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* ms epoch.
|
|
27
|
+
*/
|
|
28
|
+
createdAt: number;
|
|
29
|
+
/**
|
|
30
|
+
* ms epoch; absent = no expiry.
|
|
31
|
+
*/
|
|
32
|
+
expiresAt?: number | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* ms epoch when the record was revoked.
|
|
35
|
+
*/
|
|
36
|
+
revokedAt?: number | undefined;
|
|
37
|
+
revokedReason?: string | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* ms epoch of the last successful verify.
|
|
40
|
+
*/
|
|
41
|
+
lastUsedAt?: number | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Index into the peppers array that was used at mint. 0 = newest.
|
|
44
|
+
*/
|
|
45
|
+
pepperVersion?: number | undefined;
|
|
46
|
+
};
|
|
47
|
+
type ApiKeyStore = {
|
|
48
|
+
put: (record: ApiKeyRecord) => Promise<void>;
|
|
49
|
+
getById: (id: string) => Promise<ApiKeyRecord | null>;
|
|
50
|
+
update: (id: string, patch: Partial<ApiKeyRecord>) => Promise<ApiKeyRecord | null>;
|
|
51
|
+
revoke: (id: string, reason?: string) => Promise<boolean>;
|
|
52
|
+
revokeAllForUser: (userId: string, reason?: string) => Promise<number>;
|
|
53
|
+
listByUser: (userId: string) => Promise<ApiKeyRecord[]>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type AdapterContext = {
|
|
57
|
+
getHeader: (name: string) => string | undefined;
|
|
58
|
+
method?: string | undefined;
|
|
59
|
+
ip?: string | undefined;
|
|
60
|
+
query?: Record<string, unknown> | undefined;
|
|
61
|
+
};
|
|
62
|
+
type ApiKeyMiddlewareOptions = {
|
|
63
|
+
store: ApiKeyStore;
|
|
64
|
+
peppers?: any[] | undefined;
|
|
65
|
+
requiredScopes?: string[] | undefined;
|
|
66
|
+
expectedPrefix?: string | undefined;
|
|
67
|
+
updateLastUsed?: boolean | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* Case-insensitive. The default reads `Authorization: Bearer <key>`;
|
|
70
|
+
* a `x-api-key` config reads `X-API-Key: <key>` directly.
|
|
71
|
+
*/
|
|
72
|
+
headerName?: string | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* `'bearer'` expects `Bearer <key>`; `'raw'` uses the header value as-is.
|
|
75
|
+
*/
|
|
76
|
+
scheme?: "bearer" | "raw" | undefined;
|
|
77
|
+
/**
|
|
78
|
+
* When true, falls back to `?api_key=<key>` if no matching header is
|
|
79
|
+
* found. Discouraged because query strings leak into access logs and
|
|
80
|
+
* referer headers — off by default.
|
|
81
|
+
*/
|
|
82
|
+
allowQueryParam?: boolean | undefined;
|
|
83
|
+
queryParamName?: string | undefined;
|
|
84
|
+
/**
|
|
85
|
+
* Property name attached to the request object on success.
|
|
86
|
+
*/
|
|
87
|
+
attach?: string | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* Override the default extraction entirely.
|
|
90
|
+
*/
|
|
91
|
+
tokenFromRequest?: ((ctx: AdapterContext) => string | undefined) | undefined;
|
|
92
|
+
};
|
|
93
|
+
|
|
1
94
|
/**
|
|
2
95
|
* @param {import('./core.js').ApiKeyMiddlewareOptions} options
|
|
3
96
|
*/
|
|
4
|
-
|
|
97
|
+
declare function apiKeyMiddleware(options: ApiKeyMiddlewareOptions): (req: any, res: any, next: any) => Promise<any>;
|
|
98
|
+
|
|
99
|
+
export { apiKeyMiddleware };
|