@exortek/apikey 1.1.3 → 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 +12 -0
- package/dist/index.cjs +32 -10
- package/dist/index.mjs +32 -10
- package/dist/middleware/express.cjs +51 -9
- package/dist/middleware/express.mjs +51 -9
- package/dist/middleware/fastify.cjs +51 -9
- package/dist/middleware/fastify.mjs +51 -9
- package/dist/stores.cjs +50 -5
- package/dist/stores.mjs +50 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
3
15
|
## 1.1.3
|
|
4
16
|
|
|
5
17
|
### 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.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
|
}
|
|
@@ -387,7 +387,7 @@ const ALPHABET$1 = /^[A-Za-z0-9_-]*$/;
|
|
|
387
387
|
* @param {Buffer | Uint8Array} bytes
|
|
388
388
|
* @returns {string}
|
|
389
389
|
*/
|
|
390
|
-
function encode(bytes) {
|
|
390
|
+
function encode$1(bytes) {
|
|
391
391
|
if (bytes == null || (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array))) {
|
|
392
392
|
throw new TypeError('base64url.encode: expected Buffer or Uint8Array');
|
|
393
393
|
}
|
|
@@ -451,6 +451,39 @@ for (const [alias, canonical] of [
|
|
|
451
451
|
DECODE_TABLE[alias.charCodeAt(0)] = DECODE_TABLE[canonical.charCodeAt(0)];
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Encode bytes as a Crockford base32 string.
|
|
456
|
+
*
|
|
457
|
+
* @param {Buffer | Uint8Array} bytes
|
|
458
|
+
* @returns {string}
|
|
459
|
+
* @throws {TypeError} if `bytes` is not a Buffer or Uint8Array.
|
|
460
|
+
*/
|
|
461
|
+
function encode(bytes) {
|
|
462
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
463
|
+
throw new TypeError('encode: input must be a Buffer or Uint8Array.');
|
|
464
|
+
}
|
|
465
|
+
if (bytes.length === 0) {
|
|
466
|
+
return '';
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let out = '';
|
|
470
|
+
let bits = 0;
|
|
471
|
+
let value = 0;
|
|
472
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
473
|
+
value = (value << 8) | bytes[i];
|
|
474
|
+
bits += 8;
|
|
475
|
+
while (bits >= 5) {
|
|
476
|
+
bits -= 5;
|
|
477
|
+
out += ALPHABET[(value >>> bits) & 0x1f];
|
|
478
|
+
value &= (1 << bits) - 1;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (bits > 0) {
|
|
482
|
+
out += ALPHABET[(value << (5 - bits)) & 0x1f];
|
|
483
|
+
}
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
|
|
454
487
|
/**
|
|
455
488
|
* Decode a Crockford base32 string into a Buffer.
|
|
456
489
|
*
|
|
@@ -610,7 +643,7 @@ function hashSecret(secretBytes, pepper) {
|
|
|
610
643
|
const digest = pepper
|
|
611
644
|
? createHmac('sha256', pepper).update(secretBytes).digest()
|
|
612
645
|
: createHash('sha256').update(secretBytes).digest();
|
|
613
|
-
return encode(digest);
|
|
646
|
+
return encode$1(digest);
|
|
614
647
|
}
|
|
615
648
|
|
|
616
649
|
/**
|
|
@@ -702,6 +735,12 @@ function candidateHashesFor(rawSecret, peppers) {
|
|
|
702
735
|
} catch {
|
|
703
736
|
return { candidateHashes: [] };
|
|
704
737
|
}
|
|
738
|
+
// Crockford decoding is forgiving (case, I/L→1, O→0), so one secret has
|
|
739
|
+
// many accepted spellings. A key must have exactly one: anything keyed on
|
|
740
|
+
// the wire string (logs, deny lists, dedupe) would otherwise miss variants.
|
|
741
|
+
if (encode(secretBytes) !== rawSecret) {
|
|
742
|
+
return { candidateHashes: [] };
|
|
743
|
+
}
|
|
705
744
|
if (!peppers || peppers.length === 0) {
|
|
706
745
|
return { candidateHashes: [hashSecret(secretBytes, null)] };
|
|
707
746
|
}
|
|
@@ -772,19 +811,15 @@ async function verifyApiKey(rawKey, options) {
|
|
|
772
811
|
if (!record) {
|
|
773
812
|
return { valid: false, reason: 'not_found' };
|
|
774
813
|
}
|
|
775
|
-
const now = _now(options.now);
|
|
776
|
-
if (record.revokedAt) {
|
|
777
|
-
return { valid: false, reason: 'revoked' };
|
|
778
|
-
}
|
|
779
|
-
if (record.expiresAt && record.expiresAt <= now) {
|
|
780
|
-
return { valid: false, reason: 'expired' };
|
|
781
|
-
}
|
|
782
814
|
if (parsed.prefix !== record.prefix) {
|
|
783
815
|
// Someone submitted a key whose id belongs to a different prefix —
|
|
784
816
|
// treat as bad, not merely 'prefix_mismatch', because the id half
|
|
785
817
|
// was correct: the attacker guessed the id and swapped the prefix.
|
|
786
818
|
return { valid: false, reason: 'prefix_mismatch' };
|
|
787
819
|
}
|
|
820
|
+
// The secret is checked before lifecycle state: the id is not secret (it
|
|
821
|
+
// is in every masked key), so answering 'revoked' / 'expired' first
|
|
822
|
+
// would tell anyone holding an id the key's status without its secret.
|
|
788
823
|
const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
|
|
789
824
|
let matched = false;
|
|
790
825
|
let matchedIndex = -1;
|
|
@@ -798,6 +833,13 @@ async function verifyApiKey(rawKey, options) {
|
|
|
798
833
|
if (!matched) {
|
|
799
834
|
return { valid: false, reason: 'bad_secret' };
|
|
800
835
|
}
|
|
836
|
+
const now = _now(options.now);
|
|
837
|
+
if (record.revokedAt) {
|
|
838
|
+
return { valid: false, reason: 'revoked' };
|
|
839
|
+
}
|
|
840
|
+
if (record.expiresAt && record.expiresAt <= now) {
|
|
841
|
+
return { valid: false, reason: 'expired' };
|
|
842
|
+
}
|
|
801
843
|
if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
|
|
802
844
|
return { valid: false, reason: 'missing_scope' };
|
|
803
845
|
}
|
|
@@ -450,7 +450,7 @@ const ALPHABET$1 = /^[A-Za-z0-9_-]*$/;
|
|
|
450
450
|
* @param {Buffer | Uint8Array} bytes
|
|
451
451
|
* @returns {string}
|
|
452
452
|
*/
|
|
453
|
-
function encode(bytes) {
|
|
453
|
+
function encode$1(bytes) {
|
|
454
454
|
if (bytes == null || (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array))) {
|
|
455
455
|
throw new TypeError('base64url.encode: expected Buffer or Uint8Array');
|
|
456
456
|
}
|
|
@@ -514,6 +514,39 @@ for (const [alias, canonical] of [
|
|
|
514
514
|
DECODE_TABLE[alias.charCodeAt(0)] = DECODE_TABLE[canonical.charCodeAt(0)];
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Encode bytes as a Crockford base32 string.
|
|
519
|
+
*
|
|
520
|
+
* @param {Buffer | Uint8Array} bytes
|
|
521
|
+
* @returns {string}
|
|
522
|
+
* @throws {TypeError} if `bytes` is not a Buffer or Uint8Array.
|
|
523
|
+
*/
|
|
524
|
+
function encode(bytes) {
|
|
525
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
526
|
+
throw new TypeError('encode: input must be a Buffer or Uint8Array.');
|
|
527
|
+
}
|
|
528
|
+
if (bytes.length === 0) {
|
|
529
|
+
return '';
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
let out = '';
|
|
533
|
+
let bits = 0;
|
|
534
|
+
let value = 0;
|
|
535
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
536
|
+
value = (value << 8) | bytes[i];
|
|
537
|
+
bits += 8;
|
|
538
|
+
while (bits >= 5) {
|
|
539
|
+
bits -= 5;
|
|
540
|
+
out += ALPHABET[(value >>> bits) & 0x1f];
|
|
541
|
+
value &= (1 << bits) - 1;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (bits > 0) {
|
|
545
|
+
out += ALPHABET[(value << (5 - bits)) & 0x1f];
|
|
546
|
+
}
|
|
547
|
+
return out;
|
|
548
|
+
}
|
|
549
|
+
|
|
517
550
|
/**
|
|
518
551
|
* Decode a Crockford base32 string into a Buffer.
|
|
519
552
|
*
|
|
@@ -673,7 +706,7 @@ function hashSecret(secretBytes, pepper) {
|
|
|
673
706
|
const digest = pepper
|
|
674
707
|
? node_crypto.createHmac('sha256', pepper).update(secretBytes).digest()
|
|
675
708
|
: node_crypto.createHash('sha256').update(secretBytes).digest();
|
|
676
|
-
return encode(digest);
|
|
709
|
+
return encode$1(digest);
|
|
677
710
|
}
|
|
678
711
|
|
|
679
712
|
/**
|
|
@@ -765,6 +798,12 @@ function candidateHashesFor(rawSecret, peppers) {
|
|
|
765
798
|
} catch {
|
|
766
799
|
return { candidateHashes: [] };
|
|
767
800
|
}
|
|
801
|
+
// Crockford decoding is forgiving (case, I/L→1, O→0), so one secret has
|
|
802
|
+
// many accepted spellings. A key must have exactly one: anything keyed on
|
|
803
|
+
// the wire string (logs, deny lists, dedupe) would otherwise miss variants.
|
|
804
|
+
if (encode(secretBytes) !== rawSecret) {
|
|
805
|
+
return { candidateHashes: [] };
|
|
806
|
+
}
|
|
768
807
|
if (!peppers || peppers.length === 0) {
|
|
769
808
|
return { candidateHashes: [hashSecret(secretBytes, null)] };
|
|
770
809
|
}
|
|
@@ -835,19 +874,15 @@ async function verifyApiKey(rawKey, options) {
|
|
|
835
874
|
if (!record) {
|
|
836
875
|
return { valid: false, reason: 'not_found' };
|
|
837
876
|
}
|
|
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
|
-
}
|
|
845
877
|
if (parsed.prefix !== record.prefix) {
|
|
846
878
|
// Someone submitted a key whose id belongs to a different prefix —
|
|
847
879
|
// treat as bad, not merely 'prefix_mismatch', because the id half
|
|
848
880
|
// was correct: the attacker guessed the id and swapped the prefix.
|
|
849
881
|
return { valid: false, reason: 'prefix_mismatch' };
|
|
850
882
|
}
|
|
883
|
+
// The secret is checked before lifecycle state: the id is not secret (it
|
|
884
|
+
// is in every masked key), so answering 'revoked' / 'expired' first
|
|
885
|
+
// would tell anyone holding an id the key's status without its secret.
|
|
851
886
|
const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
|
|
852
887
|
let matched = false;
|
|
853
888
|
let matchedIndex = -1;
|
|
@@ -861,6 +896,13 @@ async function verifyApiKey(rawKey, options) {
|
|
|
861
896
|
if (!matched) {
|
|
862
897
|
return { valid: false, reason: 'bad_secret' };
|
|
863
898
|
}
|
|
899
|
+
const now = _now(options.now);
|
|
900
|
+
if (record.revokedAt) {
|
|
901
|
+
return { valid: false, reason: 'revoked' };
|
|
902
|
+
}
|
|
903
|
+
if (record.expiresAt && record.expiresAt <= now) {
|
|
904
|
+
return { valid: false, reason: 'expired' };
|
|
905
|
+
}
|
|
864
906
|
if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
|
|
865
907
|
return { valid: false, reason: 'missing_scope' };
|
|
866
908
|
}
|
|
@@ -448,7 +448,7 @@ const ALPHABET$1 = /^[A-Za-z0-9_-]*$/;
|
|
|
448
448
|
* @param {Buffer | Uint8Array} bytes
|
|
449
449
|
* @returns {string}
|
|
450
450
|
*/
|
|
451
|
-
function encode(bytes) {
|
|
451
|
+
function encode$1(bytes) {
|
|
452
452
|
if (bytes == null || (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array))) {
|
|
453
453
|
throw new TypeError('base64url.encode: expected Buffer or Uint8Array');
|
|
454
454
|
}
|
|
@@ -512,6 +512,39 @@ for (const [alias, canonical] of [
|
|
|
512
512
|
DECODE_TABLE[alias.charCodeAt(0)] = DECODE_TABLE[canonical.charCodeAt(0)];
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
+
/**
|
|
516
|
+
* Encode bytes as a Crockford base32 string.
|
|
517
|
+
*
|
|
518
|
+
* @param {Buffer | Uint8Array} bytes
|
|
519
|
+
* @returns {string}
|
|
520
|
+
* @throws {TypeError} if `bytes` is not a Buffer or Uint8Array.
|
|
521
|
+
*/
|
|
522
|
+
function encode(bytes) {
|
|
523
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
524
|
+
throw new TypeError('encode: input must be a Buffer or Uint8Array.');
|
|
525
|
+
}
|
|
526
|
+
if (bytes.length === 0) {
|
|
527
|
+
return '';
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let out = '';
|
|
531
|
+
let bits = 0;
|
|
532
|
+
let value = 0;
|
|
533
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
534
|
+
value = (value << 8) | bytes[i];
|
|
535
|
+
bits += 8;
|
|
536
|
+
while (bits >= 5) {
|
|
537
|
+
bits -= 5;
|
|
538
|
+
out += ALPHABET[(value >>> bits) & 0x1f];
|
|
539
|
+
value &= (1 << bits) - 1;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (bits > 0) {
|
|
543
|
+
out += ALPHABET[(value << (5 - bits)) & 0x1f];
|
|
544
|
+
}
|
|
545
|
+
return out;
|
|
546
|
+
}
|
|
547
|
+
|
|
515
548
|
/**
|
|
516
549
|
* Decode a Crockford base32 string into a Buffer.
|
|
517
550
|
*
|
|
@@ -671,7 +704,7 @@ function hashSecret(secretBytes, pepper) {
|
|
|
671
704
|
const digest = pepper
|
|
672
705
|
? createHmac('sha256', pepper).update(secretBytes).digest()
|
|
673
706
|
: createHash('sha256').update(secretBytes).digest();
|
|
674
|
-
return encode(digest);
|
|
707
|
+
return encode$1(digest);
|
|
675
708
|
}
|
|
676
709
|
|
|
677
710
|
/**
|
|
@@ -763,6 +796,12 @@ function candidateHashesFor(rawSecret, peppers) {
|
|
|
763
796
|
} catch {
|
|
764
797
|
return { candidateHashes: [] };
|
|
765
798
|
}
|
|
799
|
+
// Crockford decoding is forgiving (case, I/L→1, O→0), so one secret has
|
|
800
|
+
// many accepted spellings. A key must have exactly one: anything keyed on
|
|
801
|
+
// the wire string (logs, deny lists, dedupe) would otherwise miss variants.
|
|
802
|
+
if (encode(secretBytes) !== rawSecret) {
|
|
803
|
+
return { candidateHashes: [] };
|
|
804
|
+
}
|
|
766
805
|
if (!peppers || peppers.length === 0) {
|
|
767
806
|
return { candidateHashes: [hashSecret(secretBytes, null)] };
|
|
768
807
|
}
|
|
@@ -833,19 +872,15 @@ async function verifyApiKey(rawKey, options) {
|
|
|
833
872
|
if (!record) {
|
|
834
873
|
return { valid: false, reason: 'not_found' };
|
|
835
874
|
}
|
|
836
|
-
const now = _now(options.now);
|
|
837
|
-
if (record.revokedAt) {
|
|
838
|
-
return { valid: false, reason: 'revoked' };
|
|
839
|
-
}
|
|
840
|
-
if (record.expiresAt && record.expiresAt <= now) {
|
|
841
|
-
return { valid: false, reason: 'expired' };
|
|
842
|
-
}
|
|
843
875
|
if (parsed.prefix !== record.prefix) {
|
|
844
876
|
// Someone submitted a key whose id belongs to a different prefix —
|
|
845
877
|
// treat as bad, not merely 'prefix_mismatch', because the id half
|
|
846
878
|
// was correct: the attacker guessed the id and swapped the prefix.
|
|
847
879
|
return { valid: false, reason: 'prefix_mismatch' };
|
|
848
880
|
}
|
|
881
|
+
// The secret is checked before lifecycle state: the id is not secret (it
|
|
882
|
+
// is in every masked key), so answering 'revoked' / 'expired' first
|
|
883
|
+
// would tell anyone holding an id the key's status without its secret.
|
|
849
884
|
const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
|
|
850
885
|
let matched = false;
|
|
851
886
|
let matchedIndex = -1;
|
|
@@ -859,6 +894,13 @@ async function verifyApiKey(rawKey, options) {
|
|
|
859
894
|
if (!matched) {
|
|
860
895
|
return { valid: false, reason: 'bad_secret' };
|
|
861
896
|
}
|
|
897
|
+
const now = _now(options.now);
|
|
898
|
+
if (record.revokedAt) {
|
|
899
|
+
return { valid: false, reason: 'revoked' };
|
|
900
|
+
}
|
|
901
|
+
if (record.expiresAt && record.expiresAt <= now) {
|
|
902
|
+
return { valid: false, reason: 'expired' };
|
|
903
|
+
}
|
|
862
904
|
if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
|
|
863
905
|
return { valid: false, reason: 'missing_scope' };
|
|
864
906
|
}
|
package/dist/stores.cjs
CHANGED
|
@@ -169,6 +169,8 @@ function detectDialect(client) {
|
|
|
169
169
|
* smembers: (key: string) => Promise<string[]>,
|
|
170
170
|
* setWithTTL: (key: string, value: string, ttlMs: number) => Promise<void>,
|
|
171
171
|
* setPlain: (key: string, value: string) => Promise<void>,
|
|
172
|
+
* setKeepTTL: (key: string, value: string) => Promise<void>,
|
|
173
|
+
* setIfAbsent: (key: string, value: string, ttlMs: number) => Promise<boolean>,
|
|
172
174
|
* parseRecord: (raw: string|object|null) => object|null,
|
|
173
175
|
* }}
|
|
174
176
|
*/
|
|
@@ -238,6 +240,29 @@ function createRedisHelpers(client) {
|
|
|
238
240
|
await client.set(key, value);
|
|
239
241
|
},
|
|
240
242
|
|
|
243
|
+
// Overwrite a value but keep whatever expiry the key already has
|
|
244
|
+
// (Redis ≥ 6.0 `SET … KEEPTTL`). A plain SET clears the TTL, so an
|
|
245
|
+
// update to an expiring record would otherwise make it immortal.
|
|
246
|
+
async setKeepTTL(key, value) {
|
|
247
|
+
if (dialect === 'ioredis') {
|
|
248
|
+
await client.set(key, value, 'KEEPTTL');
|
|
249
|
+
} else {
|
|
250
|
+
await client.set(key, value, { KEEPTTL: true });
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
// Atomic claim: `SET key value PX ttl NX`. Resolves true when this call
|
|
255
|
+
// created the key, false when it already existed — a cross-process
|
|
256
|
+
// compare-and-set for "only the first caller wins".
|
|
257
|
+
async setIfAbsent(key, value, ttlMs) {
|
|
258
|
+
const px = Math.max(1, Math.ceil(ttlMs));
|
|
259
|
+
const reply =
|
|
260
|
+
dialect === 'ioredis'
|
|
261
|
+
? await client.set(key, value, 'PX', px, 'NX')
|
|
262
|
+
: await client.set(key, value, { PX: px, NX: true });
|
|
263
|
+
return reply === 'OK';
|
|
264
|
+
},
|
|
265
|
+
|
|
241
266
|
parseRecord(raw) {
|
|
242
267
|
if (!raw) {
|
|
243
268
|
return null;
|
|
@@ -333,8 +358,15 @@ function createMemoryRecordStore(config) {
|
|
|
333
358
|
byIndex,
|
|
334
359
|
|
|
335
360
|
async put(record) {
|
|
336
|
-
|
|
337
|
-
|
|
361
|
+
const id = record[idField];
|
|
362
|
+
const previous = byId.get(id);
|
|
363
|
+
// Overwriting a record under a different owner must drop the old
|
|
364
|
+
// owner's index entry, or the old owner keeps listing it.
|
|
365
|
+
if (previous && previous[indexField] !== record[indexField]) {
|
|
366
|
+
removeIndex(previous[indexField], id);
|
|
367
|
+
}
|
|
368
|
+
byId.set(id, copy(record));
|
|
369
|
+
addIndex(record[indexField], id);
|
|
338
370
|
},
|
|
339
371
|
|
|
340
372
|
async getById(id) {
|
|
@@ -399,7 +431,7 @@ function createMemoryRecordStore(config) {
|
|
|
399
431
|
* @returns {{
|
|
400
432
|
* put: (record: object, ttlMs?: number) => Promise<void>,
|
|
401
433
|
* getById: (id: string) => Promise<object|null>,
|
|
402
|
-
* update: (id: string, patch: object, ttlMs?: number) => Promise<object|null>,
|
|
434
|
+
* update: (id: string, patch: object, ttlMs?: number) => Promise<object|null>, without `ttlMs` a TTL store keeps the key's remaining expiry
|
|
403
435
|
* listByIndex: (indexValue: string) => Promise<object[]>,
|
|
404
436
|
* readRecord: (id: string) => Promise<object|null>,
|
|
405
437
|
* writeRecord: (record: object, ttlMs?: number) => Promise<void>,
|
|
@@ -464,6 +496,16 @@ function createRedisRecordStore(client, config) {
|
|
|
464
496
|
}
|
|
465
497
|
}
|
|
466
498
|
|
|
499
|
+
// An update with no explicit TTL keeps the key's remaining expiry
|
|
500
|
+
// instead of clearing it.
|
|
501
|
+
async function rewriteRecord(record, ttlMs) {
|
|
502
|
+
if (ttl && ttlMs === undefined) {
|
|
503
|
+
await helpers.setKeepTTL(rk(record[idField]), JSON.stringify(record));
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
await writeRecord(record, ttlMs);
|
|
507
|
+
}
|
|
508
|
+
|
|
467
509
|
async function writeTombstone(id, data, ttlMs) {
|
|
468
510
|
const value = JSON.stringify(data);
|
|
469
511
|
if (ttl && ttlMs !== undefined) {
|
|
@@ -500,7 +542,10 @@ function createRedisRecordStore(client, config) {
|
|
|
500
542
|
const raw = rows[i * stride];
|
|
501
543
|
const rawTomb = tombstones ? rows[i * stride + 1] : null;
|
|
502
544
|
const record = helpers.parseRecord(raw);
|
|
503
|
-
|
|
545
|
+
// Gone, or re-put under another owner (`put` does not read the old
|
|
546
|
+
// record, so it cannot unlink the old index entry itself) — prune the
|
|
547
|
+
// stale entry and never hand the record to this owner.
|
|
548
|
+
if (!record || record[indexField] !== indexValue) {
|
|
504
549
|
await indexRemove(indexValue, ids[i]);
|
|
505
550
|
pairs.push([ids[i], null]);
|
|
506
551
|
continue;
|
|
@@ -529,7 +574,7 @@ function createRedisRecordStore(client, config) {
|
|
|
529
574
|
}
|
|
530
575
|
const oldIndex = existing[indexField];
|
|
531
576
|
const next = { ...existing, ...patch };
|
|
532
|
-
await
|
|
577
|
+
await rewriteRecord(next, ttlMs);
|
|
533
578
|
if (!isUndefined(patch[indexField]) && patch[indexField] !== oldIndex) {
|
|
534
579
|
if (isString(oldIndex)) {
|
|
535
580
|
await indexRemove(oldIndex, id);
|
package/dist/stores.mjs
CHANGED
|
@@ -167,6 +167,8 @@ function detectDialect(client) {
|
|
|
167
167
|
* smembers: (key: string) => Promise<string[]>,
|
|
168
168
|
* setWithTTL: (key: string, value: string, ttlMs: number) => Promise<void>,
|
|
169
169
|
* setPlain: (key: string, value: string) => Promise<void>,
|
|
170
|
+
* setKeepTTL: (key: string, value: string) => Promise<void>,
|
|
171
|
+
* setIfAbsent: (key: string, value: string, ttlMs: number) => Promise<boolean>,
|
|
170
172
|
* parseRecord: (raw: string|object|null) => object|null,
|
|
171
173
|
* }}
|
|
172
174
|
*/
|
|
@@ -236,6 +238,29 @@ function createRedisHelpers(client) {
|
|
|
236
238
|
await client.set(key, value);
|
|
237
239
|
},
|
|
238
240
|
|
|
241
|
+
// Overwrite a value but keep whatever expiry the key already has
|
|
242
|
+
// (Redis ≥ 6.0 `SET … KEEPTTL`). A plain SET clears the TTL, so an
|
|
243
|
+
// update to an expiring record would otherwise make it immortal.
|
|
244
|
+
async setKeepTTL(key, value) {
|
|
245
|
+
if (dialect === 'ioredis') {
|
|
246
|
+
await client.set(key, value, 'KEEPTTL');
|
|
247
|
+
} else {
|
|
248
|
+
await client.set(key, value, { KEEPTTL: true });
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
// Atomic claim: `SET key value PX ttl NX`. Resolves true when this call
|
|
253
|
+
// created the key, false when it already existed — a cross-process
|
|
254
|
+
// compare-and-set for "only the first caller wins".
|
|
255
|
+
async setIfAbsent(key, value, ttlMs) {
|
|
256
|
+
const px = Math.max(1, Math.ceil(ttlMs));
|
|
257
|
+
const reply =
|
|
258
|
+
dialect === 'ioredis'
|
|
259
|
+
? await client.set(key, value, 'PX', px, 'NX')
|
|
260
|
+
: await client.set(key, value, { PX: px, NX: true });
|
|
261
|
+
return reply === 'OK';
|
|
262
|
+
},
|
|
263
|
+
|
|
239
264
|
parseRecord(raw) {
|
|
240
265
|
if (!raw) {
|
|
241
266
|
return null;
|
|
@@ -331,8 +356,15 @@ function createMemoryRecordStore(config) {
|
|
|
331
356
|
byIndex,
|
|
332
357
|
|
|
333
358
|
async put(record) {
|
|
334
|
-
|
|
335
|
-
|
|
359
|
+
const id = record[idField];
|
|
360
|
+
const previous = byId.get(id);
|
|
361
|
+
// Overwriting a record under a different owner must drop the old
|
|
362
|
+
// owner's index entry, or the old owner keeps listing it.
|
|
363
|
+
if (previous && previous[indexField] !== record[indexField]) {
|
|
364
|
+
removeIndex(previous[indexField], id);
|
|
365
|
+
}
|
|
366
|
+
byId.set(id, copy(record));
|
|
367
|
+
addIndex(record[indexField], id);
|
|
336
368
|
},
|
|
337
369
|
|
|
338
370
|
async getById(id) {
|
|
@@ -397,7 +429,7 @@ function createMemoryRecordStore(config) {
|
|
|
397
429
|
* @returns {{
|
|
398
430
|
* put: (record: object, ttlMs?: number) => Promise<void>,
|
|
399
431
|
* getById: (id: string) => Promise<object|null>,
|
|
400
|
-
* update: (id: string, patch: object, ttlMs?: number) => Promise<object|null>,
|
|
432
|
+
* update: (id: string, patch: object, ttlMs?: number) => Promise<object|null>, without `ttlMs` a TTL store keeps the key's remaining expiry
|
|
401
433
|
* listByIndex: (indexValue: string) => Promise<object[]>,
|
|
402
434
|
* readRecord: (id: string) => Promise<object|null>,
|
|
403
435
|
* writeRecord: (record: object, ttlMs?: number) => Promise<void>,
|
|
@@ -462,6 +494,16 @@ function createRedisRecordStore(client, config) {
|
|
|
462
494
|
}
|
|
463
495
|
}
|
|
464
496
|
|
|
497
|
+
// An update with no explicit TTL keeps the key's remaining expiry
|
|
498
|
+
// instead of clearing it.
|
|
499
|
+
async function rewriteRecord(record, ttlMs) {
|
|
500
|
+
if (ttl && ttlMs === undefined) {
|
|
501
|
+
await helpers.setKeepTTL(rk(record[idField]), JSON.stringify(record));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
await writeRecord(record, ttlMs);
|
|
505
|
+
}
|
|
506
|
+
|
|
465
507
|
async function writeTombstone(id, data, ttlMs) {
|
|
466
508
|
const value = JSON.stringify(data);
|
|
467
509
|
if (ttl && ttlMs !== undefined) {
|
|
@@ -498,7 +540,10 @@ function createRedisRecordStore(client, config) {
|
|
|
498
540
|
const raw = rows[i * stride];
|
|
499
541
|
const rawTomb = tombstones ? rows[i * stride + 1] : null;
|
|
500
542
|
const record = helpers.parseRecord(raw);
|
|
501
|
-
|
|
543
|
+
// Gone, or re-put under another owner (`put` does not read the old
|
|
544
|
+
// record, so it cannot unlink the old index entry itself) — prune the
|
|
545
|
+
// stale entry and never hand the record to this owner.
|
|
546
|
+
if (!record || record[indexField] !== indexValue) {
|
|
502
547
|
await indexRemove(indexValue, ids[i]);
|
|
503
548
|
pairs.push([ids[i], null]);
|
|
504
549
|
continue;
|
|
@@ -527,7 +572,7 @@ function createRedisRecordStore(client, config) {
|
|
|
527
572
|
}
|
|
528
573
|
const oldIndex = existing[indexField];
|
|
529
574
|
const next = { ...existing, ...patch };
|
|
530
|
-
await
|
|
575
|
+
await rewriteRecord(next, ttlMs);
|
|
531
576
|
if (!isUndefined(patch[indexField]) && patch[indexField] !== oldIndex) {
|
|
532
577
|
if (isString(oldIndex)) {
|
|
533
578
|
await indexRemove(oldIndex, id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exortek/apikey",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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,
|