@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,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
|
+
};
|