@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 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
- const multiplier = UNIT_MS[/** @type {keyof typeof UNIT_MS} */ (unit)];
111
- if (multiplier === undefined) {
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 * multiplier);
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
- export function createApiKey(options: CreateApiKeyOptions): Promise<CreateApiKeyResult>;
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
- export function verifyApiKey(rawKey: string, options: VerifyApiKeyOptions): Promise<VerifyApiKeyResult>;
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
- export function revokeApiKey(keyOrId: string, options: {
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
- export function revokeAllForUser(userId: string, options: {
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
- export function listApiKeys(userId: string, options: {
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
- export function rehashApiKey(rawKey: string, options: {
277
+ declare function rehashApiKey(rawKey: string, options: {
150
278
  store: ApiKeyStore;
151
279
  peppers: (Buffer | Uint8Array | string)[];
152
280
  }): Promise<boolean>;
153
- export { maskKey as maskApiKey };
154
- export type ApiKeyRecord = {
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
- export type ApiKeyStore = {
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
- export type CreateApiKeyOptions = {
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
- export type CreateApiKeyResult = {
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
- export type VerifyApiKeyOptions = {
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
- export type VerifyApiKeyFailureReason = "malformed" | "not_found" | "expired" | "revoked" | "bad_secret" | "prefix_mismatch" | "missing_scope" | "store_unavailable";
261
- export type VerifyApiKeyResult = {
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
- import { mask as maskKey } from './token.js';
276
- export { ApiKeyError, ErrorCode } from "./errors.js";
277
- export { covers, hasAll, hasAny } from "./scopes.js";
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
- const multiplier = UNIT_MS[/** @type {keyof typeof UNIT_MS} */ (unit)];
109
- if (multiplier === undefined) {
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 * multiplier);
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
- export function apiKeyMiddleware(options: import("./core.js").ApiKeyMiddlewareOptions): (req: any, res: any, next: any) => Promise<any>;
97
+ declare function apiKeyMiddleware(options: ApiKeyMiddlewareOptions): (req: any, res: any, next: any) => Promise<any>;
98
+
99
+ export { apiKeyMiddleware };