@exortek/apikey 1.0.0 → 1.0.2
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 +42 -32
- package/dist/index.cjs +22 -12
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +22 -12
- package/dist/middleware/core.d.ts +2 -2
- package/dist/middleware/express.cjs +22 -12
- package/dist/middleware/express.mjs +22 -12
- package/dist/middleware/fastify.cjs +109 -48
- package/dist/middleware/fastify.d.ts +1 -1
- package/dist/middleware/fastify.mjs +109 -48
- package/dist/stores.cjs +539 -325
- package/dist/stores.mjs +539 -325
- package/dist/token.d.ts +3 -3
- package/package.json +3 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,38 +1,48 @@
|
|
|
1
1
|
# @exortek/apikey
|
|
2
2
|
|
|
3
|
+
## 1.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 689cf87: Remove workspace-only `devDependencies` from published package.json — `@exortek/shared` no longer appears as
|
|
8
|
+
`"0.0.0"` on the npm registry.
|
|
9
|
+
- 689cf87: Replace `fastify-plugin` npm dependency with `@exortek/shared/fastify-plugin` — a built-in drop-in that
|
|
10
|
+
covers skip-override, display-name, plugin-meta, version constraints, and encapsulate. Users no longer need to
|
|
11
|
+
`npm i fastify-plugin` alongside fastify.
|
|
12
|
+
|
|
13
|
+
## 1.0.1
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 31223e4: Consolidate duplicated store internals into @exortek/shared utilities (redis-helpers, incr-store,
|
|
18
|
+
record-store). No public API changes.
|
|
19
|
+
|
|
20
|
+
apikey: fix Redis store race condition where a concurrent update() could silently un-revoke a key — revocations now
|
|
21
|
+
use a tombstone key that update() never touches.
|
|
22
|
+
|
|
23
|
+
apikey: fix memory store put() storing by reference instead of copying — now consistent with getById()'s copy-on-read
|
|
24
|
+
contract.
|
|
25
|
+
|
|
3
26
|
## 1.0.0
|
|
4
27
|
|
|
5
28
|
### Initial release
|
|
6
29
|
|
|
7
|
-
- **`createApiKey(options)`** — mint a Stripe-style 3-segment token
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
-
|
|
17
|
-
|
|
18
|
-
- **
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
-
|
|
25
|
-
|
|
26
|
-
- **Stores:** `memoryStore()` and `redisStore(client, options?)` under
|
|
27
|
-
the `@exortek/apikey/stores` subpath. Standard CRUD (`put` /
|
|
28
|
-
`getById` / `update` / `revoke` / `revokeAllForUser` / `listByUser`)
|
|
29
|
-
— bring your own by implementing the interface.
|
|
30
|
-
- **Middleware:** Express (`@exortek/apikey/middleware/express`) and
|
|
31
|
-
Fastify (`@exortek/apikey/middleware/fastify`) adapters over a
|
|
32
|
-
shared `middleware/core.js` — `Authorization: Bearer <key>` by
|
|
33
|
-
default, configurable header + raw scheme + opt-in query-param
|
|
34
|
-
fallback. Attaches the verify result to `req.apiKey`.
|
|
35
|
-
- **Errors:** `ApiKeyError` + `ErrorCode` catalogue
|
|
36
|
-
(`INVALID_ARGUMENT` / `INVALID_PREFIX` / `INVALID_PEPPER` /
|
|
37
|
-
`STORE_ERROR`). Expected verify failures surface as
|
|
38
|
-
`{ valid: false, reason }`, not exceptions.
|
|
30
|
+
- **`createApiKey(options)`** — mint a Stripe-style 3-segment token `<prefix>_<id>_<secret>` (base32-crockford, no
|
|
31
|
+
ambiguous glyphs). Stores an HMAC-SHA256 hash of the secret plus a plaintext id for O(1) lookup. Optional peppers
|
|
32
|
+
(newest-first array, each ≥16 bytes) turn a stolen DB into a non-crackable artifact.
|
|
33
|
+
- **`verifyApiKey(rawKey, options)`** — parse, look up by id, timing-safe hash compare, expiry / revocation / scope
|
|
34
|
+
enforcement. Returns `{ valid: true, userId, scopes, id, prefix, name?, environment?, metadata?, needsRehash? }` or
|
|
35
|
+
`{ valid: false, reason }` — never throws on a bad key.
|
|
36
|
+
- **Scopes:** `hasAll` / `hasAny` / `covers` with `*` super wildcard and `namespace:*` suffix wildcard.
|
|
37
|
+
- **Pepper rotation:** verify against every pepper, mint with the newest; `needsRehash: true` in the verify result
|
|
38
|
+
signals the secret matched an older pepper so `rehashApiKey` can silently migrate storage.
|
|
39
|
+
- **`revokeApiKey`** (by key or id), **`revokeAllForUser`**, **`listApiKeys`** (sorted most-recently-used first).
|
|
40
|
+
- **`mask(key)`** log-safe display, **`parseApiKey(key)`** unverified segment extraction.
|
|
41
|
+
- **Stores:** `memoryStore()` and `redisStore(client, options?)` under the `@exortek/apikey/stores` subpath. Standard
|
|
42
|
+
CRUD (`put` / `getById` / `update` / `revoke` / `revokeAllForUser` / `listByUser`) — bring your own by implementing
|
|
43
|
+
the interface.
|
|
44
|
+
- **Middleware:** Express (`@exortek/apikey/middleware/express`) and Fastify (`@exortek/apikey/middleware/fastify`)
|
|
45
|
+
adapters over a shared `middleware/core.js` — `Authorization: Bearer <key>` by default, configurable header + raw
|
|
46
|
+
scheme + opt-in query-param fallback. Attaches the verify result to `req.apiKey`.
|
|
47
|
+
- **Errors:** `ApiKeyError` + `ErrorCode` catalogue (`INVALID_ARGUMENT` / `INVALID_PREFIX` / `INVALID_PEPPER` /
|
|
48
|
+
`STORE_ERROR`). Expected verify failures surface as `{ valid: false, reason }`, not exceptions.
|
package/dist/index.cjs
CHANGED
|
@@ -946,11 +946,11 @@ function timingSafeEqual(a, b) {
|
|
|
946
946
|
*
|
|
947
947
|
* **Wire format:**
|
|
948
948
|
*
|
|
949
|
-
* <prefix>_<
|
|
950
|
-
* ^^^^^^
|
|
951
|
-
* type id (128-bit
|
|
949
|
+
* <prefix>_<crockford-base32(16 random bytes)>_<crockford-base32(32 random bytes)>
|
|
950
|
+
* ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
951
|
+
* type id (128-bit, 26 chars) secret (256-bit, 52 chars)
|
|
952
952
|
*
|
|
953
|
-
*
|
|
953
|
+
* sk_live_<id-26-chars>_<secret-52-chars>
|
|
954
954
|
*
|
|
955
955
|
* Stripe / GitHub / Anthropic all use this three-segment shape. The
|
|
956
956
|
* split matters:
|
|
@@ -1050,9 +1050,9 @@ function hashSecret(secretBytes, pepper) {
|
|
|
1050
1050
|
|
|
1051
1051
|
/**
|
|
1052
1052
|
* Timing-safe compare of a candidate hash against a stored hash. Both
|
|
1053
|
-
* inputs are base64url strings
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1053
|
+
* inputs are base64url strings. A length mismatch early-returns `false`
|
|
1054
|
+
* — all stored hashes are fixed-length (43-char SHA-256 digests), so
|
|
1055
|
+
* the length check leaks no information about the stored value.
|
|
1056
1056
|
*
|
|
1057
1057
|
* @param {string} candidate
|
|
1058
1058
|
* @param {string} stored
|
|
@@ -1093,15 +1093,25 @@ function parseApiKey(key) {
|
|
|
1093
1093
|
// secret use the base32-crockford alphabet which contains no
|
|
1094
1094
|
// underscores — so the last two '_' cleanly bound them.
|
|
1095
1095
|
const lastUnderscore = key.lastIndexOf('_');
|
|
1096
|
-
if (lastUnderscore <= 0) {
|
|
1096
|
+
if (lastUnderscore <= 0) {
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1097
1099
|
const prevUnderscore = key.lastIndexOf('_', lastUnderscore - 1);
|
|
1098
|
-
if (prevUnderscore <= 0) {
|
|
1100
|
+
if (prevUnderscore <= 0) {
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1099
1103
|
const prefix = key.slice(0, prevUnderscore);
|
|
1100
1104
|
const id = key.slice(prevUnderscore + 1, lastUnderscore);
|
|
1101
1105
|
const secret = key.slice(lastUnderscore + 1);
|
|
1102
|
-
if (!PREFIX_RE.test(prefix)) {
|
|
1103
|
-
|
|
1104
|
-
|
|
1106
|
+
if (!PREFIX_RE.test(prefix)) {
|
|
1107
|
+
return null;
|
|
1108
|
+
}
|
|
1109
|
+
if (id.length !== ID_CHARS || !CROCKFORD_RE.test(id)) {
|
|
1110
|
+
return null;
|
|
1111
|
+
}
|
|
1112
|
+
if (secret.length !== SECRET_CHARS || !CROCKFORD_RE.test(secret)) {
|
|
1113
|
+
return null;
|
|
1114
|
+
}
|
|
1105
1115
|
return { prefix, id, secret };
|
|
1106
1116
|
}
|
|
1107
1117
|
|
package/dist/index.d.ts
CHANGED
|
@@ -223,7 +223,7 @@ export type CreateApiKeyOptions = {
|
|
|
223
223
|
/**
|
|
224
224
|
* Newest first. Each ≥16 bytes. Omit for plain SHA-256 storage.
|
|
225
225
|
*/
|
|
226
|
-
peppers?:
|
|
226
|
+
peppers?: any[] | undefined;
|
|
227
227
|
/**
|
|
228
228
|
* Override `Date.now()` for testing.
|
|
229
229
|
*/
|
|
@@ -245,7 +245,7 @@ export type CreateApiKeyResult = {
|
|
|
245
245
|
};
|
|
246
246
|
export type VerifyApiKeyOptions = {
|
|
247
247
|
store: ApiKeyStore;
|
|
248
|
-
peppers?:
|
|
248
|
+
peppers?: any[] | undefined;
|
|
249
249
|
requiredScopes?: string[] | undefined;
|
|
250
250
|
/**
|
|
251
251
|
* Reject a valid key whose prefix differs.
|
package/dist/index.mjs
CHANGED
|
@@ -944,11 +944,11 @@ function timingSafeEqual(a, b) {
|
|
|
944
944
|
*
|
|
945
945
|
* **Wire format:**
|
|
946
946
|
*
|
|
947
|
-
* <prefix>_<
|
|
948
|
-
* ^^^^^^
|
|
949
|
-
* type id (128-bit
|
|
947
|
+
* <prefix>_<crockford-base32(16 random bytes)>_<crockford-base32(32 random bytes)>
|
|
948
|
+
* ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
949
|
+
* type id (128-bit, 26 chars) secret (256-bit, 52 chars)
|
|
950
950
|
*
|
|
951
|
-
*
|
|
951
|
+
* sk_live_<id-26-chars>_<secret-52-chars>
|
|
952
952
|
*
|
|
953
953
|
* Stripe / GitHub / Anthropic all use this three-segment shape. The
|
|
954
954
|
* split matters:
|
|
@@ -1048,9 +1048,9 @@ function hashSecret(secretBytes, pepper) {
|
|
|
1048
1048
|
|
|
1049
1049
|
/**
|
|
1050
1050
|
* Timing-safe compare of a candidate hash against a stored hash. Both
|
|
1051
|
-
* inputs are base64url strings
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1051
|
+
* inputs are base64url strings. A length mismatch early-returns `false`
|
|
1052
|
+
* — all stored hashes are fixed-length (43-char SHA-256 digests), so
|
|
1053
|
+
* the length check leaks no information about the stored value.
|
|
1054
1054
|
*
|
|
1055
1055
|
* @param {string} candidate
|
|
1056
1056
|
* @param {string} stored
|
|
@@ -1091,15 +1091,25 @@ function parseApiKey(key) {
|
|
|
1091
1091
|
// secret use the base32-crockford alphabet which contains no
|
|
1092
1092
|
// underscores — so the last two '_' cleanly bound them.
|
|
1093
1093
|
const lastUnderscore = key.lastIndexOf('_');
|
|
1094
|
-
if (lastUnderscore <= 0) {
|
|
1094
|
+
if (lastUnderscore <= 0) {
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1095
1097
|
const prevUnderscore = key.lastIndexOf('_', lastUnderscore - 1);
|
|
1096
|
-
if (prevUnderscore <= 0) {
|
|
1098
|
+
if (prevUnderscore <= 0) {
|
|
1099
|
+
return null;
|
|
1100
|
+
}
|
|
1097
1101
|
const prefix = key.slice(0, prevUnderscore);
|
|
1098
1102
|
const id = key.slice(prevUnderscore + 1, lastUnderscore);
|
|
1099
1103
|
const secret = key.slice(lastUnderscore + 1);
|
|
1100
|
-
if (!PREFIX_RE.test(prefix)) {
|
|
1101
|
-
|
|
1102
|
-
|
|
1104
|
+
if (!PREFIX_RE.test(prefix)) {
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
if (id.length !== ID_CHARS || !CROCKFORD_RE.test(id)) {
|
|
1108
|
+
return null;
|
|
1109
|
+
}
|
|
1110
|
+
if (secret.length !== SECRET_CHARS || !CROCKFORD_RE.test(secret)) {
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1103
1113
|
return { prefix, id, secret };
|
|
1104
1114
|
}
|
|
1105
1115
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export function normalizeOptions(options: ApiKeyMiddlewareOptions): {
|
|
8
8
|
store: import("../index.js").ApiKeyStore;
|
|
9
|
-
peppers:
|
|
9
|
+
peppers: any[] | undefined;
|
|
10
10
|
requiredScopes: string[] | undefined;
|
|
11
11
|
expectedPrefix: string | undefined;
|
|
12
12
|
updateLastUsed: boolean;
|
|
@@ -45,7 +45,7 @@ export type AdapterContext = {
|
|
|
45
45
|
};
|
|
46
46
|
export type ApiKeyMiddlewareOptions = {
|
|
47
47
|
store: import("../index.js").ApiKeyStore;
|
|
48
|
-
peppers?:
|
|
48
|
+
peppers?: any[] | undefined;
|
|
49
49
|
requiredScopes?: string[] | undefined;
|
|
50
50
|
expectedPrefix?: string | undefined;
|
|
51
51
|
updateLastUsed?: boolean | undefined;
|
|
@@ -787,11 +787,11 @@ function timingSafeEqual(a, b) {
|
|
|
787
787
|
*
|
|
788
788
|
* **Wire format:**
|
|
789
789
|
*
|
|
790
|
-
* <prefix>_<
|
|
791
|
-
* ^^^^^^
|
|
792
|
-
* type id (128-bit
|
|
790
|
+
* <prefix>_<crockford-base32(16 random bytes)>_<crockford-base32(32 random bytes)>
|
|
791
|
+
* ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
792
|
+
* type id (128-bit, 26 chars) secret (256-bit, 52 chars)
|
|
793
793
|
*
|
|
794
|
-
*
|
|
794
|
+
* sk_live_<id-26-chars>_<secret-52-chars>
|
|
795
795
|
*
|
|
796
796
|
* Stripe / GitHub / Anthropic all use this three-segment shape. The
|
|
797
797
|
* split matters:
|
|
@@ -871,9 +871,9 @@ function hashSecret(secretBytes, pepper) {
|
|
|
871
871
|
|
|
872
872
|
/**
|
|
873
873
|
* Timing-safe compare of a candidate hash against a stored hash. Both
|
|
874
|
-
* inputs are base64url strings
|
|
875
|
-
*
|
|
876
|
-
*
|
|
874
|
+
* inputs are base64url strings. A length mismatch early-returns `false`
|
|
875
|
+
* — all stored hashes are fixed-length (43-char SHA-256 digests), so
|
|
876
|
+
* the length check leaks no information about the stored value.
|
|
877
877
|
*
|
|
878
878
|
* @param {string} candidate
|
|
879
879
|
* @param {string} stored
|
|
@@ -914,15 +914,25 @@ function parseApiKey(key) {
|
|
|
914
914
|
// secret use the base32-crockford alphabet which contains no
|
|
915
915
|
// underscores — so the last two '_' cleanly bound them.
|
|
916
916
|
const lastUnderscore = key.lastIndexOf('_');
|
|
917
|
-
if (lastUnderscore <= 0) {
|
|
917
|
+
if (lastUnderscore <= 0) {
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
918
920
|
const prevUnderscore = key.lastIndexOf('_', lastUnderscore - 1);
|
|
919
|
-
if (prevUnderscore <= 0) {
|
|
921
|
+
if (prevUnderscore <= 0) {
|
|
922
|
+
return null;
|
|
923
|
+
}
|
|
920
924
|
const prefix = key.slice(0, prevUnderscore);
|
|
921
925
|
const id = key.slice(prevUnderscore + 1, lastUnderscore);
|
|
922
926
|
const secret = key.slice(lastUnderscore + 1);
|
|
923
|
-
if (!PREFIX_RE.test(prefix)) {
|
|
924
|
-
|
|
925
|
-
|
|
927
|
+
if (!PREFIX_RE.test(prefix)) {
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
if (id.length !== ID_CHARS || !CROCKFORD_RE.test(id)) {
|
|
931
|
+
return null;
|
|
932
|
+
}
|
|
933
|
+
if (secret.length !== SECRET_CHARS || !CROCKFORD_RE.test(secret)) {
|
|
934
|
+
return null;
|
|
935
|
+
}
|
|
926
936
|
return { prefix, id, secret };
|
|
927
937
|
}
|
|
928
938
|
|
|
@@ -785,11 +785,11 @@ function timingSafeEqual(a, b) {
|
|
|
785
785
|
*
|
|
786
786
|
* **Wire format:**
|
|
787
787
|
*
|
|
788
|
-
* <prefix>_<
|
|
789
|
-
* ^^^^^^
|
|
790
|
-
* type id (128-bit
|
|
788
|
+
* <prefix>_<crockford-base32(16 random bytes)>_<crockford-base32(32 random bytes)>
|
|
789
|
+
* ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
790
|
+
* type id (128-bit, 26 chars) secret (256-bit, 52 chars)
|
|
791
791
|
*
|
|
792
|
-
*
|
|
792
|
+
* sk_live_<id-26-chars>_<secret-52-chars>
|
|
793
793
|
*
|
|
794
794
|
* Stripe / GitHub / Anthropic all use this three-segment shape. The
|
|
795
795
|
* split matters:
|
|
@@ -869,9 +869,9 @@ function hashSecret(secretBytes, pepper) {
|
|
|
869
869
|
|
|
870
870
|
/**
|
|
871
871
|
* Timing-safe compare of a candidate hash against a stored hash. Both
|
|
872
|
-
* inputs are base64url strings
|
|
873
|
-
*
|
|
874
|
-
*
|
|
872
|
+
* inputs are base64url strings. A length mismatch early-returns `false`
|
|
873
|
+
* — all stored hashes are fixed-length (43-char SHA-256 digests), so
|
|
874
|
+
* the length check leaks no information about the stored value.
|
|
875
875
|
*
|
|
876
876
|
* @param {string} candidate
|
|
877
877
|
* @param {string} stored
|
|
@@ -912,15 +912,25 @@ function parseApiKey(key) {
|
|
|
912
912
|
// secret use the base32-crockford alphabet which contains no
|
|
913
913
|
// underscores — so the last two '_' cleanly bound them.
|
|
914
914
|
const lastUnderscore = key.lastIndexOf('_');
|
|
915
|
-
if (lastUnderscore <= 0) {
|
|
915
|
+
if (lastUnderscore <= 0) {
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
916
918
|
const prevUnderscore = key.lastIndexOf('_', lastUnderscore - 1);
|
|
917
|
-
if (prevUnderscore <= 0) {
|
|
919
|
+
if (prevUnderscore <= 0) {
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
918
922
|
const prefix = key.slice(0, prevUnderscore);
|
|
919
923
|
const id = key.slice(prevUnderscore + 1, lastUnderscore);
|
|
920
924
|
const secret = key.slice(lastUnderscore + 1);
|
|
921
|
-
if (!PREFIX_RE.test(prefix)) {
|
|
922
|
-
|
|
923
|
-
|
|
925
|
+
if (!PREFIX_RE.test(prefix)) {
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
if (id.length !== ID_CHARS || !CROCKFORD_RE.test(id)) {
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
if (secret.length !== SECRET_CHARS || !CROCKFORD_RE.test(secret)) {
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
924
934
|
return { prefix, id, secret };
|
|
925
935
|
}
|
|
926
936
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var fp = require('fastify-plugin');
|
|
4
3
|
var node_crypto = require('node:crypto');
|
|
5
4
|
|
|
6
5
|
/**
|
|
@@ -131,6 +130,67 @@ function isBuffer(value) {
|
|
|
131
130
|
return Buffer.isBuffer(value);
|
|
132
131
|
}
|
|
133
132
|
|
|
133
|
+
let counter = 0;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Mark a function as a Fastify plugin with encapsulation bypass.
|
|
137
|
+
*
|
|
138
|
+
* Drop-in replacement for the `fastify-plugin` npm package — covers
|
|
139
|
+
* the same symbols and option handling without the external dependency.
|
|
140
|
+
*
|
|
141
|
+
* @param {Function} fn Plugin function `(fastify, opts) => …`
|
|
142
|
+
* @param {string | PluginOptions} [options]
|
|
143
|
+
* A semver range string (shorthand for `{ fastify: range }`) or a
|
|
144
|
+
* full options object.
|
|
145
|
+
* @returns {Function} The same function, decorated with plugin metadata.
|
|
146
|
+
*
|
|
147
|
+
* @typedef {object} PluginOptions
|
|
148
|
+
* @property {string} [name] Display name. Auto-generated from
|
|
149
|
+
* `fn.name` when omitted.
|
|
150
|
+
* @property {string} [fastify] Semver range the plugin is compatible
|
|
151
|
+
* with (e.g. `'>=4'`). Fastify checks
|
|
152
|
+
* this at register time.
|
|
153
|
+
* @property {string[]} [decorators] Decorators the plugin expects to
|
|
154
|
+
* exist before it runs.
|
|
155
|
+
* @property {string[]} [dependencies] Plugins that must be registered
|
|
156
|
+
* before this one.
|
|
157
|
+
* @property {boolean} [encapsulate] When `true`, the plugin stays
|
|
158
|
+
* encapsulated (skip-override = false).
|
|
159
|
+
* Default `false` (skip override).
|
|
160
|
+
*/
|
|
161
|
+
function fastifyPlugin(fn, options) {
|
|
162
|
+
if (fn && fn.default !== undefined) {
|
|
163
|
+
fn = fn.default;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!isFunction(fn)) {
|
|
167
|
+
throw new TypeError(`fastifyPlugin expects a function, instead got '${typeof fn}'`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let opts;
|
|
171
|
+
if (isString(options)) {
|
|
172
|
+
opts = { fastify: options };
|
|
173
|
+
} else if (isObject(options)) {
|
|
174
|
+
opts = { ...options };
|
|
175
|
+
} else {
|
|
176
|
+
opts = {};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!opts.name) {
|
|
180
|
+
opts.name = (fn.name || 'anonymous') + '-auto-' + counter++;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
fn[Symbol.for('skip-override')] = opts.encapsulate !== true;
|
|
184
|
+
fn[Symbol.for('fastify.display-name')] = opts.name;
|
|
185
|
+
fn[Symbol.for('plugin-meta')] = opts;
|
|
186
|
+
|
|
187
|
+
if (!fn.default) {
|
|
188
|
+
fn.default = fn;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return fn;
|
|
192
|
+
}
|
|
193
|
+
|
|
134
194
|
/**
|
|
135
195
|
* Imperative single-argument guard helpers — the everyday
|
|
136
196
|
* `assertPositiveInt(x, 'name')` shape used at API boundaries.
|
|
@@ -788,11 +848,11 @@ function timingSafeEqual(a, b) {
|
|
|
788
848
|
*
|
|
789
849
|
* **Wire format:**
|
|
790
850
|
*
|
|
791
|
-
* <prefix>_<
|
|
792
|
-
* ^^^^^^
|
|
793
|
-
* type id (128-bit
|
|
851
|
+
* <prefix>_<crockford-base32(16 random bytes)>_<crockford-base32(32 random bytes)>
|
|
852
|
+
* ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
853
|
+
* type id (128-bit, 26 chars) secret (256-bit, 52 chars)
|
|
794
854
|
*
|
|
795
|
-
*
|
|
855
|
+
* sk_live_<id-26-chars>_<secret-52-chars>
|
|
796
856
|
*
|
|
797
857
|
* Stripe / GitHub / Anthropic all use this three-segment shape. The
|
|
798
858
|
* split matters:
|
|
@@ -872,9 +932,9 @@ function hashSecret(secretBytes, pepper) {
|
|
|
872
932
|
|
|
873
933
|
/**
|
|
874
934
|
* Timing-safe compare of a candidate hash against a stored hash. Both
|
|
875
|
-
* inputs are base64url strings
|
|
876
|
-
*
|
|
877
|
-
*
|
|
935
|
+
* inputs are base64url strings. A length mismatch early-returns `false`
|
|
936
|
+
* — all stored hashes are fixed-length (43-char SHA-256 digests), so
|
|
937
|
+
* the length check leaks no information about the stored value.
|
|
878
938
|
*
|
|
879
939
|
* @param {string} candidate
|
|
880
940
|
* @param {string} stored
|
|
@@ -915,15 +975,25 @@ function parseApiKey(key) {
|
|
|
915
975
|
// secret use the base32-crockford alphabet which contains no
|
|
916
976
|
// underscores — so the last two '_' cleanly bound them.
|
|
917
977
|
const lastUnderscore = key.lastIndexOf('_');
|
|
918
|
-
if (lastUnderscore <= 0) {
|
|
978
|
+
if (lastUnderscore <= 0) {
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
919
981
|
const prevUnderscore = key.lastIndexOf('_', lastUnderscore - 1);
|
|
920
|
-
if (prevUnderscore <= 0) {
|
|
982
|
+
if (prevUnderscore <= 0) {
|
|
983
|
+
return null;
|
|
984
|
+
}
|
|
921
985
|
const prefix = key.slice(0, prevUnderscore);
|
|
922
986
|
const id = key.slice(prevUnderscore + 1, lastUnderscore);
|
|
923
987
|
const secret = key.slice(lastUnderscore + 1);
|
|
924
|
-
if (!PREFIX_RE.test(prefix)) {
|
|
925
|
-
|
|
926
|
-
|
|
988
|
+
if (!PREFIX_RE.test(prefix)) {
|
|
989
|
+
return null;
|
|
990
|
+
}
|
|
991
|
+
if (id.length !== ID_CHARS || !CROCKFORD_RE.test(id)) {
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
if (secret.length !== SECRET_CHARS || !CROCKFORD_RE.test(secret)) {
|
|
995
|
+
return null;
|
|
996
|
+
}
|
|
927
997
|
return { prefix, id, secret };
|
|
928
998
|
}
|
|
929
999
|
|
|
@@ -1330,45 +1400,36 @@ function _statusFor(reason) {
|
|
|
1330
1400
|
*
|
|
1331
1401
|
* app.get('/v1/whoami', async req => req.apiKey);
|
|
1332
1402
|
*
|
|
1333
|
-
*
|
|
1334
|
-
* `req.apiKey` decorator
|
|
1335
|
-
*
|
|
1336
|
-
*
|
|
1337
|
-
* which route group needs an API key — the usual Fastify pattern for
|
|
1338
|
-
* "protect only these routes with auth".
|
|
1339
|
-
*
|
|
1340
|
-
* `fastify-plugin` is an **optional peer** — install it with
|
|
1341
|
-
* `npm i fastify fastify-plugin` (or `yarn add`) alongside the fastify
|
|
1342
|
-
* runtime. Not a hard dependency because Express-only consumers of
|
|
1343
|
-
* `@exortek/apikey` should not have to install it.
|
|
1403
|
+
* The plugin bypasses Fastify's encapsulation so the `preHandler` hook
|
|
1404
|
+
* and the `req.apiKey` decorator apply to routes in the parent scope.
|
|
1405
|
+
* Register inside a scoped `app.register(async scope => …)` block to
|
|
1406
|
+
* control which route group needs an API key.
|
|
1344
1407
|
*/
|
|
1345
1408
|
|
|
1346
1409
|
|
|
1347
|
-
|
|
1348
|
-
|
|
1410
|
+
const apiKeyPlugin = fastifyPlugin(
|
|
1411
|
+
async function apiKeyPluginFn(app, options) {
|
|
1412
|
+
const config = normalizeOptions(options);
|
|
1349
1413
|
|
|
1350
|
-
|
|
1414
|
+
app.decorateRequest(config.attach, null);
|
|
1351
1415
|
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
}
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
fastify: '4.x || 5.x',
|
|
1371
|
-
name: '@exortek/apikey',
|
|
1372
|
-
});
|
|
1416
|
+
app.addHook('preHandler', async (req, reply) => {
|
|
1417
|
+
const ctx = {
|
|
1418
|
+
getHeader: name => req.headers?.[name],
|
|
1419
|
+
method: req.method,
|
|
1420
|
+
ip: req.ip,
|
|
1421
|
+
query: req.query,
|
|
1422
|
+
};
|
|
1423
|
+
const result = await runApiKey(ctx, config);
|
|
1424
|
+
if (result.response) {
|
|
1425
|
+
reply.code(result.response.status).send(result.response.body);
|
|
1426
|
+
return reply;
|
|
1427
|
+
}
|
|
1428
|
+
req[config.attach] = result.verifyResult;
|
|
1429
|
+
return undefined;
|
|
1430
|
+
});
|
|
1431
|
+
},
|
|
1432
|
+
{ name: '@exortek/apikey' },
|
|
1433
|
+
);
|
|
1373
1434
|
|
|
1374
1435
|
exports.apiKeyPlugin = apiKeyPlugin;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export const apiKeyPlugin: Function;
|