@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.
Files changed (38) hide show
  1. package/dist/apiKeys/ApiKey.d.ts +51 -0
  2. package/dist/apiKeys/ApiKey.d.ts.map +1 -0
  3. package/dist/apiKeys/ApiKey.js +63 -0
  4. package/dist/apiKeys/ApiKey.js.map +1 -0
  5. package/dist/apiKeys/crypto.d.ts +61 -0
  6. package/dist/apiKeys/crypto.d.ts.map +1 -0
  7. package/dist/apiKeys/crypto.js +132 -0
  8. package/dist/apiKeys/crypto.js.map +1 -0
  9. package/dist/apiKeys/index.d.ts +10 -0
  10. package/dist/apiKeys/index.d.ts.map +1 -0
  11. package/dist/apiKeys/index.js +25 -0
  12. package/dist/apiKeys/index.js.map +1 -0
  13. package/dist/apiKeys/middleware.d.ts +54 -0
  14. package/dist/apiKeys/middleware.d.ts.map +1 -0
  15. package/dist/apiKeys/middleware.js +174 -0
  16. package/dist/apiKeys/middleware.js.map +1 -0
  17. package/dist/apiKeys/service.d.ts +43 -0
  18. package/dist/apiKeys/service.d.ts.map +1 -0
  19. package/dist/apiKeys/service.js +122 -0
  20. package/dist/apiKeys/service.js.map +1 -0
  21. package/dist/apiKeys/types.d.ts +50 -0
  22. package/dist/apiKeys/types.d.ts.map +1 -0
  23. package/dist/apiKeys/types.js +24 -0
  24. package/dist/apiKeys/types.js.map +1 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -0
  28. package/dist/index.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/apiKeys/ApiKey.ts +75 -0
  31. package/src/apiKeys/crypto.test.ts +161 -0
  32. package/src/apiKeys/crypto.ts +163 -0
  33. package/src/apiKeys/index.ts +21 -0
  34. package/src/apiKeys/middleware.test.ts +254 -0
  35. package/src/apiKeys/middleware.ts +206 -0
  36. package/src/apiKeys/service.ts +149 -0
  37. package/src/apiKeys/types.ts +69 -0
  38. package/src/index.ts +3 -0
@@ -0,0 +1,163 @@
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ createHash,
5
+ randomBytes,
6
+ scryptSync,
7
+ timingSafeEqual
8
+ } from 'crypto';
9
+ import { requireEnv } from '../config/env';
10
+
11
+ /**
12
+ * Key material and the two different things we do with it.
13
+ *
14
+ * A presented key is checked against a SHA-256 digest — fast, because it runs on
15
+ * every authenticated request. Plain SHA-256 rather than bcrypt is right here
16
+ * and wrong for passwords: the secret is 32 bytes from a CSPRNG, so there is no
17
+ * dictionary to attack and nothing for a slow hash to buy.
18
+ *
19
+ * Separately, the key is stored encrypted so the owner can look it up again
20
+ * later. That is a deliberate trade — see `encryptSecret` — and the reason the
21
+ * digest is kept as well: verification never touches the reversible copy.
22
+ */
23
+
24
+ /** `tmb_live_<id>_<secret>` — the prefix makes a leaked key greppable in logs. */
25
+ const KEY_PREFIX = 'tmb_live';
26
+ const ID_BYTES = 6; // 12 hex chars, the public half
27
+ const SECRET_BYTES = 32;
28
+
29
+ const ALGORITHM = 'aes-256-gcm';
30
+ const IV_BYTES = 12;
31
+ /** Fixed: the input is a high-entropy env secret, not a password, so a per-record salt buys nothing. */
32
+ const KDF_SALT = 'tumbaland/api-key/v1';
33
+
34
+ export interface GeneratedKey {
35
+ /** the whole key, shown to the user and never stored in this form */
36
+ token: string;
37
+ /** the public half, indexed for lookup */
38
+ id: string;
39
+ /** SHA-256 of the secret half, for verification */
40
+ hash: string;
41
+ }
42
+
43
+ /** Derive the AES key once per process; scrypt is deliberately slow. */
44
+ let cachedKey: Buffer | null = null;
45
+ function encryptionKey(): Buffer {
46
+ if (!cachedKey) {
47
+ cachedKey = scryptSync(requireEnv('API_KEY_ENCRYPTION_SECRET'), KDF_SALT, 32);
48
+ }
49
+ return cachedKey;
50
+ }
51
+
52
+ /** Test seam: the derived key is cached for the life of the process. */
53
+ export const resetEncryptionKeyCache = (): void => {
54
+ cachedKey = null;
55
+ };
56
+
57
+ /**
58
+ * Whether keys can be issued and revealed at all.
59
+ *
60
+ * Only the reversible copy needs the secret — verification runs off the digest —
61
+ * so a service that merely accepts keys works without it. That asymmetry is easy
62
+ * to get wrong in a deployment, so callers can ask rather than discovering it
63
+ * through a 500 on someone's first key.
64
+ */
65
+ export const isEncryptionConfigured = (): boolean =>
66
+ Boolean(process.env.API_KEY_ENCRYPTION_SECRET);
67
+
68
+ export const sha256 = (value: string): string =>
69
+ createHash('sha256').update(value, 'utf8').digest('hex');
70
+
71
+ export const generateKey = (): GeneratedKey => {
72
+ const id = randomBytes(ID_BYTES).toString('hex');
73
+ const secret = randomBytes(SECRET_BYTES).toString('base64url');
74
+
75
+ return {
76
+ token: `${KEY_PREFIX}_${id}_${secret}`,
77
+ id,
78
+ hash: sha256(secret)
79
+ };
80
+ };
81
+
82
+ export interface ParsedKey {
83
+ id: string;
84
+ secret: string;
85
+ }
86
+
87
+ /**
88
+ * Split a presented key into its public id and its secret half.
89
+ *
90
+ * Returns null rather than throwing: an unparseable token is an ordinary failed
91
+ * authentication, not an exceptional condition, and the caller answers both the
92
+ * same way.
93
+ */
94
+ export const parseKey = (token: unknown): ParsedKey | null => {
95
+ if (typeof token !== 'string') return null;
96
+
97
+ // Matched positionally rather than split on '_': the base64url alphabet
98
+ // includes '_', so a secret can contain any number of them and splitting
99
+ // would reject roughly a third of otherwise valid keys. The id is
100
+ // fixed-width, so everything after it is the secret.
101
+ const match = token.match(/^tmb_live_([0-9a-f]{12})_(.+)$/);
102
+ if (!match) return null;
103
+ if (match[2].length < 16) return null;
104
+
105
+ return { id: match[1], secret: match[2] };
106
+ };
107
+
108
+ /** True when a token even looks like one of ours — lets the caller skip a JWT parse. */
109
+ export const looksLikeApiKey = (token: unknown): boolean =>
110
+ typeof token === 'string' && token.startsWith(`${KEY_PREFIX}_`);
111
+
112
+ /** Constant-time digest comparison, so a wrong key leaks nothing through timing. */
113
+ export const secretMatches = (secret: string, expectedHash: string): boolean => {
114
+ const presented = Buffer.from(sha256(secret), 'hex');
115
+ const expected = Buffer.from(expectedHash, 'hex');
116
+ if (presented.length !== expected.length) return false;
117
+ return timingSafeEqual(presented, expected);
118
+ };
119
+
120
+ export interface SealedSecret {
121
+ ciphertext: string;
122
+ iv: string;
123
+ tag: string;
124
+ }
125
+
126
+ /**
127
+ * Encrypt the key so it can be shown again later.
128
+ *
129
+ * Storing a recoverable copy is weaker than hashing alone: whoever holds both
130
+ * the database and `API_KEY_ENCRYPTION_SECRET` can mint the plaintext. It buys
131
+ * the ability to re-read a key you have lost, which the product wants. The
132
+ * secret lives outside the database precisely so that a dump, a backup, or a
133
+ * read-only replica is not on its own enough.
134
+ */
135
+ export const encryptSecret = (token: string): SealedSecret => {
136
+ const iv = randomBytes(IV_BYTES);
137
+ const cipher = createCipheriv(ALGORITHM, encryptionKey(), iv);
138
+ const ciphertext = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
139
+
140
+ return {
141
+ ciphertext: ciphertext.toString('base64'),
142
+ iv: iv.toString('base64'),
143
+ tag: cipher.getAuthTag().toString('base64')
144
+ };
145
+ };
146
+
147
+ /**
148
+ * Recover a stored key. Throws when the ciphertext has been tampered with —
149
+ * GCM authenticates, so a modified record fails loudly instead of returning
150
+ * plausible rubbish.
151
+ */
152
+ export const decryptSecret = (sealed: SealedSecret): string => {
153
+ const decipher = createDecipheriv(ALGORITHM, encryptionKey(), Buffer.from(sealed.iv, 'base64'));
154
+ decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
155
+
156
+ return Buffer.concat([
157
+ decipher.update(Buffer.from(sealed.ciphertext, 'base64')),
158
+ decipher.final()
159
+ ]).toString('utf8');
160
+ };
161
+
162
+ /** What the UI shows in a list: enough to tell two keys apart, not enough to use one. */
163
+ export const displayPrefix = (id: string): string => `${KEY_PREFIX}_${id}`;
@@ -0,0 +1,21 @@
1
+ export { ApiKey } from './ApiKey';
2
+ export type { IApiKey } from './ApiKey';
3
+ export {
4
+ createApiKey,
5
+ listApiKeys,
6
+ revealApiKey,
7
+ revokeApiKey,
8
+ deleteApiKey,
9
+ verifyApiKey
10
+ } from './service';
11
+ export type { CreateApiKeyInput, CreatedApiKey } from './service';
12
+ export { authenticateAgent, requireScope, denyApiKeys } from './middleware';
13
+ export type { ApiKeyContext } from './middleware';
14
+ export { API_KEY_SCOPES, isApiKeyScope } from './types';
15
+ export type { ApiKeyScope, ApiKeySummary, ApiKeyVerification, ApiKeyRejection } from './types';
16
+ export {
17
+ looksLikeApiKey,
18
+ displayPrefix,
19
+ isEncryptionConfigured,
20
+ resetEncryptionKeyCache
21
+ } from './crypto';
@@ -0,0 +1,254 @@
1
+ import { Request, Response } from 'express';
2
+
3
+ jest.mock('./service', () => ({ verifyApiKey: jest.fn() }));
4
+ jest.mock('../logging/logger', () => ({
5
+ __esModule: true,
6
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }
7
+ }));
8
+
9
+ import jwt from 'jsonwebtoken';
10
+ import { verifyApiKey } from './service';
11
+ import { authenticateAgent, denyApiKeys } from './middleware';
12
+
13
+ const mockedVerify = verifyApiKey as jest.Mock;
14
+ const ORIGINAL_ENV = process.env;
15
+
16
+ function mockRes(): Response {
17
+ const res: Partial<Response> = {};
18
+ res.status = jest.fn().mockReturnValue(res);
19
+ res.json = jest.fn().mockReturnValue(res);
20
+ return res as Response;
21
+ }
22
+
23
+ const req = (over: Partial<Request> = {}): Request =>
24
+ ({ query: {}, body: {}, headers: {}, path: '/activities', ...over }) as unknown as Request;
25
+
26
+ const withKey = (token = 'tmb_live_abcdef123456_secretsecretsecret', over: Partial<Request> = {}) =>
27
+ req({ headers: { authorization: `Bearer ${token}` }, ...over });
28
+
29
+ const okVerification = (over: Record<string, unknown> = {}) => ({
30
+ ok: true,
31
+ userId: 'u1',
32
+ userEmail: 'u1@example.com',
33
+ userName: 'Tester',
34
+ keyId: 'k1',
35
+ scopes: ['relationship:read', 'relationship:write'],
36
+ groupId: null,
37
+ ...over
38
+ });
39
+
40
+ beforeEach(() => {
41
+ jest.clearAllMocks();
42
+ process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-jwt-secret' };
43
+ });
44
+
45
+ afterEach(() => {
46
+ process.env = ORIGINAL_ENV;
47
+ });
48
+
49
+ describe('authenticateAgent — no credentials', () => {
50
+ it('rejects a request with no token', async () => {
51
+ const res = mockRes();
52
+ const next = jest.fn();
53
+
54
+ await authenticateAgent()(req(), res, next);
55
+
56
+ expect(res.status).toHaveBeenCalledWith(401);
57
+ expect(next).not.toHaveBeenCalled();
58
+ });
59
+ });
60
+
61
+ describe('authenticateAgent — session tokens', () => {
62
+ it('accepts a valid JWT and grants the full session, with no apiKey context', async () => {
63
+ const token = jwt.sign({ id: 'u1', email: 'a@b.c', name: 'A', groups: ['g1', 'g2'] }, 'test-jwt-secret');
64
+ const request = req({ headers: { authorization: `Bearer ${token}` } });
65
+ const next = jest.fn();
66
+
67
+ await authenticateAgent('relationship:write')(request, mockRes(), next);
68
+
69
+ expect(next).toHaveBeenCalled();
70
+ expect(request.user?.id).toBe('u1');
71
+ // A person keeps every group they belong to — scopes constrain keys, not sessions.
72
+ expect(request.userGroups).toEqual(['g1', 'g2']);
73
+ expect(request.apiKey).toBeUndefined();
74
+ expect(mockedVerify).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it('reads the token from a cookie as well as the header', async () => {
78
+ const token = jwt.sign({ id: 'u1', email: 'a@b.c', name: 'A' }, 'test-jwt-secret');
79
+ const request = req({ cookies: { access_token: token } } as Partial<Request>);
80
+ const next = jest.fn();
81
+
82
+ await authenticateAgent()(request, mockRes(), next);
83
+
84
+ expect(next).toHaveBeenCalled();
85
+ expect(request.user?.id).toBe('u1');
86
+ });
87
+
88
+ it('rejects a JWT signed with the wrong secret', async () => {
89
+ const token = jwt.sign({ id: 'u1' }, 'not-the-secret');
90
+ const res = mockRes();
91
+ const next = jest.fn();
92
+
93
+ await authenticateAgent()(req({ headers: { authorization: `Bearer ${token}` } }), res, next);
94
+
95
+ expect(res.status).toHaveBeenCalledWith(401);
96
+ expect(next).not.toHaveBeenCalled();
97
+ });
98
+ });
99
+
100
+ describe('authenticateAgent — API keys', () => {
101
+ it('accepts a valid key and populates the request as a session would', async () => {
102
+ mockedVerify.mockResolvedValue(okVerification());
103
+ const request = withKey();
104
+ const next = jest.fn();
105
+
106
+ await authenticateAgent('relationship:read')(request, mockRes(), next);
107
+
108
+ expect(next).toHaveBeenCalled();
109
+ expect(request.user).toEqual({ id: 'u1', email: 'u1@example.com', name: 'Tester' });
110
+ expect(request.apiKey).toEqual({
111
+ keyId: 'k1',
112
+ scopes: ['relationship:read', 'relationship:write'],
113
+ groupId: null
114
+ });
115
+ });
116
+
117
+ it.each([
118
+ ['unknown'], ['revoked'], ['expired'], ['bad-secret'], ['malformed']
119
+ ])('answers a %s key with the same opaque 401', async (rejection) => {
120
+ mockedVerify.mockResolvedValue({ ok: false, rejection });
121
+ const res = mockRes();
122
+ const next = jest.fn();
123
+
124
+ await authenticateAgent()(withKey(), res, next);
125
+
126
+ expect(res.status).toHaveBeenCalledWith(401);
127
+ // Nothing in the body distinguishes the five cases — that difference would
128
+ // tell someone guessing which half of the guess was right.
129
+ expect(res.json).toHaveBeenCalledWith({
130
+ success: false,
131
+ message: 'Invalid or expired credentials'
132
+ });
133
+ expect(next).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it('refuses a key missing a required scope, and names what is missing', async () => {
137
+ mockedVerify.mockResolvedValue(okVerification({ scopes: ['relationship:read'] }));
138
+ const res = mockRes();
139
+ const next = jest.fn();
140
+
141
+ await authenticateAgent('relationship:write')(withKey(), res, next);
142
+
143
+ expect(res.status).toHaveBeenCalledWith(403);
144
+ expect(res.json).toHaveBeenCalledWith(
145
+ expect.objectContaining({ message: expect.stringContaining('relationship:write') })
146
+ );
147
+ expect(next).not.toHaveBeenCalled();
148
+ });
149
+
150
+ it('requires every listed scope, not just one of them', async () => {
151
+ mockedVerify.mockResolvedValue(okVerification({ scopes: ['relationship:read'] }));
152
+ const next = jest.fn();
153
+
154
+ await authenticateAgent('relationship:read', 'album:write')(withKey(), mockRes(), next);
155
+
156
+ expect(next).not.toHaveBeenCalled();
157
+ });
158
+ });
159
+
160
+ describe('authenticateAgent — tenant pinning', () => {
161
+ it('grants a personal key no groups at all', async () => {
162
+ mockedVerify.mockResolvedValue(okVerification({ groupId: null }));
163
+ const request = withKey();
164
+
165
+ await authenticateAgent()(request, mockRes(), jest.fn());
166
+
167
+ expect(request.userGroups).toEqual([]);
168
+ });
169
+
170
+ it('grants a group key exactly its own group, not the owner’s whole membership', async () => {
171
+ mockedVerify.mockResolvedValue(okVerification({ groupId: 'g1' }));
172
+ const request = withKey();
173
+
174
+ await authenticateAgent()(request, mockRes(), jest.fn());
175
+
176
+ expect(request.userGroups).toEqual(['g1']);
177
+ });
178
+
179
+ it('fills in the pinned group so the caller never has to know it', async () => {
180
+ mockedVerify.mockResolvedValue(okVerification({ groupId: 'g1' }));
181
+ const request = withKey();
182
+ const next = jest.fn();
183
+
184
+ await authenticateAgent()(request, mockRes(), next);
185
+
186
+ expect(next).toHaveBeenCalled();
187
+ expect(request.query.groupId).toBe('g1');
188
+ expect((request.body as Record<string, unknown>).groupId).toBe('g1');
189
+ });
190
+
191
+ it('refuses a query that names a different group', async () => {
192
+ mockedVerify.mockResolvedValue(okVerification({ groupId: 'g1' }));
193
+ const res = mockRes();
194
+ const next = jest.fn();
195
+
196
+ await authenticateAgent()(withKey(undefined, { query: { groupId: 'g2' } }), res, next);
197
+
198
+ expect(res.status).toHaveBeenCalledWith(403);
199
+ expect(next).not.toHaveBeenCalled();
200
+ });
201
+
202
+ it('refuses a body that names a different group', async () => {
203
+ mockedVerify.mockResolvedValue(okVerification({ groupId: 'g1' }));
204
+ const res = mockRes();
205
+ const next = jest.fn();
206
+
207
+ await authenticateAgent()(withKey(undefined, { body: { groupId: 'g2' } }), res, next);
208
+
209
+ expect(res.status).toHaveBeenCalledWith(403);
210
+ expect(next).not.toHaveBeenCalled();
211
+ });
212
+
213
+ it('refuses a personal key that tries to reach into a group', async () => {
214
+ mockedVerify.mockResolvedValue(okVerification({ groupId: null }));
215
+ const res = mockRes();
216
+ const next = jest.fn();
217
+
218
+ await authenticateAgent()(withKey(undefined, { query: { groupId: 'g1' } }), res, next);
219
+
220
+ expect(res.status).toHaveBeenCalledWith(403);
221
+ expect(next).not.toHaveBeenCalled();
222
+ });
223
+
224
+ it('allows a request that names the pinned group explicitly', async () => {
225
+ mockedVerify.mockResolvedValue(okVerification({ groupId: 'g1' }));
226
+ const next = jest.fn();
227
+
228
+ await authenticateAgent()(withKey(undefined, { query: { groupId: 'g1' } }), mockRes(), next);
229
+
230
+ expect(next).toHaveBeenCalled();
231
+ });
232
+ });
233
+
234
+ describe('denyApiKeys', () => {
235
+ it('lets a session through', () => {
236
+ const next = jest.fn();
237
+ denyApiKeys(req(), mockRes(), next);
238
+ expect(next).toHaveBeenCalled();
239
+ });
240
+
241
+ it('blocks a request that arrived on a key', () => {
242
+ const res = mockRes();
243
+ const next = jest.fn();
244
+
245
+ denyApiKeys(
246
+ req({ apiKey: { keyId: 'k1', scopes: [], groupId: null } }) as Request,
247
+ res,
248
+ next
249
+ );
250
+
251
+ expect(res.status).toHaveBeenCalledWith(403);
252
+ expect(next).not.toHaveBeenCalled();
253
+ });
254
+ });
@@ -0,0 +1,206 @@
1
+ import { NextFunction, Request, RequestHandler, Response } from 'express';
2
+ import jwt from 'jsonwebtoken';
3
+ import { requireEnv } from '../config/env';
4
+ import logger from '../logging/logger';
5
+ import type { UserPayload } from '../types/auth';
6
+ import { looksLikeApiKey } from './crypto';
7
+ import { verifyApiKey } from './service';
8
+ import type { ApiKeyScope } from './types';
9
+
10
+ /**
11
+ * Request-scoped facts about the key a request arrived on. Absent on ordinary
12
+ * session requests, which is itself the signal a handler needs: `req.apiKey`
13
+ * being set means software is acting, not a person.
14
+ */
15
+ export interface ApiKeyContext {
16
+ keyId: string;
17
+ scopes: ApiKeyScope[];
18
+ /** the tenant this key is pinned to; null means the owner's personal data */
19
+ groupId: string | null;
20
+ }
21
+
22
+ declare global {
23
+ namespace Express {
24
+ interface Request {
25
+ apiKey?: ApiKeyContext;
26
+ }
27
+ }
28
+ }
29
+
30
+ /** Both auth paths read the token from the same two places. */
31
+ const extractToken = (req: Request): string | undefined =>
32
+ req.cookies?.access_token || req.headers.authorization?.replace('Bearer ', '');
33
+
34
+ const unauthorized = (res: Response): void => {
35
+ res.status(401).json({ success: false, message: 'Invalid or expired credentials' });
36
+ };
37
+
38
+ /**
39
+ * The tenant a request is asking to act in, wherever it named one.
40
+ *
41
+ * Handlers read `groupId` from either the query string or the body depending on
42
+ * the verb, so both are checked — a pin that only covered one of them would be
43
+ * no pin at all.
44
+ */
45
+ const requestedGroupId = (req: Request): string | undefined => {
46
+ const fromQuery = req.query?.groupId;
47
+ if (typeof fromQuery === 'string' && fromQuery.length > 0) return fromQuery;
48
+
49
+ const fromBody = (req.body as Record<string, unknown> | undefined)?.groupId;
50
+ if (typeof fromBody === 'string' && fromBody.length > 0) return fromBody;
51
+
52
+ return undefined;
53
+ };
54
+
55
+ /**
56
+ * Hold a key to the tenant it was issued for.
57
+ *
58
+ * Two things happen here, and the second is the one that makes keys pleasant to
59
+ * use. A request that names a *different* tenant is refused outright — the pin
60
+ * is the whole reason a key is safe to hand to an agent. A request that names
61
+ * none has the pinned tenant written in for it, so the agent never has to know a
62
+ * group id exists, and a handler's `if (groupId) ... else personal` branch lands
63
+ * where the key's owner intended.
64
+ */
65
+ const applyTenantPin = (req: Request, groupId: string | null): boolean => {
66
+ const requested = requestedGroupId(req);
67
+
68
+ if (requested !== undefined && requested !== groupId) return false;
69
+
70
+ if (groupId !== null && requested === undefined) {
71
+ // Express 5 makes req.query a getter, so it is redefined rather than assigned.
72
+ Object.defineProperty(req, 'query', {
73
+ value: { ...req.query, groupId },
74
+ writable: true,
75
+ configurable: true,
76
+ enumerable: true
77
+ });
78
+ if (req.body && typeof req.body === 'object') {
79
+ (req.body as Record<string, unknown>).groupId = groupId;
80
+ }
81
+ }
82
+
83
+ return true;
84
+ };
85
+
86
+ /**
87
+ * Authenticate a request that software may legitimately be making.
88
+ *
89
+ * Accepts either an ordinary session — a person at a keyboard, who keeps the
90
+ * full run of their own account — or an API key carrying every one of
91
+ * `requiredScopes`.
92
+ *
93
+ * This is deliberately *not* what `authenticateToken` became. Keys stay refused
94
+ * everywhere by default, and a route opts in by naming the scopes it needs; the
95
+ * alternative, teaching the existing middleware about keys, would have silently
96
+ * opened every route in every service at once, account deletion included.
97
+ */
98
+ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHandler =>
99
+ async function authenticateAgentHandler(req: Request, res: Response, next: NextFunction): Promise<void> {
100
+ const token = extractToken(req);
101
+ if (!token) {
102
+ res.status(401).json({ success: false, message: 'Access token required' });
103
+ return;
104
+ }
105
+
106
+ if (!looksLikeApiKey(token)) {
107
+ try {
108
+ const user = jwt.verify(token, requireEnv('JWT_SECRET')) as UserPayload;
109
+ req.user = user;
110
+ req.userGroups = user.groups ?? [];
111
+ next();
112
+ } catch {
113
+ unauthorized(res);
114
+ }
115
+ return;
116
+ }
117
+
118
+ const result = await verifyApiKey(token);
119
+ if (!result.ok) {
120
+ // The client is told only that the credentials failed; which of the five
121
+ // ways it failed is a detail that would help someone guessing.
122
+ logger.warn('API key rejected', { rejection: result.rejection, path: req.path });
123
+ unauthorized(res);
124
+ return;
125
+ }
126
+
127
+ const scopes = result.scopes ?? [];
128
+ const missing = requiredScopes.filter((scope) => !scopes.includes(scope));
129
+ if (missing.length > 0) {
130
+ res.status(403).json({
131
+ success: false,
132
+ message: `API key is missing required scope: ${missing.join(', ')}`
133
+ });
134
+ return;
135
+ }
136
+
137
+ const groupId = result.groupId ?? null;
138
+ if (!applyTenantPin(req, groupId)) {
139
+ res.status(403).json({
140
+ success: false,
141
+ message: 'API key is not permitted to act in the requested group'
142
+ });
143
+ return;
144
+ }
145
+
146
+ req.user = {
147
+ id: result.userId!,
148
+ email: result.userEmail ?? '',
149
+ name: result.userName ?? ''
150
+ };
151
+ // Only the pinned group, never the owner's full membership: this is what
152
+ // stops a key reaching a group it was not issued for through any handler
153
+ // that consults `userGroups` instead of the `groupId` parameter.
154
+ req.userGroups = groupId ? [groupId] : [];
155
+ req.apiKey = { keyId: result.keyId!, scopes, groupId };
156
+
157
+ next();
158
+ };
159
+
160
+ /**
161
+ * Require a scope on a route already behind `authenticateAgent`.
162
+ *
163
+ * Splitting authentication from authorization lets one `router.use` cover a
164
+ * whole router while each route still states what it needs — so a read-only key
165
+ * reaches the GETs and stops at the POSTs, instead of being turned away at the
166
+ * door for lacking a scope half the router never uses.
167
+ *
168
+ * A session passes unconditionally: scopes narrow what software may do on a
169
+ * person's behalf, not what the person may do themselves.
170
+ */
171
+ export const requireScope = (...requiredScopes: ApiKeyScope[]): RequestHandler =>
172
+ function requireScopeHandler(req, res, next) {
173
+ if (!req.apiKey) {
174
+ next();
175
+ return;
176
+ }
177
+
178
+ const missing = requiredScopes.filter((scope) => !req.apiKey!.scopes.includes(scope));
179
+ if (missing.length > 0) {
180
+ res.status(403).json({
181
+ success: false,
182
+ message: `API key is missing required scope: ${missing.join(', ')}`
183
+ });
184
+ return;
185
+ }
186
+
187
+ next();
188
+ };
189
+
190
+ /**
191
+ * Refuse API keys on a route that a session may still use.
192
+ *
193
+ * For the handful of operations that should stay a person's to perform — key
194
+ * management itself, most obviously, since a key that can mint keys is a key
195
+ * that cannot be revoked.
196
+ */
197
+ export const denyApiKeys: RequestHandler = (req, res, next) => {
198
+ if (req.apiKey) {
199
+ res.status(403).json({
200
+ success: false,
201
+ message: 'This operation requires an interactive session, not an API key'
202
+ });
203
+ return;
204
+ }
205
+ next();
206
+ };