@optimizely-opal/opal-tool-ocp-sdk 0.0.0-beta.7 → 0.0.0-beta.8
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/README.md +9 -43
- package/dist/auth/TokenVerifier.d.ts +31 -0
- package/dist/auth/TokenVerifier.d.ts.map +1 -0
- package/dist/auth/TokenVerifier.js +127 -0
- package/dist/auth/TokenVerifier.js.map +1 -0
- package/dist/auth/TokenVerifier.test.d.ts +2 -0
- package/dist/auth/TokenVerifier.test.d.ts.map +1 -0
- package/dist/auth/TokenVerifier.test.js +114 -0
- package/dist/auth/TokenVerifier.test.js.map +1 -0
- package/dist/function/ToolFunction.d.ts +4 -7
- package/dist/function/ToolFunction.d.ts.map +1 -1
- package/dist/function/ToolFunction.js +35 -10
- package/dist/function/ToolFunction.js.map +1 -1
- package/dist/function/ToolFunction.test.js +177 -196
- package/dist/function/ToolFunction.test.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/service/Service.d.ts +7 -7
- package/dist/service/Service.d.ts.map +1 -1
- package/dist/service/Service.js +22 -16
- package/dist/service/Service.js.map +1 -1
- package/dist/service/Service.test.js +8 -3
- package/dist/service/Service.test.js.map +1 -1
- package/dist/types/Models.d.ts +5 -5
- package/dist/types/Models.d.ts.map +1 -1
- package/dist/types/Models.js +9 -9
- package/dist/types/Models.js.map +1 -1
- package/package.json +5 -3
- package/src/auth/TokenVerifier.test.ts +152 -0
- package/src/auth/TokenVerifier.ts +145 -0
- package/src/function/ToolFunction.test.ts +194 -214
- package/src/function/ToolFunction.ts +41 -11
- package/src/index.ts +1 -0
- package/src/service/Service.test.ts +8 -3
- package/src/service/Service.ts +22 -17
- package/src/types/Models.ts +4 -4
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import { ToolFunction } from './ToolFunction';
|
|
2
2
|
import { toolsService } from '../service/Service';
|
|
3
|
-
import { Response } from '@zaiusinc/app-sdk';
|
|
3
|
+
import { Response, getAppContext } from '@zaiusinc/app-sdk';
|
|
4
|
+
import { getTokenVerifier } from '../auth/TokenVerifier';
|
|
4
5
|
|
|
5
|
-
// Mock the
|
|
6
|
+
// Mock the dependencies
|
|
6
7
|
jest.mock('../service/Service', () => ({
|
|
7
8
|
toolsService: {
|
|
8
9
|
processRequest: jest.fn(),
|
|
9
|
-
extractBearerToken: jest.fn(),
|
|
10
10
|
},
|
|
11
11
|
}));
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
jest.mock('../auth/TokenVerifier', () => ({
|
|
14
|
+
getTokenVerifier: jest.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
14
17
|
jest.mock('@zaiusinc/app-sdk', () => ({
|
|
15
18
|
Function: class {
|
|
16
19
|
protected request: any;
|
|
17
20
|
public constructor(_name?: string) {
|
|
18
|
-
// Mock constructor that accepts optional name parameter
|
|
19
21
|
this.request = {};
|
|
20
22
|
}
|
|
21
23
|
},
|
|
@@ -27,41 +29,34 @@ jest.mock('@zaiusinc/app-sdk', () => ({
|
|
|
27
29
|
bodyAsU8Array: new Uint8Array()
|
|
28
30
|
})),
|
|
29
31
|
amendLogContext: jest.fn(),
|
|
32
|
+
getAppContext: jest.fn(),
|
|
33
|
+
logger: {
|
|
34
|
+
info: jest.fn(),
|
|
35
|
+
error: jest.fn(),
|
|
36
|
+
warn: jest.fn(),
|
|
37
|
+
debug: jest.fn(),
|
|
38
|
+
},
|
|
30
39
|
}));
|
|
31
40
|
|
|
32
41
|
// Create a concrete implementation for testing
|
|
33
42
|
class TestToolFunction extends ToolFunction {
|
|
34
|
-
private mockValidateBearerToken: jest.MockedFunction<(token: string) => boolean>;
|
|
35
43
|
private mockReady: jest.MockedFunction<() => Promise<boolean>>;
|
|
36
44
|
|
|
37
45
|
public constructor(request?: any) {
|
|
38
|
-
super(request || {});
|
|
39
|
-
// Set the request directly without defaulting to empty object
|
|
46
|
+
super(request || {});
|
|
40
47
|
(this as any).request = request;
|
|
41
|
-
|
|
42
|
-
this.mockValidateBearerToken = jest.fn().mockReturnValue(true);
|
|
43
48
|
this.mockReady = jest.fn().mockResolvedValue(true);
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
// Override the concrete method with mock implementation for testing
|
|
47
|
-
protected validateBearerToken(bearerToken: string): boolean {
|
|
48
|
-
return this.mockValidateBearerToken(bearerToken);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
51
|
// Override the ready method with mock implementation for testing
|
|
52
52
|
protected ready(): Promise<boolean> {
|
|
53
53
|
return this.mockReady();
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
// Expose request and validation mock for testing
|
|
57
56
|
public getRequest() {
|
|
58
57
|
return (this as any).request;
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
public getMockValidateBearerToken() {
|
|
62
|
-
return this.mockValidateBearerToken;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
60
|
public getMockReady() {
|
|
66
61
|
return this.mockReady;
|
|
67
62
|
}
|
|
@@ -72,35 +67,99 @@ describe('ToolFunction', () => {
|
|
|
72
67
|
let mockResponse: Response;
|
|
73
68
|
let toolFunction: TestToolFunction;
|
|
74
69
|
let mockProcessRequest: jest.MockedFunction<typeof toolsService.processRequest>;
|
|
75
|
-
let
|
|
70
|
+
let mockGetTokenVerifier: jest.MockedFunction<typeof getTokenVerifier>;
|
|
71
|
+
let mockGetAppContext: jest.MockedFunction<typeof getAppContext>;
|
|
72
|
+
let mockTokenVerifier: jest.Mocked<{
|
|
73
|
+
verify: (token: string) => Promise<any>;
|
|
74
|
+
}>;
|
|
76
75
|
|
|
77
76
|
beforeEach(() => {
|
|
78
77
|
jest.clearAllMocks();
|
|
79
78
|
|
|
80
|
-
// Create mock
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
method: 'POST',
|
|
84
|
-
path: '/test'
|
|
79
|
+
// Create mock token verifier
|
|
80
|
+
mockTokenVerifier = {
|
|
81
|
+
verify: jest.fn(),
|
|
85
82
|
};
|
|
86
|
-
mockResponse = {} as Response;
|
|
87
83
|
|
|
88
84
|
// Setup the mocks
|
|
89
85
|
mockProcessRequest = jest.mocked(toolsService.processRequest);
|
|
90
|
-
|
|
86
|
+
mockGetTokenVerifier = jest.mocked(getTokenVerifier);
|
|
87
|
+
mockGetAppContext = jest.mocked(getAppContext);
|
|
91
88
|
|
|
92
|
-
|
|
89
|
+
mockGetTokenVerifier.mockResolvedValue(mockTokenVerifier as any);
|
|
90
|
+
mockGetAppContext.mockReturnValue({
|
|
91
|
+
account: {
|
|
92
|
+
organizationId: 'app-org-123'
|
|
93
|
+
}
|
|
94
|
+
} as any);
|
|
95
|
+
|
|
96
|
+
// Create mock request with bodyJSON structure
|
|
97
|
+
mockRequest = {
|
|
98
|
+
headers: new Map(),
|
|
99
|
+
method: 'POST',
|
|
100
|
+
path: '/test',
|
|
101
|
+
bodyJSON: {
|
|
102
|
+
parameters: {
|
|
103
|
+
task_id: 'task-123',
|
|
104
|
+
content_id: 'content-456'
|
|
105
|
+
},
|
|
106
|
+
auth: {
|
|
107
|
+
provider: 'OptiID',
|
|
108
|
+
credentials: {
|
|
109
|
+
token_type: 'Bearer',
|
|
110
|
+
access_token: 'valid-access-token',
|
|
111
|
+
org_sso_id: 'org-sso-123',
|
|
112
|
+
user_id: 'user-456',
|
|
113
|
+
instance_id: 'instance-789',
|
|
114
|
+
customer_id: 'app-org-123',
|
|
115
|
+
product_sku: 'OPAL'
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
environment: {
|
|
119
|
+
execution_mode: 'headless'
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
mockResponse = {} as Response;
|
|
93
125
|
toolFunction = new TestToolFunction(mockRequest);
|
|
94
126
|
});
|
|
95
127
|
|
|
128
|
+
// Helper function to create a ready request with valid auth
|
|
129
|
+
const createReadyRequestWithAuth = () => ({
|
|
130
|
+
headers: new Map(),
|
|
131
|
+
method: 'GET',
|
|
132
|
+
path: '/ready',
|
|
133
|
+
bodyJSON: {
|
|
134
|
+
auth: {
|
|
135
|
+
provider: 'OptiID',
|
|
136
|
+
credentials: {
|
|
137
|
+
access_token: 'valid-token',
|
|
138
|
+
customer_id: 'app-org-123'
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Helper function to setup authorization mocks to pass
|
|
145
|
+
const setupAuthMocks = () => {
|
|
146
|
+
mockTokenVerifier.verify.mockResolvedValue(true);
|
|
147
|
+
mockGetAppContext.mockReturnValue({
|
|
148
|
+
account: {
|
|
149
|
+
organizationId: 'app-org-123'
|
|
150
|
+
}
|
|
151
|
+
} as any);
|
|
152
|
+
};
|
|
153
|
+
|
|
96
154
|
describe('/ready endpoint', () => {
|
|
155
|
+
beforeEach(() => {
|
|
156
|
+
setupAuthMocks();
|
|
157
|
+
});
|
|
158
|
+
|
|
97
159
|
it('should return ready: true when ready method returns true', async () => {
|
|
98
160
|
// Arrange
|
|
99
|
-
const readyRequest =
|
|
100
|
-
|
|
101
|
-
method: 'GET',
|
|
102
|
-
path: '/ready'
|
|
103
|
-
};
|
|
161
|
+
const readyRequest = createReadyRequestWithAuth();
|
|
162
|
+
|
|
104
163
|
toolFunction = new TestToolFunction(readyRequest);
|
|
105
164
|
toolFunction.getMockReady().mockResolvedValue(true);
|
|
106
165
|
|
|
@@ -115,11 +174,8 @@ describe('ToolFunction', () => {
|
|
|
115
174
|
|
|
116
175
|
it('should return ready: false when ready method returns false', async () => {
|
|
117
176
|
// Arrange
|
|
118
|
-
const readyRequest =
|
|
119
|
-
|
|
120
|
-
method: 'GET',
|
|
121
|
-
path: '/ready'
|
|
122
|
-
};
|
|
177
|
+
const readyRequest = createReadyRequestWithAuth();
|
|
178
|
+
|
|
123
179
|
toolFunction = new TestToolFunction(readyRequest);
|
|
124
180
|
toolFunction.getMockReady().mockResolvedValue(false);
|
|
125
181
|
|
|
@@ -134,11 +190,8 @@ describe('ToolFunction', () => {
|
|
|
134
190
|
|
|
135
191
|
it('should handle ready method throwing an error', async () => {
|
|
136
192
|
// Arrange
|
|
137
|
-
const readyRequest =
|
|
138
|
-
|
|
139
|
-
method: 'GET',
|
|
140
|
-
path: '/ready'
|
|
141
|
-
};
|
|
193
|
+
const readyRequest = createReadyRequestWithAuth();
|
|
194
|
+
|
|
142
195
|
toolFunction = new TestToolFunction(readyRequest);
|
|
143
196
|
toolFunction.getMockReady().mockRejectedValue(new Error('Ready check failed'));
|
|
144
197
|
|
|
@@ -148,52 +201,6 @@ describe('ToolFunction', () => {
|
|
|
148
201
|
expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
|
|
149
202
|
});
|
|
150
203
|
|
|
151
|
-
it('should handle /ready endpoint after bearer token validation passes', async () => {
|
|
152
|
-
// Arrange
|
|
153
|
-
const readyRequest = {
|
|
154
|
-
headers: new Map([['authorization', 'Bearer valid-token']]),
|
|
155
|
-
method: 'GET',
|
|
156
|
-
path: '/ready'
|
|
157
|
-
};
|
|
158
|
-
toolFunction = new TestToolFunction(readyRequest);
|
|
159
|
-
toolFunction.getMockReady().mockResolvedValue(true);
|
|
160
|
-
mockExtractBearerToken.mockReturnValue('valid-token');
|
|
161
|
-
toolFunction.getMockValidateBearerToken().mockReturnValue(true); // Make sure auth passes so we reach /ready
|
|
162
|
-
|
|
163
|
-
// Act
|
|
164
|
-
const result = await toolFunction.perform();
|
|
165
|
-
|
|
166
|
-
// Assert
|
|
167
|
-
expect(toolFunction.getMockReady()).toHaveBeenCalledTimes(1);
|
|
168
|
-
expect(result).toEqual(new Response(200, { ready: true }));
|
|
169
|
-
expect(mockExtractBearerToken).toHaveBeenCalledWith(readyRequest.headers); // Auth is checked first
|
|
170
|
-
expect(toolFunction.getMockValidateBearerToken()).toHaveBeenCalledWith('valid-token'); // Auth validation happens
|
|
171
|
-
expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it('should return 403 when bearer token validation fails even for /ready endpoint', async () => {
|
|
175
|
-
// Arrange
|
|
176
|
-
const readyRequest = {
|
|
177
|
-
headers: new Map([['authorization', 'Bearer invalid-token']]),
|
|
178
|
-
method: 'GET',
|
|
179
|
-
path: '/ready'
|
|
180
|
-
};
|
|
181
|
-
toolFunction = new TestToolFunction(readyRequest);
|
|
182
|
-
toolFunction.getMockReady().mockResolvedValue(true);
|
|
183
|
-
mockExtractBearerToken.mockReturnValue('invalid-token');
|
|
184
|
-
toolFunction.getMockValidateBearerToken().mockReturnValue(false); // Auth fails
|
|
185
|
-
|
|
186
|
-
// Act
|
|
187
|
-
const result = await toolFunction.perform();
|
|
188
|
-
|
|
189
|
-
// Assert
|
|
190
|
-
expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
|
|
191
|
-
expect(mockExtractBearerToken).toHaveBeenCalledWith(readyRequest.headers);
|
|
192
|
-
expect(toolFunction.getMockValidateBearerToken()).toHaveBeenCalledWith('invalid-token');
|
|
193
|
-
expect(toolFunction.getMockReady()).not.toHaveBeenCalled(); // Should not reach ready check
|
|
194
|
-
expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
|
|
195
|
-
});
|
|
196
|
-
|
|
197
204
|
it('should use default ready implementation when not overridden', async () => {
|
|
198
205
|
// Create a class that doesn't override ready method
|
|
199
206
|
class DefaultReadyToolFunction extends ToolFunction {
|
|
@@ -208,11 +215,7 @@ describe('ToolFunction', () => {
|
|
|
208
215
|
}
|
|
209
216
|
|
|
210
217
|
// Arrange
|
|
211
|
-
const readyRequest =
|
|
212
|
-
headers: new Map(),
|
|
213
|
-
method: 'GET',
|
|
214
|
-
path: '/ready'
|
|
215
|
-
};
|
|
218
|
+
const readyRequest = createReadyRequestWithAuth();
|
|
216
219
|
const defaultToolFunction = new DefaultReadyToolFunction(readyRequest);
|
|
217
220
|
|
|
218
221
|
// Act
|
|
@@ -224,159 +227,136 @@ describe('ToolFunction', () => {
|
|
|
224
227
|
});
|
|
225
228
|
});
|
|
226
229
|
|
|
227
|
-
describe('
|
|
228
|
-
it('should
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
mockExtractBearerToken.mockReturnValue(bearerToken);
|
|
232
|
-
toolFunction.getMockValidateBearerToken().mockReturnValue(true);
|
|
230
|
+
describe('perform', () => {
|
|
231
|
+
it('should execute successfully with valid token and matching organization', async () => {
|
|
232
|
+
// Setup mock token verifier to return true for valid token
|
|
233
|
+
mockTokenVerifier.verify.mockResolvedValue(true);
|
|
233
234
|
mockProcessRequest.mockResolvedValue(mockResponse);
|
|
234
235
|
|
|
235
|
-
// Act
|
|
236
236
|
const result = await toolFunction.perform();
|
|
237
237
|
|
|
238
|
-
// Assert
|
|
239
|
-
expect(mockExtractBearerToken).toHaveBeenCalledWith(mockRequest.headers);
|
|
240
|
-
expect(toolFunction.getMockValidateBearerToken()).toHaveBeenCalledWith(bearerToken);
|
|
241
|
-
expect(mockProcessRequest).toHaveBeenCalledWith(mockRequest, toolFunction);
|
|
242
238
|
expect(result).toBe(mockResponse);
|
|
239
|
+
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
240
|
+
expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
|
|
241
|
+
expect(mockGetAppContext).toHaveBeenCalled();
|
|
242
|
+
expect(mockProcessRequest).toHaveBeenCalledWith(mockRequest, toolFunction);
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
-
it('should return 403
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
mockExtractBearerToken.mockReturnValue(bearerToken);
|
|
249
|
-
toolFunction.getMockValidateBearerToken().mockReturnValue(false);
|
|
245
|
+
it('should return 403 response with invalid token', async () => {
|
|
246
|
+
// Setup mock token verifier to return false
|
|
247
|
+
mockTokenVerifier.verify.mockResolvedValue(false);
|
|
250
248
|
|
|
251
|
-
// Act
|
|
252
249
|
const result = await toolFunction.perform();
|
|
253
250
|
|
|
254
|
-
// Assert
|
|
255
|
-
expect(mockExtractBearerToken).toHaveBeenCalledWith(mockRequest.headers);
|
|
256
|
-
expect(toolFunction.getMockValidateBearerToken()).toHaveBeenCalledWith(bearerToken);
|
|
257
|
-
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
258
251
|
expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
|
|
252
|
+
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
253
|
+
expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
|
|
254
|
+
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
259
255
|
});
|
|
260
256
|
|
|
261
|
-
it('should
|
|
262
|
-
//
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
257
|
+
it('should return 403 response when organization ID does not match', async () => {
|
|
258
|
+
// Update mock request with different customer_id
|
|
259
|
+
const requestWithDifferentOrgId = {
|
|
260
|
+
...mockRequest,
|
|
261
|
+
bodyJSON: {
|
|
262
|
+
...mockRequest.bodyJSON,
|
|
263
|
+
auth: {
|
|
264
|
+
...mockRequest.bodyJSON.auth,
|
|
265
|
+
credentials: {
|
|
266
|
+
...mockRequest.bodyJSON.auth.credentials,
|
|
267
|
+
customer_id: 'different-org-123'
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
};
|
|
271
272
|
|
|
272
|
-
|
|
273
|
-
expect(mockExtractBearerToken).toHaveBeenCalledWith(mockRequest.headers);
|
|
274
|
-
expect(toolFunction.getMockValidateBearerToken()).toHaveBeenCalledWith(complexToken);
|
|
275
|
-
expect(mockProcessRequest).toHaveBeenCalledWith(mockRequest, toolFunction);
|
|
276
|
-
expect(result).toBe(mockResponse);
|
|
277
|
-
});
|
|
273
|
+
const toolFunctionWithDifferentOrgId = new TestToolFunction(requestWithDifferentOrgId);
|
|
278
274
|
|
|
279
|
-
|
|
280
|
-
// Arrange
|
|
281
|
-
const bearerToken = 'malformed-token';
|
|
282
|
-
mockExtractBearerToken.mockReturnValue(bearerToken);
|
|
283
|
-
toolFunction.getMockValidateBearerToken().mockImplementation(() => {
|
|
284
|
-
throw new Error('Token validation error');
|
|
285
|
-
});
|
|
275
|
+
const result = await toolFunctionWithDifferentOrgId.perform();
|
|
286
276
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
expect(mockExtractBearerToken).toHaveBeenCalledWith(mockRequest.headers);
|
|
290
|
-
expect(toolFunction.getMockValidateBearerToken()).toHaveBeenCalledWith(bearerToken);
|
|
277
|
+
expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
|
|
278
|
+
expect(mockGetAppContext).toHaveBeenCalled();
|
|
291
279
|
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
292
280
|
});
|
|
293
281
|
|
|
294
|
-
it('should
|
|
295
|
-
//
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
282
|
+
it('should return 403 response when access token is missing', async () => {
|
|
283
|
+
// Create request without access token
|
|
284
|
+
const requestWithoutToken = {
|
|
285
|
+
...mockRequest,
|
|
286
|
+
bodyJSON: {
|
|
287
|
+
...mockRequest.bodyJSON,
|
|
288
|
+
auth: {
|
|
289
|
+
...mockRequest.bodyJSON.auth,
|
|
290
|
+
credentials: {
|
|
291
|
+
...mockRequest.bodyJSON.auth.credentials,
|
|
292
|
+
access_token: undefined
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
};
|
|
308
297
|
|
|
309
|
-
|
|
310
|
-
// Arrange
|
|
311
|
-
const bearerToken = 'test-token';
|
|
312
|
-
mockExtractBearerToken.mockReturnValue(bearerToken);
|
|
313
|
-
toolFunction.getMockValidateBearerToken().mockReturnValue(true);
|
|
314
|
-
mockProcessRequest.mockResolvedValue(mockResponse);
|
|
298
|
+
const toolFunctionWithoutToken = new TestToolFunction(requestWithoutToken);
|
|
315
299
|
|
|
316
|
-
|
|
317
|
-
await toolFunction.perform();
|
|
300
|
+
const result = await toolFunctionWithoutToken.perform();
|
|
318
301
|
|
|
319
|
-
|
|
320
|
-
expect(
|
|
321
|
-
expect(
|
|
302
|
+
expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
|
|
303
|
+
expect(mockGetTokenVerifier).not.toHaveBeenCalled();
|
|
304
|
+
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
322
305
|
});
|
|
323
306
|
|
|
324
|
-
it('should
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
307
|
+
it('should return 403 response when organisation id is missing', async () => {
|
|
308
|
+
// Create request without customer_id
|
|
309
|
+
const requestWithoutCustomerId = {
|
|
310
|
+
...mockRequest,
|
|
311
|
+
bodyJSON: {
|
|
312
|
+
...mockRequest.bodyJSON,
|
|
313
|
+
auth: {
|
|
314
|
+
...mockRequest.bodyJSON.auth,
|
|
315
|
+
credentials: {
|
|
316
|
+
...mockRequest.bodyJSON.auth.credentials,
|
|
317
|
+
customer_id: undefined
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
};
|
|
338
322
|
|
|
339
|
-
|
|
340
|
-
// Arrange
|
|
341
|
-
mockExtractBearerToken.mockReturnValue('');
|
|
342
|
-
mockProcessRequest.mockResolvedValue(mockResponse);
|
|
323
|
+
const toolFunctionWithoutCustomerId = new TestToolFunction(requestWithoutCustomerId);
|
|
343
324
|
|
|
344
|
-
|
|
345
|
-
const result = await toolFunction.perform();
|
|
325
|
+
const result = await toolFunctionWithoutCustomerId.perform();
|
|
346
326
|
|
|
347
|
-
|
|
348
|
-
expect(
|
|
349
|
-
expect(
|
|
350
|
-
expect(mockProcessRequest).toHaveBeenCalledWith(mockRequest, toolFunction);
|
|
351
|
-
expect(result).toBe(mockResponse);
|
|
327
|
+
expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
|
|
328
|
+
expect(mockGetTokenVerifier).not.toHaveBeenCalled();
|
|
329
|
+
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
352
330
|
});
|
|
353
331
|
|
|
354
|
-
it('should
|
|
355
|
-
// Create
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
332
|
+
it('should return 403 response when auth structure is missing', async () => {
|
|
333
|
+
// Create request without auth structure
|
|
334
|
+
const requestWithoutAuth = {
|
|
335
|
+
...mockRequest,
|
|
336
|
+
bodyJSON: {
|
|
337
|
+
parameters: mockRequest.bodyJSON.parameters,
|
|
338
|
+
environment: mockRequest.bodyJSON.environment
|
|
360
339
|
}
|
|
340
|
+
};
|
|
361
341
|
|
|
362
|
-
|
|
363
|
-
return (this as any).request;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
342
|
+
const toolFunctionWithoutAuth = new TestToolFunction(requestWithoutAuth);
|
|
366
343
|
|
|
367
|
-
|
|
368
|
-
const defaultToolFunction = new DefaultToolFunction(mockRequest);
|
|
369
|
-
const bearerToken = 'any-token';
|
|
370
|
-
mockExtractBearerToken.mockReturnValue(bearerToken);
|
|
371
|
-
mockProcessRequest.mockResolvedValue(mockResponse);
|
|
344
|
+
const result = await toolFunctionWithoutAuth.perform();
|
|
372
345
|
|
|
373
|
-
|
|
374
|
-
|
|
346
|
+
expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
|
|
347
|
+
expect(mockGetTokenVerifier).not.toHaveBeenCalled();
|
|
348
|
+
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
349
|
+
});
|
|
375
350
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
351
|
+
it('should return 403 response when token verifier initialization fails', async () => {
|
|
352
|
+
// Setup mock to fail during token verifier initialization
|
|
353
|
+
mockGetTokenVerifier.mockRejectedValue(new Error('Failed to initialize token verifier'));
|
|
354
|
+
|
|
355
|
+
const result = await toolFunction.perform();
|
|
356
|
+
|
|
357
|
+
expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
|
|
358
|
+
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
359
|
+
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
380
360
|
});
|
|
381
361
|
});
|
|
382
362
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { Function, Response, amendLogContext } from '@zaiusinc/app-sdk';
|
|
1
|
+
import { Function, Response, amendLogContext, getAppContext, logger } from '@zaiusinc/app-sdk';
|
|
2
2
|
import { toolsService } from '../service/Service';
|
|
3
|
+
import { getTokenVerifier } from '../auth/TokenVerifier';
|
|
4
|
+
import { OptiIdAuthData } from '../types/Models';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Abstract base class for tool-based function execution
|
|
@@ -24,8 +26,7 @@ export abstract class ToolFunction extends Function {
|
|
|
24
26
|
*/
|
|
25
27
|
public async perform(): Promise<Response> {
|
|
26
28
|
amendLogContext({ opalThreadId: this.request.headers.get('x-opal-thread-id') || '' });
|
|
27
|
-
|
|
28
|
-
if (bearerToken && !this.validateBearerToken(bearerToken)) {
|
|
29
|
+
if (!(await this.authorizeRequest())) {
|
|
29
30
|
return new Response(403, { error: 'Forbidden' });
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -38,15 +39,44 @@ export abstract class ToolFunction extends Function {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
|
-
*
|
|
42
|
+
* Authenticate the incoming request by validating the OptiID token and organization ID
|
|
42
43
|
*
|
|
43
|
-
*
|
|
44
|
-
* Subclasses can override this method to implement custom bearer token validation logic.
|
|
45
|
-
*
|
|
46
|
-
* @param _bearerToken - The bearer token extracted from the Authorization header
|
|
47
|
-
* @returns true if the token is valid and the request should proceed, false to return 403 Forbidden
|
|
44
|
+
* @throws true if authentication succeeds
|
|
48
45
|
*/
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
private async authorizeRequest(): Promise<boolean> {
|
|
47
|
+
const authData = this.request.bodyJSON?.auth as OptiIdAuthData;
|
|
48
|
+
const accessToken = authData?.credentials?.access_token;
|
|
49
|
+
if (!accessToken || authData?.provider?.toLowerCase() !== 'optiid') {
|
|
50
|
+
logger.error('OptiID token is required but not provided');
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const customerId = authData.credentials?.customer_id;
|
|
55
|
+
if (!customerId) {
|
|
56
|
+
logger.error('Organisation ID is required but not provided');
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const appOrganisationId = getAppContext().account.organizationId;
|
|
61
|
+
if (customerId !== appOrganisationId) {
|
|
62
|
+
logger.error(`Invalid organisation ID: expected ${appOrganisationId}, received ${customerId}`);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return await this.validateAccessToken(accessToken);
|
|
51
67
|
}
|
|
68
|
+
|
|
69
|
+
private async validateAccessToken(accessToken: string | undefined): Promise<boolean> {
|
|
70
|
+
try {
|
|
71
|
+
if (!accessToken) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const tokenVerifier = await getTokenVerifier();
|
|
75
|
+
return await tokenVerifier.verify(accessToken);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
logger.error('OptiID token validation failed:', error);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
52
82
|
}
|
package/src/index.ts
CHANGED
|
@@ -122,7 +122,8 @@ describe('ToolsService', () => {
|
|
|
122
122
|
description: mockTool.description,
|
|
123
123
|
parameters: mockTool.parameters.map((p: Parameter) => p.toJSON()),
|
|
124
124
|
endpoint: mockTool.endpoint,
|
|
125
|
-
http_method: 'POST'
|
|
125
|
+
http_method: 'POST',
|
|
126
|
+
auth_requirements: [{ provider: 'OptiID', scope_bundle: 'default', required: true }]
|
|
126
127
|
});
|
|
127
128
|
});
|
|
128
129
|
|
|
@@ -181,7 +182,8 @@ describe('ToolsService', () => {
|
|
|
181
182
|
description: mockTool.description,
|
|
182
183
|
parameters: mockTool.parameters.map((p: Parameter) => p.toJSON()),
|
|
183
184
|
endpoint: mockTool.endpoint,
|
|
184
|
-
http_method: 'POST'
|
|
185
|
+
http_method: 'POST',
|
|
186
|
+
auth_requirements: [{ provider: 'OptiID', scope_bundle: 'default', required: true }]
|
|
185
187
|
});
|
|
186
188
|
|
|
187
189
|
expect(secondFunction).toEqual({
|
|
@@ -190,7 +192,10 @@ describe('ToolsService', () => {
|
|
|
190
192
|
parameters: [],
|
|
191
193
|
endpoint: '/second-tool',
|
|
192
194
|
http_method: 'POST',
|
|
193
|
-
auth_requirements:
|
|
195
|
+
auth_requirements: [
|
|
196
|
+
{ provider: 'oauth2', scope_bundle: 'calendar', required: true },
|
|
197
|
+
{ provider: 'OptiID', scope_bundle: 'default', required: true }
|
|
198
|
+
]
|
|
194
199
|
});
|
|
195
200
|
});
|
|
196
201
|
});
|