@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,51 @@
|
|
|
1
|
+
import mongoose, { Document } from 'mongoose';
|
|
2
|
+
import type { ApiKeyScope } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* One API key. Lives in backend-core rather than auth-service because both
|
|
5
|
+
* halves need it: auth-service issues and revokes keys, and every other service
|
|
6
|
+
* verifies them on the way in. All services share one database, so the model is
|
|
7
|
+
* shared rather than the lookup going over HTTP on every request.
|
|
8
|
+
*/
|
|
9
|
+
export interface IApiKey extends Document {
|
|
10
|
+
userId: string;
|
|
11
|
+
/**
|
|
12
|
+
* The owner's identity as it stood when the key was issued.
|
|
13
|
+
*
|
|
14
|
+
* Snapshotted so verifying a key stays a single indexed read. Every
|
|
15
|
+
* authenticated request would otherwise need a second query into the users
|
|
16
|
+
* collection to fill in a `UserPayload`, to serve fields almost no handler
|
|
17
|
+
* reads. The cost is that a later rename shows the old name on requests made
|
|
18
|
+
* with this key, which is a fair price and easy to reason about.
|
|
19
|
+
*/
|
|
20
|
+
userEmail: string;
|
|
21
|
+
userName: string;
|
|
22
|
+
/** what the user called it — "Claude Code", "home assistant" */
|
|
23
|
+
name: string;
|
|
24
|
+
/** the public half of the token; unique, and what a presented key is looked up by */
|
|
25
|
+
keyId: string;
|
|
26
|
+
/** SHA-256 of the secret half — what verification actually compares against */
|
|
27
|
+
hash: string;
|
|
28
|
+
/** the whole token, encrypted, so the owner can read it back later */
|
|
29
|
+
sealedCiphertext: string;
|
|
30
|
+
sealedIv: string;
|
|
31
|
+
sealedTag: string;
|
|
32
|
+
scopes: ApiKeyScope[];
|
|
33
|
+
/** the tenant this key acts in; null means the user's own non-group data */
|
|
34
|
+
groupId: string | null;
|
|
35
|
+
lastUsedAt?: Date;
|
|
36
|
+
expiresAt?: Date;
|
|
37
|
+
revokedAt?: Date;
|
|
38
|
+
/** reveals are counted and timestamped — reading back a key is worth an audit trail */
|
|
39
|
+
revealCount: number;
|
|
40
|
+
lastRevealedAt?: Date;
|
|
41
|
+
createdAt: Date;
|
|
42
|
+
updatedAt: Date;
|
|
43
|
+
}
|
|
44
|
+
export declare const ApiKey: mongoose.Model<IApiKey, {}, {}, {}, mongoose.Document<unknown, {}, IApiKey, {}, mongoose.DefaultSchemaOptions> & IApiKey & Required<{
|
|
45
|
+
_id: mongoose.Types.ObjectId;
|
|
46
|
+
}> & {
|
|
47
|
+
__v: number;
|
|
48
|
+
} & {
|
|
49
|
+
id: string;
|
|
50
|
+
}, any, IApiKey>;
|
|
51
|
+
//# sourceMappingURL=ApiKey.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ApiKey.d.ts","sourceRoot":"","sources":["../../src/apiKeys/ApiKey.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,EAAE,EAAE,QAAQ,EAAU,MAAM,UAAU,CAAC;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C;;;;;GAKG;AACH,MAAM,WAAW,OAAQ,SAAQ,QAAQ;IACvC,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;;OAQG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,gEAAgE;IAChE,IAAI,EAAE,MAAM,CAAC;IACb,qFAAqF;IACrF,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,gBAAgB,EAAE,MAAM,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,4EAA4E;IAC5E,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,UAAU,CAAC,EAAE,IAAI,CAAC;IAClB,SAAS,CAAC,EAAE,IAAI,CAAC;IACjB,SAAS,CAAC,EAAE,IAAI,CAAC;IACjB,uFAAuF;IACvF,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,IAAI,CAAC;IACtB,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;CACjB;AA6BD,eAAO,MAAM,MAAM;;;;;;gBAEgC,CAAC"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ApiKey = void 0;
|
|
37
|
+
const mongoose_1 = __importStar(require("mongoose"));
|
|
38
|
+
const ApiKeySchema = new mongoose_1.Schema({
|
|
39
|
+
userId: { type: String, required: true, index: true },
|
|
40
|
+
userEmail: { type: String, required: true },
|
|
41
|
+
userName: { type: String, required: true, default: '' },
|
|
42
|
+
name: { type: String, required: true, trim: true, maxlength: 60 },
|
|
43
|
+
keyId: { type: String, required: true, unique: true, index: true },
|
|
44
|
+
hash: { type: String, required: true },
|
|
45
|
+
sealedCiphertext: { type: String, required: true },
|
|
46
|
+
sealedIv: { type: String, required: true },
|
|
47
|
+
sealedTag: { type: String, required: true },
|
|
48
|
+
scopes: { type: [String], default: [] },
|
|
49
|
+
// Explicitly nullable rather than optional: "personal scope" is a decision the
|
|
50
|
+
// creator made, and it must not be indistinguishable from a field nobody set.
|
|
51
|
+
groupId: { type: String, default: null },
|
|
52
|
+
lastUsedAt: { type: Date },
|
|
53
|
+
expiresAt: { type: Date },
|
|
54
|
+
revokedAt: { type: Date },
|
|
55
|
+
revealCount: { type: Number, default: 0 },
|
|
56
|
+
lastRevealedAt: { type: Date }
|
|
57
|
+
}, { timestamps: true, collection: 'api_keys' });
|
|
58
|
+
// Every listing is one user's keys, newest first.
|
|
59
|
+
ApiKeySchema.index({ userId: 1, createdAt: -1 });
|
|
60
|
+
exports.ApiKey = mongoose_1.default.models.ApiKey
|
|
61
|
+
? mongoose_1.default.models.ApiKey
|
|
62
|
+
: mongoose_1.default.model('ApiKey', ApiKeySchema);
|
|
63
|
+
//# sourceMappingURL=ApiKey.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ApiKey.js","sourceRoot":"","sources":["../../src/apiKeys/ApiKey.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,qDAAsD;AA6CtD,MAAM,YAAY,GAAG,IAAI,iBAAM,CAC7B;IACE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IACrD,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC3C,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE;IACvD,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,EAAE;IACjE,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IAClE,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACtC,gBAAgB,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAClD,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC1C,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC3C,MAAM,EAAE,EAAE,IAAI,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE;IACvC,+EAA+E;IAC/E,8EAA8E;IAC9E,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE;IACxC,UAAU,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE;IAC1B,SAAS,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE;IACzB,SAAS,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE;IACzB,WAAW,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE;IACzC,cAAc,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE;CAC/B,EACD,EAAE,UAAU,EAAE,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,CAC7C,CAAC;AAEF,kDAAkD;AAClD,YAAY,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC;AAEpC,QAAA,MAAM,GAAG,kBAAQ,CAAC,MAAM,CAAC,MAAM;IAC1C,CAAC,CAAE,kBAAQ,CAAC,MAAM,CAAC,MAAkC;IACrD,CAAC,CAAC,kBAAQ,CAAC,KAAK,CAAU,QAAQ,EAAE,YAAY,CAAC,CAAC"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface GeneratedKey {
|
|
2
|
+
/** the whole key, shown to the user and never stored in this form */
|
|
3
|
+
token: string;
|
|
4
|
+
/** the public half, indexed for lookup */
|
|
5
|
+
id: string;
|
|
6
|
+
/** SHA-256 of the secret half, for verification */
|
|
7
|
+
hash: string;
|
|
8
|
+
}
|
|
9
|
+
/** Test seam: the derived key is cached for the life of the process. */
|
|
10
|
+
export declare const resetEncryptionKeyCache: () => void;
|
|
11
|
+
/**
|
|
12
|
+
* Whether keys can be issued and revealed at all.
|
|
13
|
+
*
|
|
14
|
+
* Only the reversible copy needs the secret — verification runs off the digest —
|
|
15
|
+
* so a service that merely accepts keys works without it. That asymmetry is easy
|
|
16
|
+
* to get wrong in a deployment, so callers can ask rather than discovering it
|
|
17
|
+
* through a 500 on someone's first key.
|
|
18
|
+
*/
|
|
19
|
+
export declare const isEncryptionConfigured: () => boolean;
|
|
20
|
+
export declare const sha256: (value: string) => string;
|
|
21
|
+
export declare const generateKey: () => GeneratedKey;
|
|
22
|
+
export interface ParsedKey {
|
|
23
|
+
id: string;
|
|
24
|
+
secret: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Split a presented key into its public id and its secret half.
|
|
28
|
+
*
|
|
29
|
+
* Returns null rather than throwing: an unparseable token is an ordinary failed
|
|
30
|
+
* authentication, not an exceptional condition, and the caller answers both the
|
|
31
|
+
* same way.
|
|
32
|
+
*/
|
|
33
|
+
export declare const parseKey: (token: unknown) => ParsedKey | null;
|
|
34
|
+
/** True when a token even looks like one of ours — lets the caller skip a JWT parse. */
|
|
35
|
+
export declare const looksLikeApiKey: (token: unknown) => boolean;
|
|
36
|
+
/** Constant-time digest comparison, so a wrong key leaks nothing through timing. */
|
|
37
|
+
export declare const secretMatches: (secret: string, expectedHash: string) => boolean;
|
|
38
|
+
export interface SealedSecret {
|
|
39
|
+
ciphertext: string;
|
|
40
|
+
iv: string;
|
|
41
|
+
tag: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Encrypt the key so it can be shown again later.
|
|
45
|
+
*
|
|
46
|
+
* Storing a recoverable copy is weaker than hashing alone: whoever holds both
|
|
47
|
+
* the database and `API_KEY_ENCRYPTION_SECRET` can mint the plaintext. It buys
|
|
48
|
+
* the ability to re-read a key you have lost, which the product wants. The
|
|
49
|
+
* secret lives outside the database precisely so that a dump, a backup, or a
|
|
50
|
+
* read-only replica is not on its own enough.
|
|
51
|
+
*/
|
|
52
|
+
export declare const encryptSecret: (token: string) => SealedSecret;
|
|
53
|
+
/**
|
|
54
|
+
* Recover a stored key. Throws when the ciphertext has been tampered with —
|
|
55
|
+
* GCM authenticates, so a modified record fails loudly instead of returning
|
|
56
|
+
* plausible rubbish.
|
|
57
|
+
*/
|
|
58
|
+
export declare const decryptSecret: (sealed: SealedSecret) => string;
|
|
59
|
+
/** What the UI shows in a list: enough to tell two keys apart, not enough to use one. */
|
|
60
|
+
export declare const displayPrefix: (id: string) => string;
|
|
61
|
+
//# sourceMappingURL=crypto.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../../src/apiKeys/crypto.ts"],"names":[],"mappings":"AAiCA,MAAM,WAAW,YAAY;IAC3B,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAC;IACd,0CAA0C;IAC1C,EAAE,EAAE,MAAM,CAAC;IACX,mDAAmD;IACnD,IAAI,EAAE,MAAM,CAAC;CACd;AAWD,wEAAwE;AACxE,eAAO,MAAM,uBAAuB,QAAO,IAE1C,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,QAAO,OACM,CAAC;AAEjD,eAAO,MAAM,MAAM,GAAI,OAAO,MAAM,KAAG,MACmB,CAAC;AAE3D,eAAO,MAAM,WAAW,QAAO,YAS9B,CAAC;AAEF,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,QAAQ,GAAI,OAAO,OAAO,KAAG,SAAS,GAAG,IAYrD,CAAC;AAEF,wFAAwF;AACxF,eAAO,MAAM,eAAe,GAAI,OAAO,OAAO,KAAG,OACgB,CAAC;AAElE,oFAAoF;AACpF,eAAO,MAAM,aAAa,GAAI,QAAQ,MAAM,EAAE,cAAc,MAAM,KAAG,OAKpE,CAAC;AAEF,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,GAAI,OAAO,MAAM,KAAG,YAU7C,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,QAAQ,YAAY,KAAG,MAQpD,CAAC;AAEF,yFAAyF;AACzF,eAAO,MAAM,aAAa,GAAI,IAAI,MAAM,KAAG,MAA+B,CAAC"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.displayPrefix = exports.decryptSecret = exports.encryptSecret = exports.secretMatches = exports.looksLikeApiKey = exports.parseKey = exports.generateKey = exports.sha256 = exports.isEncryptionConfigured = exports.resetEncryptionKeyCache = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const env_1 = require("../config/env");
|
|
6
|
+
/**
|
|
7
|
+
* Key material and the two different things we do with it.
|
|
8
|
+
*
|
|
9
|
+
* A presented key is checked against a SHA-256 digest — fast, because it runs on
|
|
10
|
+
* every authenticated request. Plain SHA-256 rather than bcrypt is right here
|
|
11
|
+
* and wrong for passwords: the secret is 32 bytes from a CSPRNG, so there is no
|
|
12
|
+
* dictionary to attack and nothing for a slow hash to buy.
|
|
13
|
+
*
|
|
14
|
+
* Separately, the key is stored encrypted so the owner can look it up again
|
|
15
|
+
* later. That is a deliberate trade — see `encryptSecret` — and the reason the
|
|
16
|
+
* digest is kept as well: verification never touches the reversible copy.
|
|
17
|
+
*/
|
|
18
|
+
/** `tmb_live_<id>_<secret>` — the prefix makes a leaked key greppable in logs. */
|
|
19
|
+
const KEY_PREFIX = 'tmb_live';
|
|
20
|
+
const ID_BYTES = 6; // 12 hex chars, the public half
|
|
21
|
+
const SECRET_BYTES = 32;
|
|
22
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
23
|
+
const IV_BYTES = 12;
|
|
24
|
+
/** Fixed: the input is a high-entropy env secret, not a password, so a per-record salt buys nothing. */
|
|
25
|
+
const KDF_SALT = 'tumbaland/api-key/v1';
|
|
26
|
+
/** Derive the AES key once per process; scrypt is deliberately slow. */
|
|
27
|
+
let cachedKey = null;
|
|
28
|
+
function encryptionKey() {
|
|
29
|
+
if (!cachedKey) {
|
|
30
|
+
cachedKey = (0, crypto_1.scryptSync)((0, env_1.requireEnv)('API_KEY_ENCRYPTION_SECRET'), KDF_SALT, 32);
|
|
31
|
+
}
|
|
32
|
+
return cachedKey;
|
|
33
|
+
}
|
|
34
|
+
/** Test seam: the derived key is cached for the life of the process. */
|
|
35
|
+
const resetEncryptionKeyCache = () => {
|
|
36
|
+
cachedKey = null;
|
|
37
|
+
};
|
|
38
|
+
exports.resetEncryptionKeyCache = resetEncryptionKeyCache;
|
|
39
|
+
/**
|
|
40
|
+
* Whether keys can be issued and revealed at all.
|
|
41
|
+
*
|
|
42
|
+
* Only the reversible copy needs the secret — verification runs off the digest —
|
|
43
|
+
* so a service that merely accepts keys works without it. That asymmetry is easy
|
|
44
|
+
* to get wrong in a deployment, so callers can ask rather than discovering it
|
|
45
|
+
* through a 500 on someone's first key.
|
|
46
|
+
*/
|
|
47
|
+
const isEncryptionConfigured = () => Boolean(process.env.API_KEY_ENCRYPTION_SECRET);
|
|
48
|
+
exports.isEncryptionConfigured = isEncryptionConfigured;
|
|
49
|
+
const sha256 = (value) => (0, crypto_1.createHash)('sha256').update(value, 'utf8').digest('hex');
|
|
50
|
+
exports.sha256 = sha256;
|
|
51
|
+
const generateKey = () => {
|
|
52
|
+
const id = (0, crypto_1.randomBytes)(ID_BYTES).toString('hex');
|
|
53
|
+
const secret = (0, crypto_1.randomBytes)(SECRET_BYTES).toString('base64url');
|
|
54
|
+
return {
|
|
55
|
+
token: `${KEY_PREFIX}_${id}_${secret}`,
|
|
56
|
+
id,
|
|
57
|
+
hash: (0, exports.sha256)(secret)
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
exports.generateKey = generateKey;
|
|
61
|
+
/**
|
|
62
|
+
* Split a presented key into its public id and its secret half.
|
|
63
|
+
*
|
|
64
|
+
* Returns null rather than throwing: an unparseable token is an ordinary failed
|
|
65
|
+
* authentication, not an exceptional condition, and the caller answers both the
|
|
66
|
+
* same way.
|
|
67
|
+
*/
|
|
68
|
+
const parseKey = (token) => {
|
|
69
|
+
if (typeof token !== 'string')
|
|
70
|
+
return null;
|
|
71
|
+
// Matched positionally rather than split on '_': the base64url alphabet
|
|
72
|
+
// includes '_', so a secret can contain any number of them and splitting
|
|
73
|
+
// would reject roughly a third of otherwise valid keys. The id is
|
|
74
|
+
// fixed-width, so everything after it is the secret.
|
|
75
|
+
const match = token.match(/^tmb_live_([0-9a-f]{12})_(.+)$/);
|
|
76
|
+
if (!match)
|
|
77
|
+
return null;
|
|
78
|
+
if (match[2].length < 16)
|
|
79
|
+
return null;
|
|
80
|
+
return { id: match[1], secret: match[2] };
|
|
81
|
+
};
|
|
82
|
+
exports.parseKey = parseKey;
|
|
83
|
+
/** True when a token even looks like one of ours — lets the caller skip a JWT parse. */
|
|
84
|
+
const looksLikeApiKey = (token) => typeof token === 'string' && token.startsWith(`${KEY_PREFIX}_`);
|
|
85
|
+
exports.looksLikeApiKey = looksLikeApiKey;
|
|
86
|
+
/** Constant-time digest comparison, so a wrong key leaks nothing through timing. */
|
|
87
|
+
const secretMatches = (secret, expectedHash) => {
|
|
88
|
+
const presented = Buffer.from((0, exports.sha256)(secret), 'hex');
|
|
89
|
+
const expected = Buffer.from(expectedHash, 'hex');
|
|
90
|
+
if (presented.length !== expected.length)
|
|
91
|
+
return false;
|
|
92
|
+
return (0, crypto_1.timingSafeEqual)(presented, expected);
|
|
93
|
+
};
|
|
94
|
+
exports.secretMatches = secretMatches;
|
|
95
|
+
/**
|
|
96
|
+
* Encrypt the key so it can be shown again later.
|
|
97
|
+
*
|
|
98
|
+
* Storing a recoverable copy is weaker than hashing alone: whoever holds both
|
|
99
|
+
* the database and `API_KEY_ENCRYPTION_SECRET` can mint the plaintext. It buys
|
|
100
|
+
* the ability to re-read a key you have lost, which the product wants. The
|
|
101
|
+
* secret lives outside the database precisely so that a dump, a backup, or a
|
|
102
|
+
* read-only replica is not on its own enough.
|
|
103
|
+
*/
|
|
104
|
+
const encryptSecret = (token) => {
|
|
105
|
+
const iv = (0, crypto_1.randomBytes)(IV_BYTES);
|
|
106
|
+
const cipher = (0, crypto_1.createCipheriv)(ALGORITHM, encryptionKey(), iv);
|
|
107
|
+
const ciphertext = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
|
|
108
|
+
return {
|
|
109
|
+
ciphertext: ciphertext.toString('base64'),
|
|
110
|
+
iv: iv.toString('base64'),
|
|
111
|
+
tag: cipher.getAuthTag().toString('base64')
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
exports.encryptSecret = encryptSecret;
|
|
115
|
+
/**
|
|
116
|
+
* Recover a stored key. Throws when the ciphertext has been tampered with —
|
|
117
|
+
* GCM authenticates, so a modified record fails loudly instead of returning
|
|
118
|
+
* plausible rubbish.
|
|
119
|
+
*/
|
|
120
|
+
const decryptSecret = (sealed) => {
|
|
121
|
+
const decipher = (0, crypto_1.createDecipheriv)(ALGORITHM, encryptionKey(), Buffer.from(sealed.iv, 'base64'));
|
|
122
|
+
decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
|
|
123
|
+
return Buffer.concat([
|
|
124
|
+
decipher.update(Buffer.from(sealed.ciphertext, 'base64')),
|
|
125
|
+
decipher.final()
|
|
126
|
+
]).toString('utf8');
|
|
127
|
+
};
|
|
128
|
+
exports.decryptSecret = decryptSecret;
|
|
129
|
+
/** What the UI shows in a list: enough to tell two keys apart, not enough to use one. */
|
|
130
|
+
const displayPrefix = (id) => `${KEY_PREFIX}_${id}`;
|
|
131
|
+
exports.displayPrefix = displayPrefix;
|
|
132
|
+
//# sourceMappingURL=crypto.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crypto.js","sourceRoot":"","sources":["../../src/apiKeys/crypto.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { ApiKey } from './ApiKey';
|
|
2
|
+
export type { IApiKey } from './ApiKey';
|
|
3
|
+
export { createApiKey, listApiKeys, revealApiKey, revokeApiKey, deleteApiKey, verifyApiKey } from './service';
|
|
4
|
+
export type { CreateApiKeyInput, CreatedApiKey } from './service';
|
|
5
|
+
export { authenticateAgent, requireScope, denyApiKeys } from './middleware';
|
|
6
|
+
export type { ApiKeyContext } from './middleware';
|
|
7
|
+
export { API_KEY_SCOPES, isApiKeyScope } from './types';
|
|
8
|
+
export type { ApiKeyScope, ApiKeySummary, ApiKeyVerification, ApiKeyRejection } from './types';
|
|
9
|
+
export { looksLikeApiKey, displayPrefix, isEncryptionConfigured, resetEncryptionKeyCache } from './crypto';
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/apiKeys/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAClC,YAAY,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AACxC,OAAO,EACL,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,YAAY,EACb,MAAM,WAAW,CAAC;AACnB,YAAY,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAClE,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAC5E,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACxD,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC/F,OAAO,EACL,eAAe,EACf,aAAa,EACb,sBAAsB,EACtB,uBAAuB,EACxB,MAAM,UAAU,CAAC"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resetEncryptionKeyCache = exports.isEncryptionConfigured = exports.displayPrefix = exports.looksLikeApiKey = exports.isApiKeyScope = exports.API_KEY_SCOPES = exports.denyApiKeys = exports.requireScope = exports.authenticateAgent = exports.verifyApiKey = exports.deleteApiKey = exports.revokeApiKey = exports.revealApiKey = exports.listApiKeys = exports.createApiKey = exports.ApiKey = void 0;
|
|
4
|
+
var ApiKey_1 = require("./ApiKey");
|
|
5
|
+
Object.defineProperty(exports, "ApiKey", { enumerable: true, get: function () { return ApiKey_1.ApiKey; } });
|
|
6
|
+
var service_1 = require("./service");
|
|
7
|
+
Object.defineProperty(exports, "createApiKey", { enumerable: true, get: function () { return service_1.createApiKey; } });
|
|
8
|
+
Object.defineProperty(exports, "listApiKeys", { enumerable: true, get: function () { return service_1.listApiKeys; } });
|
|
9
|
+
Object.defineProperty(exports, "revealApiKey", { enumerable: true, get: function () { return service_1.revealApiKey; } });
|
|
10
|
+
Object.defineProperty(exports, "revokeApiKey", { enumerable: true, get: function () { return service_1.revokeApiKey; } });
|
|
11
|
+
Object.defineProperty(exports, "deleteApiKey", { enumerable: true, get: function () { return service_1.deleteApiKey; } });
|
|
12
|
+
Object.defineProperty(exports, "verifyApiKey", { enumerable: true, get: function () { return service_1.verifyApiKey; } });
|
|
13
|
+
var middleware_1 = require("./middleware");
|
|
14
|
+
Object.defineProperty(exports, "authenticateAgent", { enumerable: true, get: function () { return middleware_1.authenticateAgent; } });
|
|
15
|
+
Object.defineProperty(exports, "requireScope", { enumerable: true, get: function () { return middleware_1.requireScope; } });
|
|
16
|
+
Object.defineProperty(exports, "denyApiKeys", { enumerable: true, get: function () { return middleware_1.denyApiKeys; } });
|
|
17
|
+
var types_1 = require("./types");
|
|
18
|
+
Object.defineProperty(exports, "API_KEY_SCOPES", { enumerable: true, get: function () { return types_1.API_KEY_SCOPES; } });
|
|
19
|
+
Object.defineProperty(exports, "isApiKeyScope", { enumerable: true, get: function () { return types_1.isApiKeyScope; } });
|
|
20
|
+
var crypto_1 = require("./crypto");
|
|
21
|
+
Object.defineProperty(exports, "looksLikeApiKey", { enumerable: true, get: function () { return crypto_1.looksLikeApiKey; } });
|
|
22
|
+
Object.defineProperty(exports, "displayPrefix", { enumerable: true, get: function () { return crypto_1.displayPrefix; } });
|
|
23
|
+
Object.defineProperty(exports, "isEncryptionConfigured", { enumerable: true, get: function () { return crypto_1.isEncryptionConfigured; } });
|
|
24
|
+
Object.defineProperty(exports, "resetEncryptionKeyCache", { enumerable: true, get: function () { return crypto_1.resetEncryptionKeyCache; } });
|
|
25
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/apiKeys/index.ts"],"names":[],"mappings":";;;AAAA,mCAAkC;AAAzB,gGAAA,MAAM,OAAA;AAEf,qCAOmB;AANjB,uGAAA,YAAY,OAAA;AACZ,sGAAA,WAAW,OAAA;AACX,uGAAA,YAAY,OAAA;AACZ,uGAAA,YAAY,OAAA;AACZ,uGAAA,YAAY,OAAA;AACZ,uGAAA,YAAY,OAAA;AAGd,2CAA4E;AAAnE,+GAAA,iBAAiB,OAAA;AAAE,0GAAA,YAAY,OAAA;AAAE,yGAAA,WAAW,OAAA;AAErD,iCAAwD;AAA/C,uGAAA,cAAc,OAAA;AAAE,sGAAA,aAAa,OAAA;AAEtC,mCAKkB;AAJhB,yGAAA,eAAe,OAAA;AACf,uGAAA,aAAa,OAAA;AACb,gHAAA,sBAAsB,OAAA;AACtB,iHAAA,uBAAuB,OAAA"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { RequestHandler } from 'express';
|
|
2
|
+
import type { ApiKeyScope } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Request-scoped facts about the key a request arrived on. Absent on ordinary
|
|
5
|
+
* session requests, which is itself the signal a handler needs: `req.apiKey`
|
|
6
|
+
* being set means software is acting, not a person.
|
|
7
|
+
*/
|
|
8
|
+
export interface ApiKeyContext {
|
|
9
|
+
keyId: string;
|
|
10
|
+
scopes: ApiKeyScope[];
|
|
11
|
+
/** the tenant this key is pinned to; null means the owner's personal data */
|
|
12
|
+
groupId: string | null;
|
|
13
|
+
}
|
|
14
|
+
declare global {
|
|
15
|
+
namespace Express {
|
|
16
|
+
interface Request {
|
|
17
|
+
apiKey?: ApiKeyContext;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Authenticate a request that software may legitimately be making.
|
|
23
|
+
*
|
|
24
|
+
* Accepts either an ordinary session — a person at a keyboard, who keeps the
|
|
25
|
+
* full run of their own account — or an API key carrying every one of
|
|
26
|
+
* `requiredScopes`.
|
|
27
|
+
*
|
|
28
|
+
* This is deliberately *not* what `authenticateToken` became. Keys stay refused
|
|
29
|
+
* everywhere by default, and a route opts in by naming the scopes it needs; the
|
|
30
|
+
* alternative, teaching the existing middleware about keys, would have silently
|
|
31
|
+
* opened every route in every service at once, account deletion included.
|
|
32
|
+
*/
|
|
33
|
+
export declare const authenticateAgent: (...requiredScopes: ApiKeyScope[]) => RequestHandler;
|
|
34
|
+
/**
|
|
35
|
+
* Require a scope on a route already behind `authenticateAgent`.
|
|
36
|
+
*
|
|
37
|
+
* Splitting authentication from authorization lets one `router.use` cover a
|
|
38
|
+
* whole router while each route still states what it needs — so a read-only key
|
|
39
|
+
* reaches the GETs and stops at the POSTs, instead of being turned away at the
|
|
40
|
+
* door for lacking a scope half the router never uses.
|
|
41
|
+
*
|
|
42
|
+
* A session passes unconditionally: scopes narrow what software may do on a
|
|
43
|
+
* person's behalf, not what the person may do themselves.
|
|
44
|
+
*/
|
|
45
|
+
export declare const requireScope: (...requiredScopes: ApiKeyScope[]) => RequestHandler;
|
|
46
|
+
/**
|
|
47
|
+
* Refuse API keys on a route that a session may still use.
|
|
48
|
+
*
|
|
49
|
+
* For the handful of operations that should stay a person's to perform — key
|
|
50
|
+
* management itself, most obviously, since a key that can mint keys is a key
|
|
51
|
+
* that cannot be revoked.
|
|
52
|
+
*/
|
|
53
|
+
export declare const denyApiKeys: RequestHandler;
|
|
54
|
+
//# sourceMappingURL=middleware.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/apiKeys/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAyB,cAAc,EAAY,MAAM,SAAS,CAAC;AAO1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,6EAA6E;IAC7E,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,OAAO,CAAC;QAChB,UAAU,OAAO;YACf,MAAM,CAAC,EAAE,aAAa,CAAC;SACxB;KACF;CACF;AA0DD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB,GAAI,GAAG,gBAAgB,WAAW,EAAE,KAAG,cA4DlE,CAAC;AAEJ;;;;;;;;;;GAUG;AACH,eAAO,MAAM,YAAY,GAAI,GAAG,gBAAgB,WAAW,EAAE,KAAG,cAiB7D,CAAC;AAEJ;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,cASzB,CAAC"}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.denyApiKeys = exports.requireScope = exports.authenticateAgent = void 0;
|
|
7
|
+
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
8
|
+
const env_1 = require("../config/env");
|
|
9
|
+
const logger_1 = __importDefault(require("../logging/logger"));
|
|
10
|
+
const crypto_1 = require("./crypto");
|
|
11
|
+
const service_1 = require("./service");
|
|
12
|
+
/** Both auth paths read the token from the same two places. */
|
|
13
|
+
const extractToken = (req) => req.cookies?.access_token || req.headers.authorization?.replace('Bearer ', '');
|
|
14
|
+
const unauthorized = (res) => {
|
|
15
|
+
res.status(401).json({ success: false, message: 'Invalid or expired credentials' });
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The tenant a request is asking to act in, wherever it named one.
|
|
19
|
+
*
|
|
20
|
+
* Handlers read `groupId` from either the query string or the body depending on
|
|
21
|
+
* the verb, so both are checked — a pin that only covered one of them would be
|
|
22
|
+
* no pin at all.
|
|
23
|
+
*/
|
|
24
|
+
const requestedGroupId = (req) => {
|
|
25
|
+
const fromQuery = req.query?.groupId;
|
|
26
|
+
if (typeof fromQuery === 'string' && fromQuery.length > 0)
|
|
27
|
+
return fromQuery;
|
|
28
|
+
const fromBody = req.body?.groupId;
|
|
29
|
+
if (typeof fromBody === 'string' && fromBody.length > 0)
|
|
30
|
+
return fromBody;
|
|
31
|
+
return undefined;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Hold a key to the tenant it was issued for.
|
|
35
|
+
*
|
|
36
|
+
* Two things happen here, and the second is the one that makes keys pleasant to
|
|
37
|
+
* use. A request that names a *different* tenant is refused outright — the pin
|
|
38
|
+
* is the whole reason a key is safe to hand to an agent. A request that names
|
|
39
|
+
* none has the pinned tenant written in for it, so the agent never has to know a
|
|
40
|
+
* group id exists, and a handler's `if (groupId) ... else personal` branch lands
|
|
41
|
+
* where the key's owner intended.
|
|
42
|
+
*/
|
|
43
|
+
const applyTenantPin = (req, groupId) => {
|
|
44
|
+
const requested = requestedGroupId(req);
|
|
45
|
+
if (requested !== undefined && requested !== groupId)
|
|
46
|
+
return false;
|
|
47
|
+
if (groupId !== null && requested === undefined) {
|
|
48
|
+
// Express 5 makes req.query a getter, so it is redefined rather than assigned.
|
|
49
|
+
Object.defineProperty(req, 'query', {
|
|
50
|
+
value: { ...req.query, groupId },
|
|
51
|
+
writable: true,
|
|
52
|
+
configurable: true,
|
|
53
|
+
enumerable: true
|
|
54
|
+
});
|
|
55
|
+
if (req.body && typeof req.body === 'object') {
|
|
56
|
+
req.body.groupId = groupId;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Authenticate a request that software may legitimately be making.
|
|
63
|
+
*
|
|
64
|
+
* Accepts either an ordinary session — a person at a keyboard, who keeps the
|
|
65
|
+
* full run of their own account — or an API key carrying every one of
|
|
66
|
+
* `requiredScopes`.
|
|
67
|
+
*
|
|
68
|
+
* This is deliberately *not* what `authenticateToken` became. Keys stay refused
|
|
69
|
+
* everywhere by default, and a route opts in by naming the scopes it needs; the
|
|
70
|
+
* alternative, teaching the existing middleware about keys, would have silently
|
|
71
|
+
* opened every route in every service at once, account deletion included.
|
|
72
|
+
*/
|
|
73
|
+
const authenticateAgent = (...requiredScopes) => async function authenticateAgentHandler(req, res, next) {
|
|
74
|
+
const token = extractToken(req);
|
|
75
|
+
if (!token) {
|
|
76
|
+
res.status(401).json({ success: false, message: 'Access token required' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!(0, crypto_1.looksLikeApiKey)(token)) {
|
|
80
|
+
try {
|
|
81
|
+
const user = jsonwebtoken_1.default.verify(token, (0, env_1.requireEnv)('JWT_SECRET'));
|
|
82
|
+
req.user = user;
|
|
83
|
+
req.userGroups = user.groups ?? [];
|
|
84
|
+
next();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
unauthorized(res);
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const result = await (0, service_1.verifyApiKey)(token);
|
|
92
|
+
if (!result.ok) {
|
|
93
|
+
// The client is told only that the credentials failed; which of the five
|
|
94
|
+
// ways it failed is a detail that would help someone guessing.
|
|
95
|
+
logger_1.default.warn('API key rejected', { rejection: result.rejection, path: req.path });
|
|
96
|
+
unauthorized(res);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const scopes = result.scopes ?? [];
|
|
100
|
+
const missing = requiredScopes.filter((scope) => !scopes.includes(scope));
|
|
101
|
+
if (missing.length > 0) {
|
|
102
|
+
res.status(403).json({
|
|
103
|
+
success: false,
|
|
104
|
+
message: `API key is missing required scope: ${missing.join(', ')}`
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const groupId = result.groupId ?? null;
|
|
109
|
+
if (!applyTenantPin(req, groupId)) {
|
|
110
|
+
res.status(403).json({
|
|
111
|
+
success: false,
|
|
112
|
+
message: 'API key is not permitted to act in the requested group'
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
req.user = {
|
|
117
|
+
id: result.userId,
|
|
118
|
+
email: result.userEmail ?? '',
|
|
119
|
+
name: result.userName ?? ''
|
|
120
|
+
};
|
|
121
|
+
// Only the pinned group, never the owner's full membership: this is what
|
|
122
|
+
// stops a key reaching a group it was not issued for through any handler
|
|
123
|
+
// that consults `userGroups` instead of the `groupId` parameter.
|
|
124
|
+
req.userGroups = groupId ? [groupId] : [];
|
|
125
|
+
req.apiKey = { keyId: result.keyId, scopes, groupId };
|
|
126
|
+
next();
|
|
127
|
+
};
|
|
128
|
+
exports.authenticateAgent = authenticateAgent;
|
|
129
|
+
/**
|
|
130
|
+
* Require a scope on a route already behind `authenticateAgent`.
|
|
131
|
+
*
|
|
132
|
+
* Splitting authentication from authorization lets one `router.use` cover a
|
|
133
|
+
* whole router while each route still states what it needs — so a read-only key
|
|
134
|
+
* reaches the GETs and stops at the POSTs, instead of being turned away at the
|
|
135
|
+
* door for lacking a scope half the router never uses.
|
|
136
|
+
*
|
|
137
|
+
* A session passes unconditionally: scopes narrow what software may do on a
|
|
138
|
+
* person's behalf, not what the person may do themselves.
|
|
139
|
+
*/
|
|
140
|
+
const requireScope = (...requiredScopes) => function requireScopeHandler(req, res, next) {
|
|
141
|
+
if (!req.apiKey) {
|
|
142
|
+
next();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const missing = requiredScopes.filter((scope) => !req.apiKey.scopes.includes(scope));
|
|
146
|
+
if (missing.length > 0) {
|
|
147
|
+
res.status(403).json({
|
|
148
|
+
success: false,
|
|
149
|
+
message: `API key is missing required scope: ${missing.join(', ')}`
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
next();
|
|
154
|
+
};
|
|
155
|
+
exports.requireScope = requireScope;
|
|
156
|
+
/**
|
|
157
|
+
* Refuse API keys on a route that a session may still use.
|
|
158
|
+
*
|
|
159
|
+
* For the handful of operations that should stay a person's to perform — key
|
|
160
|
+
* management itself, most obviously, since a key that can mint keys is a key
|
|
161
|
+
* that cannot be revoked.
|
|
162
|
+
*/
|
|
163
|
+
const denyApiKeys = (req, res, next) => {
|
|
164
|
+
if (req.apiKey) {
|
|
165
|
+
res.status(403).json({
|
|
166
|
+
success: false,
|
|
167
|
+
message: 'This operation requires an interactive session, not an API key'
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
next();
|
|
172
|
+
};
|
|
173
|
+
exports.denyApiKeys = denyApiKeys;
|
|
174
|
+
//# sourceMappingURL=middleware.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"middleware.js","sourceRoot":"","sources":["../../src/apiKeys/middleware.ts"],"names":[],"mappings":"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"}
|