@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
|
@@ -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/custom.d.ts
CHANGED
|
@@ -1,8 +1 @@
|
|
|
1
|
-
export
|
|
2
|
-
put: (record: any) => Promise<any>;
|
|
3
|
-
getById: (id: any) => Promise<any>;
|
|
4
|
-
update: (id: any, patch: any) => Promise<any>;
|
|
5
|
-
revoke: (id: any, reason: any) => Promise<any>;
|
|
6
|
-
revokeAllForUser: (userId: any, reason: any) => Promise<any>;
|
|
7
|
-
listByUser: (userId: any) => Promise<any>;
|
|
8
|
-
};
|
|
1
|
+
export const customStore: (impl: object) => Record<string, (...args: any[]) => Promise<any>>;
|
package/dist/stores/index.d.ts
CHANGED
|
@@ -1,3 +1,79 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
+
/**
|
|
57
|
+
* @returns {import('../index.js').ApiKeyStore & { _size: () => number }}
|
|
58
|
+
*/
|
|
59
|
+
declare function memoryStore(): ApiKeyStore & {
|
|
60
|
+
_size: () => number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {object} RedisStoreOptions
|
|
65
|
+
* @property {string} [keyPrefix='apikey:']
|
|
66
|
+
*/
|
|
67
|
+
/**
|
|
68
|
+
* @param {any} client
|
|
69
|
+
* @param {RedisStoreOptions} [options]
|
|
70
|
+
* @returns {import('../index.js').ApiKeyStore}
|
|
71
|
+
*/
|
|
72
|
+
declare function redisStore(client: any, options?: RedisStoreOptions): ApiKeyStore;
|
|
73
|
+
type RedisStoreOptions = {
|
|
74
|
+
keyPrefix?: string | undefined;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
declare const customStore: (impl: object) => Record<string, (...args: any[]) => Promise<any>>;
|
|
78
|
+
|
|
79
|
+
export { customStore, memoryStore, redisStore };
|
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);
|
|
@@ -897,39 +942,101 @@ function redisStore(client, options = {}) {
|
|
|
897
942
|
}
|
|
898
943
|
|
|
899
944
|
/**
|
|
900
|
-
*
|
|
901
|
-
*
|
|
902
|
-
* surfaces at construction time, not on the first `createApiKey` /
|
|
903
|
-
* `verifyApiKey` call. Sync implementations are wrapped transparently
|
|
904
|
-
* — return a plain value or a Promise, either works.
|
|
945
|
+
* Family factory for the `customStore(impl)` wrapper every store-shipping
|
|
946
|
+
* package exposes.
|
|
905
947
|
*
|
|
906
|
-
*
|
|
948
|
+
* Each package used to hand-roll the same three steps: assert `impl` is an
|
|
949
|
+
* object, assert its required methods are functions, then `Promise.resolve()`
|
|
950
|
+
* -wrap every call so a sync implementation is transparently promoted to the
|
|
951
|
+
* async store interface. Only the method-name arrays and the typed error
|
|
952
|
+
* differ. This collapses all of that into one binding per package.
|
|
907
953
|
*
|
|
908
|
-
*
|
|
909
|
-
* @returns {import('../index.js').ApiKeyStore}
|
|
954
|
+
* Consumers: `apikey`, `challenge`, `magic-link`, `opaque`, `session`.
|
|
910
955
|
*/
|
|
911
956
|
|
|
912
|
-
const REQUIRED = ['put', 'getById', 'update', 'revoke', 'revokeAllForUser', 'listByUser'];
|
|
913
957
|
|
|
914
|
-
|
|
958
|
+
/**
|
|
959
|
+
* Assert `impl` is an object exposing every method in `required` as a function.
|
|
960
|
+
* Throws the caller's typed error (via `wrap`) on the first failure. Used on
|
|
961
|
+
* its own by stores that keep a caller's implementation verbatim (jwt / paseto
|
|
962
|
+
* token registries), and internally by {@link createCustomStoreValidator}.
|
|
963
|
+
*
|
|
964
|
+
* @param {unknown} impl
|
|
965
|
+
* @param {readonly string[]} required
|
|
966
|
+
* @param {(message: string) => Error} wrap
|
|
967
|
+
* The package's error factory; its returned error is thrown. A `wrap` that
|
|
968
|
+
* throws directly also works (the outer `throw` is then unreachable).
|
|
969
|
+
* @returns {void}
|
|
970
|
+
*/
|
|
971
|
+
function assertStoreShape(impl, required, wrap) {
|
|
915
972
|
if (!isObject(impl)) {
|
|
916
|
-
throw
|
|
973
|
+
throw wrap(`customStore(impl) requires an object with { ${required.join(', ')} } methods`);
|
|
917
974
|
}
|
|
918
|
-
for (const name of
|
|
919
|
-
if (!isFunction(impl[name])) {
|
|
920
|
-
throw
|
|
975
|
+
for (const name of required) {
|
|
976
|
+
if (!isFunction(/** @type {any} */ (impl)[name])) {
|
|
977
|
+
throw wrap(`customStore: impl.${name} is required and must be a function`);
|
|
921
978
|
}
|
|
922
979
|
}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* @param {object} spec
|
|
984
|
+
* @param {readonly string[]} spec.required
|
|
985
|
+
* Methods that must exist on `impl`; missing ones fail at construction time.
|
|
986
|
+
* @param {readonly string[]} [spec.optional]
|
|
987
|
+
* Methods wrapped through only when present (feature-gated store extras).
|
|
988
|
+
* @param {(message: string) => Error} spec.wrap
|
|
989
|
+
* The package's error factory (its `invalidArgument`) — called with a
|
|
990
|
+
* diagnostic message; its returned typed error is thrown so
|
|
991
|
+
* `err instanceof <Pkg>Error` holds. A `wrap` that throws directly also
|
|
992
|
+
* works (the outer `throw` is then unreachable).
|
|
993
|
+
* @param {Record<string, (result: any) => any>} [spec.coerce]
|
|
994
|
+
* Optional per-method post-processor applied to the resolved result — e.g.
|
|
995
|
+
* opaque coerces `set` to `undefined` so a native `Map.set` return value
|
|
996
|
+
* can't leak out as `Promise<Map>`.
|
|
997
|
+
* @returns {(impl: object) => Record<string, (...args: any[]) => Promise<any>>}
|
|
998
|
+
*/
|
|
999
|
+
function createCustomStoreValidator({ required, optional = [], wrap, coerce = {} }) {
|
|
1000
|
+
return function customStore(impl) {
|
|
1001
|
+
assertStoreShape(impl, required, wrap);
|
|
1002
|
+
|
|
1003
|
+
/** @type {Record<string, (...args: any[]) => Promise<any>>} */
|
|
1004
|
+
const store = {};
|
|
1005
|
+
const bind = name => {
|
|
1006
|
+
const post = coerce[name];
|
|
1007
|
+
store[name] = post
|
|
1008
|
+
? (...args) => Promise.resolve(impl[name](...args)).then(post)
|
|
1009
|
+
: (...args) => Promise.resolve(impl[name](...args));
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
for (const name of required) {
|
|
1013
|
+
bind(name);
|
|
1014
|
+
}
|
|
1015
|
+
for (const name of optional) {
|
|
1016
|
+
if (isFunction(impl[name])) {
|
|
1017
|
+
bind(name);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return store;
|
|
930
1021
|
};
|
|
931
1022
|
}
|
|
932
1023
|
|
|
1024
|
+
/**
|
|
1025
|
+
* Wrap a user-supplied store into the `ApiKeyStore` interface. Validates the
|
|
1026
|
+
* required methods at construction time (not on first use) and promotes sync
|
|
1027
|
+
* implementations to the async interface.
|
|
1028
|
+
*
|
|
1029
|
+
* Required: `put`, `getById`, `update`, `revoke`, `revokeAllForUser`, `listByUser`.
|
|
1030
|
+
*
|
|
1031
|
+
* @param {Partial<import('../index.js').ApiKeyStore>} impl
|
|
1032
|
+
* @returns {import('../index.js').ApiKeyStore}
|
|
1033
|
+
*/
|
|
1034
|
+
|
|
1035
|
+
const customStore = createCustomStoreValidator({
|
|
1036
|
+
required: ['put', 'getById', 'update', 'revoke', 'revokeAllForUser', 'listByUser'],
|
|
1037
|
+
wrap: invalidArgument,
|
|
1038
|
+
});
|
|
1039
|
+
|
|
933
1040
|
exports.customStore = customStore;
|
|
934
1041
|
exports.memoryStore = memoryStore;
|
|
935
1042
|
exports.redisStore = redisStore;
|