@tumbaland/backend-core 1.29.0 → 1.31.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/dist/apiKeys/ApiKey.d.ts +51 -0
- package/dist/apiKeys/ApiKey.d.ts.map +1 -0
- package/dist/apiKeys/ApiKey.js +63 -0
- package/dist/apiKeys/ApiKey.js.map +1 -0
- package/dist/apiKeys/crypto.d.ts +61 -0
- package/dist/apiKeys/crypto.d.ts.map +1 -0
- package/dist/apiKeys/crypto.js +132 -0
- package/dist/apiKeys/crypto.js.map +1 -0
- package/dist/apiKeys/index.d.ts +10 -0
- package/dist/apiKeys/index.d.ts.map +1 -0
- package/dist/apiKeys/index.js +25 -0
- package/dist/apiKeys/index.js.map +1 -0
- package/dist/apiKeys/middleware.d.ts +54 -0
- package/dist/apiKeys/middleware.d.ts.map +1 -0
- package/dist/apiKeys/middleware.js +174 -0
- package/dist/apiKeys/middleware.js.map +1 -0
- package/dist/apiKeys/service.d.ts +43 -0
- package/dist/apiKeys/service.d.ts.map +1 -0
- package/dist/apiKeys/service.js +122 -0
- package/dist/apiKeys/service.js.map +1 -0
- package/dist/apiKeys/types.d.ts +50 -0
- package/dist/apiKeys/types.d.ts.map +1 -0
- package/dist/apiKeys/types.js +24 -0
- package/dist/apiKeys/types.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/apiKeys/ApiKey.ts +75 -0
- package/src/apiKeys/crypto.test.ts +161 -0
- package/src/apiKeys/crypto.ts +163 -0
- package/src/apiKeys/index.ts +21 -0
- package/src/apiKeys/middleware.test.ts +254 -0
- package/src/apiKeys/middleware.ts +206 -0
- package/src/apiKeys/service.ts +149 -0
- package/src/apiKeys/types.ts +69 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ApiKeyScope, ApiKeySummary, ApiKeyVerification } from './types';
|
|
2
|
+
export interface CreateApiKeyInput {
|
|
3
|
+
userId: string;
|
|
4
|
+
userEmail: string;
|
|
5
|
+
userName: string;
|
|
6
|
+
name: string;
|
|
7
|
+
scopes: ApiKeyScope[];
|
|
8
|
+
/** null pins the key to the user's own non-group data */
|
|
9
|
+
groupId?: string | null;
|
|
10
|
+
expiresAt?: Date | null;
|
|
11
|
+
}
|
|
12
|
+
export interface CreatedApiKey {
|
|
13
|
+
summary: ApiKeySummary;
|
|
14
|
+
/** the full token — the only time it is returned without an explicit reveal */
|
|
15
|
+
token: string;
|
|
16
|
+
}
|
|
17
|
+
export declare const createApiKey: (input: CreateApiKeyInput) => Promise<CreatedApiKey>;
|
|
18
|
+
export declare const listApiKeys: (userId: string) => Promise<ApiKeySummary[]>;
|
|
19
|
+
/**
|
|
20
|
+
* Read a key back in the clear.
|
|
21
|
+
*
|
|
22
|
+
* Scoped to the owner by query rather than by a check afterwards, so there is no
|
|
23
|
+
* path where a mismatched userId still reaches the decrypt. Revoked keys are
|
|
24
|
+
* still revealable — the user may need to see which key they just turned off.
|
|
25
|
+
*/
|
|
26
|
+
export declare const revealApiKey: (userId: string, id: string) => Promise<string | null>;
|
|
27
|
+
/** Revoking is a tombstone, not a delete: the audit trail outlives the key. */
|
|
28
|
+
export declare const revokeApiKey: (userId: string, id: string) => Promise<ApiKeySummary | null>;
|
|
29
|
+
export declare const deleteApiKey: (userId: string, id: string) => Promise<boolean>;
|
|
30
|
+
/**
|
|
31
|
+
* Check a presented token.
|
|
32
|
+
*
|
|
33
|
+
* Every failure returns the same shape and the caller answers all of them with
|
|
34
|
+
* one message: distinguishing "no such key" from "wrong secret" to the client
|
|
35
|
+
* would confirm which half of a guess was right. The `rejection` field exists
|
|
36
|
+
* for the server's own logs.
|
|
37
|
+
*
|
|
38
|
+
* `lastUsedAt` is written on the side and never awaited — it is a convenience
|
|
39
|
+
* for the listing UI, and making every authenticated request wait on a write to
|
|
40
|
+
* maintain it would be a poor trade.
|
|
41
|
+
*/
|
|
42
|
+
export declare const verifyApiKey: (token: unknown) => Promise<ApiKeyVerification>;
|
|
43
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/apiKeys/service.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAE9E,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;CACzB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,aAAa,CAAC;IACvB,+EAA+E;IAC/E,KAAK,EAAE,MAAM,CAAC;CACf;AAcD,eAAO,MAAM,YAAY,GAAU,OAAO,iBAAiB,KAAG,OAAO,CAAC,aAAa,CAoBlF,CAAC;AAEF,eAAO,MAAM,WAAW,GAAU,QAAQ,MAAM,KAAG,OAAO,CAAC,aAAa,EAAE,CAGzE,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,GAAU,QAAQ,MAAM,EAAE,IAAI,MAAM,KAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAepF,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,YAAY,GAAU,QAAQ,MAAM,EAAE,IAAI,MAAM,KAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAU3F,CAAC;AAEF,eAAO,MAAM,YAAY,GAAU,QAAQ,MAAM,EAAE,IAAI,MAAM,KAAG,OAAO,CAAC,OAAO,CAG9E,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,YAAY,GAAU,OAAO,OAAO,KAAG,OAAO,CAAC,kBAAkB,CA2B7E,CAAC"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.verifyApiKey = exports.deleteApiKey = exports.revokeApiKey = exports.revealApiKey = exports.listApiKeys = exports.createApiKey = void 0;
|
|
4
|
+
const ApiKey_1 = require("./ApiKey");
|
|
5
|
+
const crypto_1 = require("./crypto");
|
|
6
|
+
const toSummary = (key) => ({
|
|
7
|
+
id: String(key._id),
|
|
8
|
+
name: key.name,
|
|
9
|
+
prefix: (0, crypto_1.displayPrefix)(key.keyId),
|
|
10
|
+
scopes: key.scopes,
|
|
11
|
+
groupId: key.groupId ?? null,
|
|
12
|
+
createdAt: key.createdAt.toISOString(),
|
|
13
|
+
lastUsedAt: key.lastUsedAt?.toISOString() ?? null,
|
|
14
|
+
expiresAt: key.expiresAt?.toISOString() ?? null,
|
|
15
|
+
revokedAt: key.revokedAt?.toISOString() ?? null
|
|
16
|
+
});
|
|
17
|
+
const createApiKey = async (input) => {
|
|
18
|
+
const { token, id, hash } = (0, crypto_1.generateKey)();
|
|
19
|
+
const sealed = (0, crypto_1.encryptSecret)(token);
|
|
20
|
+
const key = await ApiKey_1.ApiKey.create({
|
|
21
|
+
userId: input.userId,
|
|
22
|
+
userEmail: input.userEmail,
|
|
23
|
+
userName: input.userName,
|
|
24
|
+
name: input.name,
|
|
25
|
+
keyId: id,
|
|
26
|
+
hash,
|
|
27
|
+
sealedCiphertext: sealed.ciphertext,
|
|
28
|
+
sealedIv: sealed.iv,
|
|
29
|
+
sealedTag: sealed.tag,
|
|
30
|
+
scopes: input.scopes,
|
|
31
|
+
groupId: input.groupId ?? null,
|
|
32
|
+
expiresAt: input.expiresAt ?? undefined
|
|
33
|
+
});
|
|
34
|
+
return { summary: toSummary(key), token };
|
|
35
|
+
};
|
|
36
|
+
exports.createApiKey = createApiKey;
|
|
37
|
+
const listApiKeys = async (userId) => {
|
|
38
|
+
const keys = await ApiKey_1.ApiKey.find({ userId }).sort({ createdAt: -1 });
|
|
39
|
+
return keys.map(toSummary);
|
|
40
|
+
};
|
|
41
|
+
exports.listApiKeys = listApiKeys;
|
|
42
|
+
/**
|
|
43
|
+
* Read a key back in the clear.
|
|
44
|
+
*
|
|
45
|
+
* Scoped to the owner by query rather than by a check afterwards, so there is no
|
|
46
|
+
* path where a mismatched userId still reaches the decrypt. Revoked keys are
|
|
47
|
+
* still revealable — the user may need to see which key they just turned off.
|
|
48
|
+
*/
|
|
49
|
+
const revealApiKey = async (userId, id) => {
|
|
50
|
+
const key = await ApiKey_1.ApiKey.findOne({ _id: id, userId });
|
|
51
|
+
if (!key)
|
|
52
|
+
return null;
|
|
53
|
+
const token = (0, crypto_1.decryptSecret)({
|
|
54
|
+
ciphertext: key.sealedCiphertext,
|
|
55
|
+
iv: key.sealedIv,
|
|
56
|
+
tag: key.sealedTag
|
|
57
|
+
});
|
|
58
|
+
key.revealCount += 1;
|
|
59
|
+
key.lastRevealedAt = new Date();
|
|
60
|
+
await key.save();
|
|
61
|
+
return token;
|
|
62
|
+
};
|
|
63
|
+
exports.revealApiKey = revealApiKey;
|
|
64
|
+
/** Revoking is a tombstone, not a delete: the audit trail outlives the key. */
|
|
65
|
+
const revokeApiKey = async (userId, id) => {
|
|
66
|
+
const key = await ApiKey_1.ApiKey.findOne({ _id: id, userId });
|
|
67
|
+
if (!key)
|
|
68
|
+
return null;
|
|
69
|
+
if (!key.revokedAt) {
|
|
70
|
+
key.revokedAt = new Date();
|
|
71
|
+
await key.save();
|
|
72
|
+
}
|
|
73
|
+
return toSummary(key);
|
|
74
|
+
};
|
|
75
|
+
exports.revokeApiKey = revokeApiKey;
|
|
76
|
+
const deleteApiKey = async (userId, id) => {
|
|
77
|
+
const { deletedCount } = await ApiKey_1.ApiKey.deleteOne({ _id: id, userId });
|
|
78
|
+
return deletedCount > 0;
|
|
79
|
+
};
|
|
80
|
+
exports.deleteApiKey = deleteApiKey;
|
|
81
|
+
/**
|
|
82
|
+
* Check a presented token.
|
|
83
|
+
*
|
|
84
|
+
* Every failure returns the same shape and the caller answers all of them with
|
|
85
|
+
* one message: distinguishing "no such key" from "wrong secret" to the client
|
|
86
|
+
* would confirm which half of a guess was right. The `rejection` field exists
|
|
87
|
+
* for the server's own logs.
|
|
88
|
+
*
|
|
89
|
+
* `lastUsedAt` is written on the side and never awaited — it is a convenience
|
|
90
|
+
* for the listing UI, and making every authenticated request wait on a write to
|
|
91
|
+
* maintain it would be a poor trade.
|
|
92
|
+
*/
|
|
93
|
+
const verifyApiKey = async (token) => {
|
|
94
|
+
const parsed = (0, crypto_1.parseKey)(token);
|
|
95
|
+
if (!parsed)
|
|
96
|
+
return { ok: false, rejection: 'malformed' };
|
|
97
|
+
const key = await ApiKey_1.ApiKey.findOne({ keyId: parsed.id });
|
|
98
|
+
if (!key)
|
|
99
|
+
return { ok: false, rejection: 'unknown' };
|
|
100
|
+
if (key.revokedAt)
|
|
101
|
+
return { ok: false, rejection: 'revoked' };
|
|
102
|
+
if (key.expiresAt && key.expiresAt.getTime() <= Date.now()) {
|
|
103
|
+
return { ok: false, rejection: 'expired' };
|
|
104
|
+
}
|
|
105
|
+
if (!(0, crypto_1.secretMatches)(parsed.secret, key.hash)) {
|
|
106
|
+
return { ok: false, rejection: 'bad-secret' };
|
|
107
|
+
}
|
|
108
|
+
void ApiKey_1.ApiKey.updateOne({ _id: key._id }, { $set: { lastUsedAt: new Date() } }).catch(() => {
|
|
109
|
+
// A missed usage timestamp must never fail an otherwise valid request.
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
userId: key.userId,
|
|
114
|
+
userEmail: key.userEmail,
|
|
115
|
+
userName: key.userName,
|
|
116
|
+
keyId: String(key._id),
|
|
117
|
+
scopes: key.scopes,
|
|
118
|
+
groupId: key.groupId ?? null
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
exports.verifyApiKey = verifyApiKey;
|
|
122
|
+
//# sourceMappingURL=service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.js","sourceRoot":"","sources":["../../src/apiKeys/service.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API keys let a non-browser client — an MCP server driving Claude or ChatGPT,
|
|
3
|
+
* a script, a cron job — act as a user without a Google sign-in flow.
|
|
4
|
+
*
|
|
5
|
+
* They are deliberately narrower than a session: a session is a person at a
|
|
6
|
+
* keyboard who can see what they are doing, while a key is handed to software
|
|
7
|
+
* that acts on its own. So a key carries explicit scopes, and it pins the tenant
|
|
8
|
+
* it writes to rather than choosing one per request the way the UI does.
|
|
9
|
+
*/
|
|
10
|
+
/** Everything a key may be granted. Read and write are separate on purpose. */
|
|
11
|
+
export declare const API_KEY_SCOPES: readonly ["relationship:read", "relationship:write", "album:read", "album:write", "finance:read", "finance:write"];
|
|
12
|
+
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
|
13
|
+
export declare const isApiKeyScope: (value: unknown) => value is ApiKeyScope;
|
|
14
|
+
/**
|
|
15
|
+
* The tenant a key acts in, fixed when the key is created.
|
|
16
|
+
*
|
|
17
|
+
* The web app picks this per request from a tenant selector, but an agent has no
|
|
18
|
+
* such UI and no way to know it guessed wrong — so the choice is made once, by a
|
|
19
|
+
* person, and the key cannot escape it. `groupId: null` means the user's own
|
|
20
|
+
* non-group data.
|
|
21
|
+
*/
|
|
22
|
+
export interface ApiKeyTenant {
|
|
23
|
+
groupId: string | null;
|
|
24
|
+
}
|
|
25
|
+
/** A key as the API hands it back — never including the secret. */
|
|
26
|
+
export interface ApiKeySummary {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
/** the public half, shown in listings so a key is identifiable at a glance */
|
|
30
|
+
prefix: string;
|
|
31
|
+
scopes: ApiKeyScope[];
|
|
32
|
+
groupId: string | null;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
lastUsedAt: string | null;
|
|
35
|
+
expiresAt: string | null;
|
|
36
|
+
revokedAt: string | null;
|
|
37
|
+
}
|
|
38
|
+
/** Why a presented key was refused. Kept out of the HTTP response — see the middleware. */
|
|
39
|
+
export type ApiKeyRejection = 'malformed' | 'unknown' | 'revoked' | 'expired' | 'bad-secret';
|
|
40
|
+
export interface ApiKeyVerification {
|
|
41
|
+
ok: boolean;
|
|
42
|
+
rejection?: ApiKeyRejection;
|
|
43
|
+
userId?: string;
|
|
44
|
+
userEmail?: string;
|
|
45
|
+
userName?: string;
|
|
46
|
+
keyId?: string;
|
|
47
|
+
scopes?: ApiKeyScope[];
|
|
48
|
+
groupId?: string | null;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/apiKeys/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,+EAA+E;AAC/E,eAAO,MAAM,cAAc,oHAOjB,CAAC;AAEX,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC;AAE1D,eAAO,MAAM,aAAa,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,WAC4B,CAAC;AAErF;;;;;;;GAOG;AACH,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,mEAAmE;AACnE,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,8EAA8E;IAC9E,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,2FAA2F;AAC3F,MAAM,MAAM,eAAe,GACvB,WAAW,GACX,SAAS,GACT,SAAS,GACT,SAAS,GACT,YAAY,CAAC;AAEjB,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,OAAO,CAAC;IACZ,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,WAAW,EAAE,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* API keys let a non-browser client — an MCP server driving Claude or ChatGPT,
|
|
4
|
+
* a script, a cron job — act as a user without a Google sign-in flow.
|
|
5
|
+
*
|
|
6
|
+
* They are deliberately narrower than a session: a session is a person at a
|
|
7
|
+
* keyboard who can see what they are doing, while a key is handed to software
|
|
8
|
+
* that acts on its own. So a key carries explicit scopes, and it pins the tenant
|
|
9
|
+
* it writes to rather than choosing one per request the way the UI does.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.isApiKeyScope = exports.API_KEY_SCOPES = void 0;
|
|
13
|
+
/** Everything a key may be granted. Read and write are separate on purpose. */
|
|
14
|
+
exports.API_KEY_SCOPES = [
|
|
15
|
+
'relationship:read',
|
|
16
|
+
'relationship:write',
|
|
17
|
+
'album:read',
|
|
18
|
+
'album:write',
|
|
19
|
+
'finance:read',
|
|
20
|
+
'finance:write'
|
|
21
|
+
];
|
|
22
|
+
const isApiKeyScope = (value) => typeof value === 'string' && exports.API_KEY_SCOPES.includes(value);
|
|
23
|
+
exports.isApiKeyScope = isApiKeyScope;
|
|
24
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/apiKeys/types.ts"],"names":[],"mappings":";AAAA;;;;;;;;GAQG;;;AAEH,+EAA+E;AAClE,QAAA,cAAc,GAAG;IAC5B,mBAAmB;IACnB,oBAAoB;IACpB,YAAY;IACZ,aAAa;IACb,cAAc;IACd,eAAe;CACP,CAAC;AAIJ,MAAM,aAAa,GAAG,CAAC,KAAc,EAAwB,EAAE,CACpE,OAAO,KAAK,KAAK,QAAQ,IAAK,sBAAoC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;AADxE,QAAA,aAAa,iBAC2D"}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFound
|
|
|
12
12
|
export type { PaymentRequiredDetails } from './errors/HttpError';
|
|
13
13
|
export * from './entitlements';
|
|
14
14
|
export * from './groups';
|
|
15
|
+
export * from './apiKeys';
|
|
15
16
|
export { authenticateToken, optionalAuth } from './middleware/authMiddleware';
|
|
16
17
|
export { requireInternalServiceToken, internalServiceTokenBypass, allowUserOrInternalService, verifyInternalToken, INTERNAL_TOKEN_HEADER, SERVICE_ID_HEADER } from './middleware/internalServiceAuth';
|
|
17
18
|
export { createCorsMiddleware } from './middleware/corsMiddleware';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,YAAY,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,YAAY,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAC;AAG9D,OAAO,EAAE,OAAO,IAAI,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAGrD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAC/D,YAAY,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAGpE,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAGhE,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAG1C,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,iBAAiB,EAAE,cAAc,EAAE,aAAa,EAAE,aAAa,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC7K,YAAY,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAGjE,cAAc,gBAAgB,CAAC;AAG/B,cAAc,UAAU,CAAC;AAGzB,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC9E,OAAO,EACL,2BAA2B,EAC3B,0BAA0B,EAC1B,0BAA0B,EAC1B,mBAAmB,EACnB,qBAAqB,EACrB,iBAAiB,EAClB,MAAM,kCAAkC,CAAC;AAC1C,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AACnE,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAC1G,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AACnH,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAGjD,YAAY,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGhD,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AACnF,OAAO,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7E,YAAY,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAG9E,OAAO,EACL,iBAAiB,EACjB,mBAAmB,EACnB,iBAAiB,EACjB,qBAAqB,EACrB,oBAAoB,EACpB,eAAe,EACf,QAAQ,EACT,MAAM,WAAW,CAAC;AAGnB,OAAO,EACL,UAAU,EACV,SAAS,EACT,SAAS,EACT,iBAAiB,EACjB,eAAe,EACf,SAAS,EACT,UAAU,EACV,aAAa,EACb,kBAAkB,EACnB,MAAM,WAAW,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,YAAY,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,YAAY,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAC;AAG9D,OAAO,EAAE,OAAO,IAAI,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAGrD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAC/D,YAAY,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAGpE,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAGhE,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAG1C,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,iBAAiB,EAAE,cAAc,EAAE,aAAa,EAAE,aAAa,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC7K,YAAY,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAGjE,cAAc,gBAAgB,CAAC;AAG/B,cAAc,UAAU,CAAC;AAGzB,cAAc,WAAW,CAAC;AAG1B,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC9E,OAAO,EACL,2BAA2B,EAC3B,0BAA0B,EAC1B,0BAA0B,EAC1B,mBAAmB,EACnB,qBAAqB,EACrB,iBAAiB,EAClB,MAAM,kCAAkC,CAAC;AAC1C,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AACnE,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAC1G,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AACnH,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAGjD,YAAY,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGhD,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AACnF,OAAO,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7E,YAAY,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAG9E,OAAO,EACL,iBAAiB,EACjB,mBAAmB,EACnB,iBAAiB,EACjB,qBAAqB,EACrB,oBAAoB,EACpB,eAAe,EACf,QAAQ,EACT,MAAM,WAAW,CAAC;AAGnB,OAAO,EACL,UAAU,EACV,SAAS,EACT,SAAS,EACT,iBAAiB,EACjB,eAAe,EACf,SAAS,EACT,UAAU,EACV,aAAa,EACb,kBAAkB,EACnB,MAAM,WAAW,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -56,6 +56,8 @@ Object.defineProperty(exports, "PaymentRequiredError", { enumerable: true, get:
|
|
|
56
56
|
__exportStar(require("./entitlements"), exports);
|
|
57
57
|
// Group ownership (the quota subject for anything pooled)
|
|
58
58
|
__exportStar(require("./groups"), exports);
|
|
59
|
+
// API keys (non-browser clients: MCP servers, scripts, agents)
|
|
60
|
+
__exportStar(require("./apiKeys"), exports);
|
|
59
61
|
// Middleware
|
|
60
62
|
var authMiddleware_1 = require("./middleware/authMiddleware");
|
|
61
63
|
Object.defineProperty(exports, "authenticateToken", { enumerable: true, get: function () { return authMiddleware_1.authenticateToken; } });
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,qFAAqF;;;;;;;;;;;;;;;;;;;;;AAErF,gBAAgB;AAChB,qDAAoD;AAA3C,8GAAA,aAAa,OAAA;AAEtB,2CAAkD;AAAzC,4GAAA,gBAAgB,OAAA;AAGzB,UAAU;AACV,2CAAqD;AAA5C,iHAAA,OAAO,OAAU;AAE1B,gBAAgB;AAChB,8DAAoF;AAA3E,+GAAA,WAAW,OAAA;AAAE,8GAAA,UAAU,OAAA;AAAE,kHAAA,cAAc,OAAA;AAChD,gEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAG1B,WAAW;AACX,oDAAgE;AAAvD,uGAAA,SAAS,OAAA;AAAE,0GAAA,YAAY,OAAA;AAEhC,SAAS;AACT,oCAA0C;AAAjC,iGAAA,UAAU,OAAA;AAEnB,SAAS;AACT,gDAA6K;AAApK,sGAAA,SAAS,OAAA;AAAE,4GAAA,eAAe,OAAA;AAAE,8GAAA,iBAAiB,OAAA;AAAE,2GAAA,cAAc,OAAA;AAAE,0GAAA,aAAa,OAAA;AAAE,0GAAA,aAAa,OAAA;AAAE,iHAAA,oBAAoB,OAAA;AAAE,iHAAA,oBAAoB,OAAA;AAGhJ,wDAAwD;AACxD,iDAA+B;AAE/B,0DAA0D;AAC1D,2CAAyB;AAEzB,aAAa;AACb,8DAA8E;AAArE,mHAAA,iBAAiB,OAAA;AAAE,8GAAA,YAAY,OAAA;AACxC,wEAO0C;AANxC,kIAAA,2BAA2B,OAAA;AAC3B,iIAAA,0BAA0B,OAAA;AAC1B,iIAAA,0BAA0B,OAAA;AAC1B,0HAAA,mBAAmB,OAAA;AACnB,4HAAA,qBAAqB,OAAA;AACrB,wHAAA,iBAAiB,OAAA;AAEnB,8DAAmE;AAA1D,sHAAA,oBAAoB,OAAA;AAC7B,0DAAyD;AAAhD,4GAAA,YAAY,OAAA;AACrB,4DAA0G;AAAjG,8GAAA,aAAa,OAAA;AAAE,yHAAA,wBAAwB,OAAA;AAAE,oHAAA,mBAAmB,OAAA;AACrE,kDAAmH;AAA1G,2GAAA,eAAe,OAAA;AAAE,6GAAA,iBAAiB,OAAA;AAAE,+GAAA,mBAAmB,OAAA;AAAE,6GAAA,iBAAiB,OAAA;AACnF,kDAAiD;AAAxC,oGAAA,QAAQ,OAAA;AAKjB,QAAQ;AACR,mDAAmF;AAA1E,oHAAA,qBAAqB,OAAA;AAAE,oHAAA,qBAAqB,OAAA;AACrD,6CAA6E;AAApE,6GAAA,iBAAiB,OAAA;AAAE,uGAAA,WAAW,OAAA;AAAE,qGAAA,SAAS,OAAA;AAElD,2DAA8E;AAArE,mHAAA,gBAAgB,OAAA;AAAE,oHAAA,iBAAiB,OAAA;AAE5C,UAAU;AACV,qCAQmB;AAPjB,4GAAA,iBAAiB,OAAA;AACjB,8GAAA,mBAAmB,OAAA;AACnB,4GAAA,iBAAiB,OAAA;AACjB,gHAAA,qBAAqB,OAAA;AACrB,+GAAA,oBAAoB,OAAA;AACpB,0GAAA,eAAe,OAAA;AACf,mGAAA,QAAQ,OAAA;AAGV,UAAU;AACV,qCAUmB;AATjB,qGAAA,UAAU,OAAA;AACV,oGAAA,SAAS,OAAA;AACT,oGAAA,SAAS,OAAA;AACT,4GAAA,iBAAiB,OAAA;AACjB,0GAAA,eAAe,OAAA;AACf,oGAAA,SAAS,OAAA;AACT,qGAAA,UAAU,OAAA;AACV,wGAAA,aAAa,OAAA;AACb,6GAAA,kBAAkB,OAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,qFAAqF;;;;;;;;;;;;;;;;;;;;;AAErF,gBAAgB;AAChB,qDAAoD;AAA3C,8GAAA,aAAa,OAAA;AAEtB,2CAAkD;AAAzC,4GAAA,gBAAgB,OAAA;AAGzB,UAAU;AACV,2CAAqD;AAA5C,iHAAA,OAAO,OAAU;AAE1B,gBAAgB;AAChB,8DAAoF;AAA3E,+GAAA,WAAW,OAAA;AAAE,8GAAA,UAAU,OAAA;AAAE,kHAAA,cAAc,OAAA;AAChD,gEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAG1B,WAAW;AACX,oDAAgE;AAAvD,uGAAA,SAAS,OAAA;AAAE,0GAAA,YAAY,OAAA;AAEhC,SAAS;AACT,oCAA0C;AAAjC,iGAAA,UAAU,OAAA;AAEnB,SAAS;AACT,gDAA6K;AAApK,sGAAA,SAAS,OAAA;AAAE,4GAAA,eAAe,OAAA;AAAE,8GAAA,iBAAiB,OAAA;AAAE,2GAAA,cAAc,OAAA;AAAE,0GAAA,aAAa,OAAA;AAAE,0GAAA,aAAa,OAAA;AAAE,iHAAA,oBAAoB,OAAA;AAAE,iHAAA,oBAAoB,OAAA;AAGhJ,wDAAwD;AACxD,iDAA+B;AAE/B,0DAA0D;AAC1D,2CAAyB;AAEzB,+DAA+D;AAC/D,4CAA0B;AAE1B,aAAa;AACb,8DAA8E;AAArE,mHAAA,iBAAiB,OAAA;AAAE,8GAAA,YAAY,OAAA;AACxC,wEAO0C;AANxC,kIAAA,2BAA2B,OAAA;AAC3B,iIAAA,0BAA0B,OAAA;AAC1B,iIAAA,0BAA0B,OAAA;AAC1B,0HAAA,mBAAmB,OAAA;AACnB,4HAAA,qBAAqB,OAAA;AACrB,wHAAA,iBAAiB,OAAA;AAEnB,8DAAmE;AAA1D,sHAAA,oBAAoB,OAAA;AAC7B,0DAAyD;AAAhD,4GAAA,YAAY,OAAA;AACrB,4DAA0G;AAAjG,8GAAA,aAAa,OAAA;AAAE,yHAAA,wBAAwB,OAAA;AAAE,oHAAA,mBAAmB,OAAA;AACrE,kDAAmH;AAA1G,2GAAA,eAAe,OAAA;AAAE,6GAAA,iBAAiB,OAAA;AAAE,+GAAA,mBAAmB,OAAA;AAAE,6GAAA,iBAAiB,OAAA;AACnF,kDAAiD;AAAxC,oGAAA,QAAQ,OAAA;AAKjB,QAAQ;AACR,mDAAmF;AAA1E,oHAAA,qBAAqB,OAAA;AAAE,oHAAA,qBAAqB,OAAA;AACrD,6CAA6E;AAApE,6GAAA,iBAAiB,OAAA;AAAE,uGAAA,WAAW,OAAA;AAAE,qGAAA,SAAS,OAAA;AAElD,2DAA8E;AAArE,mHAAA,gBAAgB,OAAA;AAAE,oHAAA,iBAAiB,OAAA;AAE5C,UAAU;AACV,qCAQmB;AAPjB,4GAAA,iBAAiB,OAAA;AACjB,8GAAA,mBAAmB,OAAA;AACnB,4GAAA,iBAAiB,OAAA;AACjB,gHAAA,qBAAqB,OAAA;AACrB,+GAAA,oBAAoB,OAAA;AACpB,0GAAA,eAAe,OAAA;AACf,mGAAA,QAAQ,OAAA;AAGV,UAAU;AACV,qCAUmB;AATjB,qGAAA,UAAU,OAAA;AACV,oGAAA,SAAS,OAAA;AACT,oGAAA,SAAS,OAAA;AACT,4GAAA,iBAAiB,OAAA;AACjB,0GAAA,eAAe,OAAA;AACf,oGAAA,SAAS,OAAA;AACT,qGAAA,UAAU,OAAA;AACV,wGAAA,aAAa,OAAA;AACb,6GAAA,kBAAkB,OAAA"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import mongoose, { Document, Schema } from 'mongoose';
|
|
2
|
+
import type { ApiKeyScope } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One API key. Lives in backend-core rather than auth-service because both
|
|
6
|
+
* halves need it: auth-service issues and revokes keys, and every other service
|
|
7
|
+
* verifies them on the way in. All services share one database, so the model is
|
|
8
|
+
* shared rather than the lookup going over HTTP on every request.
|
|
9
|
+
*/
|
|
10
|
+
export interface IApiKey extends Document {
|
|
11
|
+
userId: string;
|
|
12
|
+
/**
|
|
13
|
+
* The owner's identity as it stood when the key was issued.
|
|
14
|
+
*
|
|
15
|
+
* Snapshotted so verifying a key stays a single indexed read. Every
|
|
16
|
+
* authenticated request would otherwise need a second query into the users
|
|
17
|
+
* collection to fill in a `UserPayload`, to serve fields almost no handler
|
|
18
|
+
* reads. The cost is that a later rename shows the old name on requests made
|
|
19
|
+
* with this key, which is a fair price and easy to reason about.
|
|
20
|
+
*/
|
|
21
|
+
userEmail: string;
|
|
22
|
+
userName: string;
|
|
23
|
+
/** what the user called it — "Claude Code", "home assistant" */
|
|
24
|
+
name: string;
|
|
25
|
+
/** the public half of the token; unique, and what a presented key is looked up by */
|
|
26
|
+
keyId: string;
|
|
27
|
+
/** SHA-256 of the secret half — what verification actually compares against */
|
|
28
|
+
hash: string;
|
|
29
|
+
/** the whole token, encrypted, so the owner can read it back later */
|
|
30
|
+
sealedCiphertext: string;
|
|
31
|
+
sealedIv: string;
|
|
32
|
+
sealedTag: string;
|
|
33
|
+
scopes: ApiKeyScope[];
|
|
34
|
+
/** the tenant this key acts in; null means the user's own non-group data */
|
|
35
|
+
groupId: string | null;
|
|
36
|
+
lastUsedAt?: Date;
|
|
37
|
+
expiresAt?: Date;
|
|
38
|
+
revokedAt?: Date;
|
|
39
|
+
/** reveals are counted and timestamped — reading back a key is worth an audit trail */
|
|
40
|
+
revealCount: number;
|
|
41
|
+
lastRevealedAt?: Date;
|
|
42
|
+
createdAt: Date;
|
|
43
|
+
updatedAt: Date;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ApiKeySchema = new Schema<IApiKey>(
|
|
47
|
+
{
|
|
48
|
+
userId: { type: String, required: true, index: true },
|
|
49
|
+
userEmail: { type: String, required: true },
|
|
50
|
+
userName: { type: String, required: true, default: '' },
|
|
51
|
+
name: { type: String, required: true, trim: true, maxlength: 60 },
|
|
52
|
+
keyId: { type: String, required: true, unique: true, index: true },
|
|
53
|
+
hash: { type: String, required: true },
|
|
54
|
+
sealedCiphertext: { type: String, required: true },
|
|
55
|
+
sealedIv: { type: String, required: true },
|
|
56
|
+
sealedTag: { type: String, required: true },
|
|
57
|
+
scopes: { type: [String], default: [] },
|
|
58
|
+
// Explicitly nullable rather than optional: "personal scope" is a decision the
|
|
59
|
+
// creator made, and it must not be indistinguishable from a field nobody set.
|
|
60
|
+
groupId: { type: String, default: null },
|
|
61
|
+
lastUsedAt: { type: Date },
|
|
62
|
+
expiresAt: { type: Date },
|
|
63
|
+
revokedAt: { type: Date },
|
|
64
|
+
revealCount: { type: Number, default: 0 },
|
|
65
|
+
lastRevealedAt: { type: Date }
|
|
66
|
+
},
|
|
67
|
+
{ timestamps: true, collection: 'api_keys' }
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Every listing is one user's keys, newest first.
|
|
71
|
+
ApiKeySchema.index({ userId: 1, createdAt: -1 });
|
|
72
|
+
|
|
73
|
+
export const ApiKey = mongoose.models.ApiKey
|
|
74
|
+
? (mongoose.models.ApiKey as mongoose.Model<IApiKey>)
|
|
75
|
+
: mongoose.model<IApiKey>('ApiKey', ApiKeySchema);
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
const ORIGINAL_ENV = process.env;
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
generateKey,
|
|
5
|
+
parseKey,
|
|
6
|
+
looksLikeApiKey,
|
|
7
|
+
secretMatches,
|
|
8
|
+
encryptSecret,
|
|
9
|
+
decryptSecret,
|
|
10
|
+
displayPrefix,
|
|
11
|
+
sha256,
|
|
12
|
+
resetEncryptionKeyCache
|
|
13
|
+
} from './crypto';
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
process.env = { ...ORIGINAL_ENV, API_KEY_ENCRYPTION_SECRET: 'test-encryption-secret' };
|
|
17
|
+
resetEncryptionKeyCache();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
process.env = ORIGINAL_ENV;
|
|
22
|
+
resetEncryptionKeyCache();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('generateKey', () => {
|
|
26
|
+
it('produces a prefixed token whose parts round-trip through parseKey', () => {
|
|
27
|
+
const { token, id, hash } = generateKey();
|
|
28
|
+
const parsed = parseKey(token);
|
|
29
|
+
|
|
30
|
+
expect(token.startsWith('tmb_live_')).toBe(true);
|
|
31
|
+
expect(parsed).not.toBeNull();
|
|
32
|
+
expect(parsed!.id).toBe(id);
|
|
33
|
+
expect(sha256(parsed!.secret)).toBe(hash);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('never repeats a key', () => {
|
|
37
|
+
const tokens = new Set(Array.from({ length: 50 }, () => generateKey().token));
|
|
38
|
+
expect(tokens.size).toBe(50);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('does not store the secret in recoverable form in the hash', () => {
|
|
42
|
+
const { token, hash } = generateKey();
|
|
43
|
+
expect(hash).not.toContain(token);
|
|
44
|
+
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('parseKey', () => {
|
|
49
|
+
it.each([
|
|
50
|
+
['a non-string', 12345],
|
|
51
|
+
['an empty string', ''],
|
|
52
|
+
['a foreign prefix', 'sk_live_abcdef123456_secretsecretsecret'],
|
|
53
|
+
['too few parts', 'tmb_live_abcdef123456'],
|
|
54
|
+
['a missing secret', 'tmb_live_abcdef123456_'],
|
|
55
|
+
['a non-hex id', 'tmb_live_ZZZZZZZZZZZZ_secretsecretsecret'],
|
|
56
|
+
['a short id', 'tmb_live_abc_secretsecretsecret'],
|
|
57
|
+
['a truncated secret', 'tmb_live_abcdef123456_short']
|
|
58
|
+
])('rejects %s', (_label, token) => {
|
|
59
|
+
expect(parseKey(token)).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('accepts a secret containing underscores, which base64url produces', () => {
|
|
63
|
+
// The alphabet is A-Za-z0-9-_, so roughly a third of real keys carry an
|
|
64
|
+
// underscore in the secret. Splitting the token on '_' rejected those.
|
|
65
|
+
expect(parseKey('tmb_live_abcdef123456_aa_bb_cc-dd_eeffgghhiijj')).toEqual({
|
|
66
|
+
id: 'abcdef123456',
|
|
67
|
+
secret: 'aa_bb_cc-dd_eeffgghhiijj'
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('round-trips every generated key, underscores and all', () => {
|
|
72
|
+
for (let i = 0; i < 200; i++) {
|
|
73
|
+
const { token, id } = generateKey();
|
|
74
|
+
expect(parseKey(token)?.id).toBe(id);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('accepts a well-formed key', () => {
|
|
79
|
+
expect(parseKey('tmb_live_abcdef123456_aaaaaaaaaaaaaaaaaaaa')).toEqual({
|
|
80
|
+
id: 'abcdef123456',
|
|
81
|
+
secret: 'aaaaaaaaaaaaaaaaaaaa'
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('looksLikeApiKey', () => {
|
|
87
|
+
it('separates our keys from JWTs so the caller can skip a pointless verify', () => {
|
|
88
|
+
expect(looksLikeApiKey(generateKey().token)).toBe(true);
|
|
89
|
+
expect(looksLikeApiKey('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc.def')).toBe(false);
|
|
90
|
+
expect(looksLikeApiKey(undefined)).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('secretMatches', () => {
|
|
95
|
+
it('accepts the right secret and rejects a wrong one', () => {
|
|
96
|
+
const { token, hash } = generateKey();
|
|
97
|
+
const { secret } = parseKey(token)!;
|
|
98
|
+
|
|
99
|
+
expect(secretMatches(secret, hash)).toBe(true);
|
|
100
|
+
expect(secretMatches(`${secret}x`, hash)).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns false rather than throwing on a malformed stored hash', () => {
|
|
104
|
+
// timingSafeEqual throws on a length mismatch, which would turn a corrupt
|
|
105
|
+
// record into a 500 instead of a failed authentication.
|
|
106
|
+
expect(secretMatches('anything', 'not-a-sha256')).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('encryptSecret / decryptSecret', () => {
|
|
111
|
+
it('round-trips a token', () => {
|
|
112
|
+
const { token } = generateKey();
|
|
113
|
+
expect(decryptSecret(encryptSecret(token))).toBe(token);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('produces different ciphertext each time, so equal keys are not detectable', () => {
|
|
117
|
+
const { token } = generateKey();
|
|
118
|
+
const a = encryptSecret(token);
|
|
119
|
+
const b = encryptSecret(token);
|
|
120
|
+
|
|
121
|
+
expect(a.ciphertext).not.toBe(b.ciphertext);
|
|
122
|
+
expect(a.iv).not.toBe(b.iv);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('refuses to decrypt tampered ciphertext rather than returning rubbish', () => {
|
|
126
|
+
const sealed = encryptSecret(generateKey().token);
|
|
127
|
+
const flipped = Buffer.from(sealed.ciphertext, 'base64');
|
|
128
|
+
flipped[0] ^= 0xff;
|
|
129
|
+
|
|
130
|
+
expect(() =>
|
|
131
|
+
decryptSecret({ ...sealed, ciphertext: flipped.toString('base64') })
|
|
132
|
+
).toThrow();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('cannot be decrypted with a different encryption secret', () => {
|
|
136
|
+
const sealed = encryptSecret(generateKey().token);
|
|
137
|
+
|
|
138
|
+
process.env.API_KEY_ENCRYPTION_SECRET = 'a-completely-different-secret';
|
|
139
|
+
resetEncryptionKeyCache();
|
|
140
|
+
|
|
141
|
+
expect(() => decryptSecret(sealed)).toThrow();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('requires the encryption secret to be configured at all', () => {
|
|
145
|
+
delete process.env.API_KEY_ENCRYPTION_SECRET;
|
|
146
|
+
resetEncryptionKeyCache();
|
|
147
|
+
|
|
148
|
+
expect(() => encryptSecret('tmb_live_x')).toThrow(/API_KEY_ENCRYPTION_SECRET/);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('displayPrefix', () => {
|
|
153
|
+
it('identifies a key without exposing enough to use it', () => {
|
|
154
|
+
const { token, id } = generateKey();
|
|
155
|
+
const prefix = displayPrefix(id);
|
|
156
|
+
|
|
157
|
+
expect(prefix).toBe(`tmb_live_${id}`);
|
|
158
|
+
expect(token.startsWith(prefix)).toBe(true);
|
|
159
|
+
expect(prefix.length).toBeLessThan(token.length);
|
|
160
|
+
});
|
|
161
|
+
});
|