@optimizely-opal/opal-tool-ocp-sdk 1.0.0-beta.3 → 1.0.0-beta.4
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 +114 -0
- package/dist/auth/AuthUtils.d.ts +5 -5
- package/dist/auth/AuthUtils.d.ts.map +1 -1
- package/dist/auth/AuthUtils.js +53 -25
- package/dist/auth/AuthUtils.js.map +1 -1
- package/dist/auth/AuthUtils.test.js +62 -117
- package/dist/auth/AuthUtils.test.js.map +1 -1
- package/dist/function/GlobalToolFunction.d.ts +1 -1
- package/dist/function/GlobalToolFunction.d.ts.map +1 -1
- package/dist/function/GlobalToolFunction.js +17 -4
- package/dist/function/GlobalToolFunction.js.map +1 -1
- package/dist/function/GlobalToolFunction.test.js +54 -8
- package/dist/function/GlobalToolFunction.test.js.map +1 -1
- package/dist/function/ToolFunction.d.ts +1 -1
- package/dist/function/ToolFunction.d.ts.map +1 -1
- package/dist/function/ToolFunction.js +17 -4
- package/dist/function/ToolFunction.js.map +1 -1
- package/dist/function/ToolFunction.test.js +54 -8
- 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 +15 -2
- package/dist/service/Service.d.ts.map +1 -1
- package/dist/service/Service.js +43 -17
- package/dist/service/Service.js.map +1 -1
- package/dist/service/Service.test.js +84 -2
- package/dist/service/Service.test.js.map +1 -1
- package/dist/types/ToolError.d.ts +59 -0
- package/dist/types/ToolError.d.ts.map +1 -0
- package/dist/types/ToolError.js +79 -0
- package/dist/types/ToolError.js.map +1 -0
- package/dist/types/ToolError.test.d.ts +2 -0
- package/dist/types/ToolError.test.d.ts.map +1 -0
- package/dist/types/ToolError.test.js +161 -0
- package/dist/types/ToolError.test.js.map +1 -0
- package/dist/validation/ParameterValidator.d.ts +5 -16
- package/dist/validation/ParameterValidator.d.ts.map +1 -1
- package/dist/validation/ParameterValidator.js +10 -3
- package/dist/validation/ParameterValidator.js.map +1 -1
- package/dist/validation/ParameterValidator.test.js +186 -146
- package/dist/validation/ParameterValidator.test.js.map +1 -1
- package/package.json +1 -1
- package/src/auth/AuthUtils.test.ts +62 -157
- package/src/auth/AuthUtils.ts +66 -32
- package/src/function/GlobalToolFunction.test.ts +54 -8
- package/src/function/GlobalToolFunction.ts +26 -6
- package/src/function/ToolFunction.test.ts +54 -8
- package/src/function/ToolFunction.ts +26 -6
- package/src/index.ts +1 -0
- package/src/service/Service.test.ts +103 -2
- package/src/service/Service.ts +45 -17
- package/src/types/ToolError.test.ts +192 -0
- package/src/types/ToolError.ts +95 -0
- package/src/validation/ParameterValidator.test.ts +185 -158
- package/src/validation/ParameterValidator.ts +17 -20
package/src/auth/AuthUtils.ts
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
1
1
|
import { getAppContext, logger } from '@zaiusinc/app-sdk';
|
|
2
2
|
import { getTokenVerifier } from './TokenVerifier';
|
|
3
3
|
import { OptiIdAuthData } from '../types/Models';
|
|
4
|
+
import { ToolError } from '../types/ToolError';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Validate the OptiID access token
|
|
7
8
|
*
|
|
8
9
|
* @param accessToken - The access token to validate
|
|
9
|
-
* @
|
|
10
|
+
* @throws {ToolError} If token validation fails
|
|
10
11
|
*/
|
|
11
|
-
async function validateAccessToken(accessToken: string | undefined): Promise<
|
|
12
|
+
async function validateAccessToken(accessToken: string | undefined): Promise<void> {
|
|
12
13
|
try {
|
|
13
14
|
if (!accessToken) {
|
|
14
|
-
|
|
15
|
+
throw new ToolError('Forbidden', 403, 'OptiID access token is required');
|
|
15
16
|
}
|
|
16
17
|
const tokenVerifier = await getTokenVerifier();
|
|
17
|
-
|
|
18
|
+
const isValid = await tokenVerifier.verify(accessToken);
|
|
19
|
+
if (!isValid) {
|
|
20
|
+
throw new ToolError('Forbidden', 403, 'Invalid OptiID access token');
|
|
21
|
+
}
|
|
18
22
|
} catch (error) {
|
|
23
|
+
if (error instanceof ToolError) {
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
19
26
|
logger.error('OptiID token validation failed:', error);
|
|
20
|
-
|
|
27
|
+
throw new ToolError('Forbidden', 403, 'Token verification failed');
|
|
21
28
|
}
|
|
22
29
|
}
|
|
23
30
|
|
|
@@ -25,37 +32,70 @@ async function validateAccessToken(accessToken: string | undefined): Promise<boo
|
|
|
25
32
|
* Extract and validate basic OptiID authentication data from request
|
|
26
33
|
*
|
|
27
34
|
* @param request - The incoming request
|
|
28
|
-
* @returns object with authData and accessToken, or null if
|
|
35
|
+
* @returns object with authData and accessToken, or null if auth is missing
|
|
29
36
|
*/
|
|
30
37
|
export function extractAuthData(request: any): { authData: OptiIdAuthData; accessToken: string } | null {
|
|
31
38
|
const authData = request?.bodyJSON?.auth as OptiIdAuthData;
|
|
39
|
+
|
|
40
|
+
if (!authData) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (authData?.provider?.toLowerCase() !== 'optiid') {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
const accessToken = authData?.credentials?.access_token;
|
|
33
|
-
if (!accessToken
|
|
49
|
+
if (!accessToken) {
|
|
34
50
|
return null;
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
return { authData, accessToken };
|
|
38
54
|
}
|
|
39
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Extract and validate auth data with error throwing for authentication flow
|
|
58
|
+
*
|
|
59
|
+
* @param request - The incoming request
|
|
60
|
+
* @returns object with authData and accessToken
|
|
61
|
+
* @throws {ToolError} If auth data is invalid or missing
|
|
62
|
+
*/
|
|
63
|
+
function extractAndValidateAuthData(request: any): { authData: OptiIdAuthData; accessToken: string } {
|
|
64
|
+
const authData = request?.bodyJSON?.auth as OptiIdAuthData;
|
|
65
|
+
|
|
66
|
+
if (!authData) {
|
|
67
|
+
throw new ToolError('Forbidden', 403, 'Authentication data is required');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (authData?.provider?.toLowerCase() !== 'optiid') {
|
|
71
|
+
throw new ToolError('Forbidden', 403, 'Only OptiID authentication provider is supported');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const accessToken = authData?.credentials?.access_token;
|
|
75
|
+
if (!accessToken) {
|
|
76
|
+
throw new ToolError('Forbidden', 403, 'OptiID access token is required');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { authData, accessToken };
|
|
80
|
+
}
|
|
81
|
+
|
|
40
82
|
/**
|
|
41
83
|
* Validate organization ID matches the app context
|
|
42
84
|
*
|
|
43
85
|
* @param customerId - The customer ID from the auth data
|
|
44
|
-
* @
|
|
86
|
+
* @throws {ToolError} If organization ID is invalid or missing
|
|
45
87
|
*/
|
|
46
|
-
function validateOrganizationId(customerId: string | undefined):
|
|
88
|
+
function validateOrganizationId(customerId: string | undefined): void {
|
|
47
89
|
if (!customerId) {
|
|
48
90
|
logger.error('Organisation ID is required but not provided');
|
|
49
|
-
|
|
91
|
+
throw new ToolError('Forbidden', 403, 'Organization ID is required');
|
|
50
92
|
}
|
|
51
93
|
|
|
52
94
|
const appOrganisationId = getAppContext()?.account?.organizationId;
|
|
53
95
|
if (customerId !== appOrganisationId) {
|
|
54
96
|
logger.error(`Invalid organisation ID: expected ${appOrganisationId}, received ${customerId}`);
|
|
55
|
-
|
|
97
|
+
throw new ToolError('Forbidden', 403, 'Organization ID does not match');
|
|
56
98
|
}
|
|
57
|
-
|
|
58
|
-
return true;
|
|
59
99
|
}
|
|
60
100
|
|
|
61
101
|
/**
|
|
@@ -73,45 +113,39 @@ function shouldSkipAuth(request: any): boolean {
|
|
|
73
113
|
*
|
|
74
114
|
* @param request - The incoming request
|
|
75
115
|
* @param validateOrg - Whether to validate organization ID
|
|
76
|
-
* @
|
|
116
|
+
* @throws {ToolError} If authentication fails
|
|
77
117
|
*/
|
|
78
|
-
async function authenticateRequest(request: any, validateOrg: boolean): Promise<
|
|
118
|
+
async function authenticateRequest(request: any, validateOrg: boolean): Promise<void> {
|
|
79
119
|
if (shouldSkipAuth(request)) {
|
|
80
|
-
return
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const authInfo = extractAuthData(request);
|
|
84
|
-
if (!authInfo) {
|
|
85
|
-
logger.error('OptiID token is required but not provided');
|
|
86
|
-
return false;
|
|
120
|
+
return;
|
|
87
121
|
}
|
|
88
122
|
|
|
89
|
-
const { authData, accessToken } =
|
|
123
|
+
const { authData, accessToken } = extractAndValidateAuthData(request);
|
|
90
124
|
|
|
91
125
|
// Validate organization ID if required
|
|
92
|
-
if (validateOrg
|
|
93
|
-
|
|
126
|
+
if (validateOrg) {
|
|
127
|
+
validateOrganizationId(authData.credentials?.customer_id);
|
|
94
128
|
}
|
|
95
129
|
|
|
96
|
-
|
|
130
|
+
await validateAccessToken(accessToken);
|
|
97
131
|
}
|
|
98
132
|
|
|
99
133
|
/**
|
|
100
134
|
* Authenticate a request for regular functions (with organization validation)
|
|
101
135
|
*
|
|
102
136
|
* @param request - The incoming request
|
|
103
|
-
* @
|
|
137
|
+
* @throws {ToolError} If authentication or authorization fails
|
|
104
138
|
*/
|
|
105
|
-
export async function authenticateRegularRequest(request: any): Promise<
|
|
106
|
-
|
|
139
|
+
export async function authenticateRegularRequest(request: any): Promise<void> {
|
|
140
|
+
await authenticateRequest(request, true);
|
|
107
141
|
}
|
|
108
142
|
|
|
109
143
|
/**
|
|
110
144
|
* Authenticate a request for global functions (without organization validation)
|
|
111
145
|
*
|
|
112
146
|
* @param request - The incoming request
|
|
113
|
-
* @
|
|
147
|
+
* @throws {ToolError} If authentication fails
|
|
114
148
|
*/
|
|
115
|
-
export async function authenticateGlobalRequest(request: any): Promise<
|
|
116
|
-
|
|
149
|
+
export async function authenticateGlobalRequest(request: any): Promise<void> {
|
|
150
|
+
await authenticateRequest(request, false);
|
|
117
151
|
}
|
|
@@ -22,12 +22,22 @@ jest.mock('@zaiusinc/app-sdk', () => ({
|
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
24
|
Request: jest.fn().mockImplementation(() => ({})),
|
|
25
|
-
Response: jest.fn().mockImplementation((status, data) => ({
|
|
25
|
+
Response: jest.fn().mockImplementation((status, data, headers) => ({
|
|
26
26
|
status,
|
|
27
27
|
data,
|
|
28
28
|
bodyJSON: data,
|
|
29
|
-
bodyAsU8Array: new Uint8Array()
|
|
29
|
+
bodyAsU8Array: new Uint8Array(),
|
|
30
|
+
headers
|
|
30
31
|
})),
|
|
32
|
+
Headers: jest.fn().mockImplementation((entries) => {
|
|
33
|
+
const headers = new Map();
|
|
34
|
+
if (entries) {
|
|
35
|
+
for (const [key, value] of entries) {
|
|
36
|
+
headers.set(key, value);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return headers;
|
|
40
|
+
}),
|
|
31
41
|
amendLogContext: jest.fn(),
|
|
32
42
|
getAppContext: jest.fn(),
|
|
33
43
|
logger: {
|
|
@@ -347,7 +357,13 @@ describe('GlobalToolFunction', () => {
|
|
|
347
357
|
|
|
348
358
|
const result = await globalToolFunction.perform();
|
|
349
359
|
|
|
350
|
-
expect(result).
|
|
360
|
+
expect(result.status).toBe(403);
|
|
361
|
+
expect(result.bodyJSON).toEqual({
|
|
362
|
+
title: 'Forbidden',
|
|
363
|
+
status: 403,
|
|
364
|
+
detail: 'Invalid OptiID access token',
|
|
365
|
+
instance: '/test'
|
|
366
|
+
});
|
|
351
367
|
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
352
368
|
expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
|
|
353
369
|
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
@@ -373,7 +389,13 @@ describe('GlobalToolFunction', () => {
|
|
|
373
389
|
|
|
374
390
|
const result = await globalToolFunctionWithoutToken.perform();
|
|
375
391
|
|
|
376
|
-
expect(result).
|
|
392
|
+
expect(result.status).toBe(403);
|
|
393
|
+
expect(result.bodyJSON).toEqual({
|
|
394
|
+
title: 'Forbidden',
|
|
395
|
+
status: 403,
|
|
396
|
+
detail: 'OptiID access token is required',
|
|
397
|
+
instance: '/test'
|
|
398
|
+
});
|
|
377
399
|
expect(mockGetTokenVerifier).not.toHaveBeenCalled();
|
|
378
400
|
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
379
401
|
});
|
|
@@ -395,7 +417,13 @@ describe('GlobalToolFunction', () => {
|
|
|
395
417
|
|
|
396
418
|
const result = await globalToolFunctionWithDifferentProvider.perform();
|
|
397
419
|
|
|
398
|
-
expect(result).
|
|
420
|
+
expect(result.status).toBe(403);
|
|
421
|
+
expect(result.bodyJSON).toEqual({
|
|
422
|
+
title: 'Forbidden',
|
|
423
|
+
status: 403,
|
|
424
|
+
detail: 'Only OptiID authentication provider is supported',
|
|
425
|
+
instance: '/test'
|
|
426
|
+
});
|
|
399
427
|
expect(mockGetTokenVerifier).not.toHaveBeenCalled();
|
|
400
428
|
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
401
429
|
});
|
|
@@ -414,7 +442,13 @@ describe('GlobalToolFunction', () => {
|
|
|
414
442
|
|
|
415
443
|
const result = await globalToolFunctionWithoutAuth.perform();
|
|
416
444
|
|
|
417
|
-
expect(result).
|
|
445
|
+
expect(result.status).toBe(403);
|
|
446
|
+
expect(result.bodyJSON).toEqual({
|
|
447
|
+
title: 'Forbidden',
|
|
448
|
+
status: 403,
|
|
449
|
+
detail: 'Authentication data is required',
|
|
450
|
+
instance: '/test'
|
|
451
|
+
});
|
|
418
452
|
expect(mockGetTokenVerifier).not.toHaveBeenCalled();
|
|
419
453
|
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
420
454
|
});
|
|
@@ -425,7 +459,13 @@ describe('GlobalToolFunction', () => {
|
|
|
425
459
|
|
|
426
460
|
const result = await globalToolFunction.perform();
|
|
427
461
|
|
|
428
|
-
expect(result).
|
|
462
|
+
expect(result.status).toBe(403);
|
|
463
|
+
expect(result.bodyJSON).toEqual({
|
|
464
|
+
title: 'Forbidden',
|
|
465
|
+
status: 403,
|
|
466
|
+
detail: 'Token verification failed',
|
|
467
|
+
instance: '/test'
|
|
468
|
+
});
|
|
429
469
|
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
430
470
|
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
431
471
|
});
|
|
@@ -436,7 +476,13 @@ describe('GlobalToolFunction', () => {
|
|
|
436
476
|
|
|
437
477
|
const result = await globalToolFunction.perform();
|
|
438
478
|
|
|
439
|
-
expect(result).
|
|
479
|
+
expect(result.status).toBe(403);
|
|
480
|
+
expect(result.bodyJSON).toEqual({
|
|
481
|
+
title: 'Forbidden',
|
|
482
|
+
status: 403,
|
|
483
|
+
detail: 'Token verification failed',
|
|
484
|
+
instance: '/test'
|
|
485
|
+
});
|
|
440
486
|
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
441
487
|
expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
|
|
442
488
|
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { GlobalFunction, Response, amendLogContext } from '@zaiusinc/app-sdk';
|
|
1
|
+
import { GlobalFunction, Response, Headers, amendLogContext } from '@zaiusinc/app-sdk';
|
|
2
2
|
import { authenticateGlobalRequest, extractAuthData } from '../auth/AuthUtils';
|
|
3
3
|
import { toolsService } from '../service/Service';
|
|
4
4
|
import { ToolLogger } from '../logging/ToolLogger';
|
|
5
|
+
import { ToolError } from '../types/ToolError';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Abstract base class for global tool-based function execution
|
|
@@ -44,8 +45,27 @@ export abstract class GlobalToolFunction extends GlobalFunction {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
private async handleRequest(): Promise<Response> {
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
try {
|
|
49
|
+
await this.authorizeRequest();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof ToolError) {
|
|
52
|
+
return new Response(
|
|
53
|
+
error.status,
|
|
54
|
+
error.toProblemDetails(this.request.path),
|
|
55
|
+
new Headers([['content-type', 'application/problem+json']])
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
// Fallback for unexpected errors
|
|
59
|
+
return new Response(
|
|
60
|
+
500,
|
|
61
|
+
{
|
|
62
|
+
title: 'Internal Server Error',
|
|
63
|
+
status: 500,
|
|
64
|
+
detail: 'An unexpected error occurred during authentication',
|
|
65
|
+
instance: this.request.path
|
|
66
|
+
},
|
|
67
|
+
new Headers([['content-type', 'application/problem+json']])
|
|
68
|
+
);
|
|
49
69
|
}
|
|
50
70
|
|
|
51
71
|
if (this.request.path === '/ready') {
|
|
@@ -59,9 +79,9 @@ export abstract class GlobalToolFunction extends GlobalFunction {
|
|
|
59
79
|
/**
|
|
60
80
|
* Authenticate the incoming request by validating only the OptiID token
|
|
61
81
|
*
|
|
62
|
-
* @
|
|
82
|
+
* @throws {ToolError} If authentication fails
|
|
63
83
|
*/
|
|
64
|
-
private async authorizeRequest(): Promise<
|
|
65
|
-
|
|
84
|
+
private async authorizeRequest(): Promise<void> {
|
|
85
|
+
await authenticateGlobalRequest(this.request);
|
|
66
86
|
}
|
|
67
87
|
}
|
|
@@ -22,12 +22,22 @@ jest.mock('@zaiusinc/app-sdk', () => ({
|
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
24
|
Request: jest.fn().mockImplementation(() => ({})),
|
|
25
|
-
Response: jest.fn().mockImplementation((status, data) => ({
|
|
25
|
+
Response: jest.fn().mockImplementation((status, data, headers) => ({
|
|
26
26
|
status,
|
|
27
27
|
data,
|
|
28
28
|
bodyJSON: data,
|
|
29
|
-
bodyAsU8Array: new Uint8Array()
|
|
29
|
+
bodyAsU8Array: new Uint8Array(),
|
|
30
|
+
headers
|
|
30
31
|
})),
|
|
32
|
+
Headers: jest.fn().mockImplementation((entries) => {
|
|
33
|
+
const headers = new Map();
|
|
34
|
+
if (entries) {
|
|
35
|
+
for (const [key, value] of entries) {
|
|
36
|
+
headers.set(key, value);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return headers;
|
|
40
|
+
}),
|
|
31
41
|
amendLogContext: jest.fn(),
|
|
32
42
|
getAppContext: jest.fn(),
|
|
33
43
|
logger: {
|
|
@@ -251,7 +261,13 @@ describe('ToolFunction', () => {
|
|
|
251
261
|
|
|
252
262
|
const result = await toolFunction.perform();
|
|
253
263
|
|
|
254
|
-
expect(result).
|
|
264
|
+
expect(result.status).toBe(403);
|
|
265
|
+
expect(result.bodyJSON).toEqual({
|
|
266
|
+
title: 'Forbidden',
|
|
267
|
+
status: 403,
|
|
268
|
+
detail: 'Invalid OptiID access token',
|
|
269
|
+
instance: '/test'
|
|
270
|
+
});
|
|
255
271
|
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
256
272
|
expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
|
|
257
273
|
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
@@ -277,7 +293,13 @@ describe('ToolFunction', () => {
|
|
|
277
293
|
|
|
278
294
|
const result = await toolFunctionWithDifferentOrgId.perform();
|
|
279
295
|
|
|
280
|
-
expect(result).
|
|
296
|
+
expect(result.status).toBe(403);
|
|
297
|
+
expect(result.bodyJSON).toEqual({
|
|
298
|
+
title: 'Forbidden',
|
|
299
|
+
status: 403,
|
|
300
|
+
detail: 'Organization ID does not match',
|
|
301
|
+
instance: '/test'
|
|
302
|
+
});
|
|
281
303
|
expect(mockGetAppContext).toHaveBeenCalled();
|
|
282
304
|
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
283
305
|
});
|
|
@@ -302,7 +324,13 @@ describe('ToolFunction', () => {
|
|
|
302
324
|
|
|
303
325
|
const result = await toolFunctionWithoutToken.perform();
|
|
304
326
|
|
|
305
|
-
expect(result).
|
|
327
|
+
expect(result.status).toBe(403);
|
|
328
|
+
expect(result.bodyJSON).toEqual({
|
|
329
|
+
title: 'Forbidden',
|
|
330
|
+
status: 403,
|
|
331
|
+
detail: 'OptiID access token is required',
|
|
332
|
+
instance: '/test'
|
|
333
|
+
});
|
|
306
334
|
expect(mockGetTokenVerifier).not.toHaveBeenCalled();
|
|
307
335
|
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
308
336
|
});
|
|
@@ -327,7 +355,13 @@ describe('ToolFunction', () => {
|
|
|
327
355
|
|
|
328
356
|
const result = await toolFunctionWithoutCustomerId.perform();
|
|
329
357
|
|
|
330
|
-
expect(result).
|
|
358
|
+
expect(result.status).toBe(403);
|
|
359
|
+
expect(result.bodyJSON).toEqual({
|
|
360
|
+
title: 'Forbidden',
|
|
361
|
+
status: 403,
|
|
362
|
+
detail: 'Organization ID is required',
|
|
363
|
+
instance: '/test'
|
|
364
|
+
});
|
|
331
365
|
expect(mockGetTokenVerifier).not.toHaveBeenCalled();
|
|
332
366
|
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
333
367
|
});
|
|
@@ -346,7 +380,13 @@ describe('ToolFunction', () => {
|
|
|
346
380
|
|
|
347
381
|
const result = await toolFunctionWithoutAuth.perform();
|
|
348
382
|
|
|
349
|
-
expect(result).
|
|
383
|
+
expect(result.status).toBe(403);
|
|
384
|
+
expect(result.bodyJSON).toEqual({
|
|
385
|
+
title: 'Forbidden',
|
|
386
|
+
status: 403,
|
|
387
|
+
detail: 'Authentication data is required',
|
|
388
|
+
instance: '/test'
|
|
389
|
+
});
|
|
350
390
|
expect(mockGetTokenVerifier).not.toHaveBeenCalled();
|
|
351
391
|
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
352
392
|
});
|
|
@@ -357,7 +397,13 @@ describe('ToolFunction', () => {
|
|
|
357
397
|
|
|
358
398
|
const result = await toolFunction.perform();
|
|
359
399
|
|
|
360
|
-
expect(result).
|
|
400
|
+
expect(result.status).toBe(403);
|
|
401
|
+
expect(result.bodyJSON).toEqual({
|
|
402
|
+
title: 'Forbidden',
|
|
403
|
+
status: 403,
|
|
404
|
+
detail: 'Token verification failed',
|
|
405
|
+
instance: '/test'
|
|
406
|
+
});
|
|
361
407
|
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
362
408
|
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
363
409
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { Function, Response, amendLogContext } from '@zaiusinc/app-sdk';
|
|
1
|
+
import { Function, Response, Headers, amendLogContext } from '@zaiusinc/app-sdk';
|
|
2
2
|
import { authenticateRegularRequest } from '../auth/AuthUtils';
|
|
3
3
|
import { toolsService } from '../service/Service';
|
|
4
4
|
import { ToolLogger } from '../logging/ToolLogger';
|
|
5
|
+
import { ToolError } from '../types/ToolError';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Abstract base class for tool-based function execution
|
|
@@ -42,8 +43,27 @@ export abstract class ToolFunction extends Function {
|
|
|
42
43
|
* @returns Response as the HTTP response
|
|
43
44
|
*/
|
|
44
45
|
private async handleRequest(): Promise<Response> {
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
try {
|
|
47
|
+
await this.authorizeRequest();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error instanceof ToolError) {
|
|
50
|
+
return new Response(
|
|
51
|
+
error.status,
|
|
52
|
+
error.toProblemDetails(this.request.path),
|
|
53
|
+
new Headers([['content-type', 'application/problem+json']])
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
// Fallback for unexpected errors
|
|
57
|
+
return new Response(
|
|
58
|
+
500,
|
|
59
|
+
{
|
|
60
|
+
title: 'Internal Server Error',
|
|
61
|
+
status: 500,
|
|
62
|
+
detail: 'An unexpected error occurred during authentication',
|
|
63
|
+
instance: this.request.path
|
|
64
|
+
},
|
|
65
|
+
new Headers([['content-type', 'application/problem+json']])
|
|
66
|
+
);
|
|
47
67
|
}
|
|
48
68
|
|
|
49
69
|
if (this.request.path === '/ready') {
|
|
@@ -58,9 +78,9 @@ export abstract class ToolFunction extends Function {
|
|
|
58
78
|
/**
|
|
59
79
|
* Authenticate the incoming request by validating the OptiID token and organization ID
|
|
60
80
|
*
|
|
61
|
-
* @
|
|
81
|
+
* @throws {ToolError} If authentication fails
|
|
62
82
|
*/
|
|
63
|
-
private async authorizeRequest(): Promise<
|
|
64
|
-
|
|
83
|
+
private async authorizeRequest(): Promise<void> {
|
|
84
|
+
await authenticateRegularRequest(this.request);
|
|
65
85
|
}
|
|
66
86
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './function/ToolFunction';
|
|
2
2
|
export * from './function/GlobalToolFunction';
|
|
3
3
|
export * from './types/Models';
|
|
4
|
+
export * from './types/ToolError';
|
|
4
5
|
export * from './decorator/Decorator';
|
|
5
6
|
export * from './auth/TokenVerifier';
|
|
6
7
|
export { Tool, Interaction, InteractionResult } from './service/Service';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { toolsService, Tool, Interaction } from './Service';
|
|
2
2
|
import { Parameter, ParameterType, AuthRequirement, OptiIdAuthDataCredentials, OptiIdAuthData } from '../types/Models';
|
|
3
|
+
import { ToolError } from '../types/ToolError';
|
|
3
4
|
import { ToolFunction } from '../function/ToolFunction';
|
|
4
5
|
import { logger } from '@zaiusinc/app-sdk';
|
|
5
6
|
|
|
@@ -370,7 +371,7 @@ describe('ToolsService', () => {
|
|
|
370
371
|
);
|
|
371
372
|
});
|
|
372
373
|
|
|
373
|
-
it('should return 500 error when tool handler throws
|
|
374
|
+
it('should return 500 error in RFC 9457 format when tool handler throws a regular error', async () => {
|
|
374
375
|
const errorMessage = 'Tool execution failed';
|
|
375
376
|
jest.mocked(mockTool.handler).mockRejectedValueOnce(new Error(errorMessage));
|
|
376
377
|
|
|
@@ -378,6 +379,13 @@ describe('ToolsService', () => {
|
|
|
378
379
|
const response = await toolsService.processRequest(mockRequest, mockToolFunction);
|
|
379
380
|
|
|
380
381
|
expect(response.status).toBe(500);
|
|
382
|
+
expect(response.bodyJSON).toEqual({
|
|
383
|
+
title: 'Internal Server Error',
|
|
384
|
+
status: 500,
|
|
385
|
+
detail: errorMessage,
|
|
386
|
+
instance: mockTool.endpoint
|
|
387
|
+
});
|
|
388
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
381
389
|
expect(logger.error).toHaveBeenCalledWith(
|
|
382
390
|
`Error in function ${mockTool.name}:`,
|
|
383
391
|
expect.any(Error)
|
|
@@ -391,6 +399,67 @@ describe('ToolsService', () => {
|
|
|
391
399
|
const response = await toolsService.processRequest(mockRequest, mockToolFunction);
|
|
392
400
|
|
|
393
401
|
expect(response.status).toBe(500);
|
|
402
|
+
expect(response.bodyJSON).toEqual({
|
|
403
|
+
title: 'Internal Server Error',
|
|
404
|
+
status: 500,
|
|
405
|
+
detail: 'An unexpected error occurred',
|
|
406
|
+
instance: mockTool.endpoint
|
|
407
|
+
});
|
|
408
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('should return custom status code when tool handler throws ToolError', async () => {
|
|
412
|
+
const toolError = new ToolError('Resource not found', 404, 'The requested task does not exist');
|
|
413
|
+
jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
|
|
414
|
+
|
|
415
|
+
const mockRequest = createMockRequest();
|
|
416
|
+
const response = await toolsService.processRequest(mockRequest, mockToolFunction);
|
|
417
|
+
|
|
418
|
+
expect(response.status).toBe(404);
|
|
419
|
+
expect(response.bodyJSON).toEqual({
|
|
420
|
+
title: 'Resource not found',
|
|
421
|
+
status: 404,
|
|
422
|
+
detail: 'The requested task does not exist',
|
|
423
|
+
instance: mockTool.endpoint
|
|
424
|
+
});
|
|
425
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
426
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
427
|
+
`Error in function ${mockTool.name}:`,
|
|
428
|
+
expect.any(ToolError)
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('should return ToolError without detail field when detail is not provided', async () => {
|
|
433
|
+
const toolError = new ToolError('Bad request', 400);
|
|
434
|
+
jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
|
|
435
|
+
|
|
436
|
+
const mockRequest = createMockRequest();
|
|
437
|
+
const response = await toolsService.processRequest(mockRequest, mockToolFunction);
|
|
438
|
+
|
|
439
|
+
expect(response.status).toBe(400);
|
|
440
|
+
expect(response.bodyJSON).toEqual({
|
|
441
|
+
title: 'Bad request',
|
|
442
|
+
status: 400,
|
|
443
|
+
instance: mockTool.endpoint
|
|
444
|
+
});
|
|
445
|
+
expect(response.bodyJSON).not.toHaveProperty('detail');
|
|
446
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('should default to 500 when ToolError is created without status', async () => {
|
|
450
|
+
const toolError = new ToolError('Database error');
|
|
451
|
+
jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
|
|
452
|
+
|
|
453
|
+
const mockRequest = createMockRequest();
|
|
454
|
+
const response = await toolsService.processRequest(mockRequest, mockToolFunction);
|
|
455
|
+
|
|
456
|
+
expect(response.status).toBe(500);
|
|
457
|
+
expect(response.bodyJSON).toEqual({
|
|
458
|
+
title: 'Database error',
|
|
459
|
+
status: 500,
|
|
460
|
+
instance: mockTool.endpoint
|
|
461
|
+
});
|
|
462
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
394
463
|
});
|
|
395
464
|
});
|
|
396
465
|
|
|
@@ -488,7 +557,7 @@ describe('ToolsService', () => {
|
|
|
488
557
|
);
|
|
489
558
|
});
|
|
490
559
|
|
|
491
|
-
it('should return 500 error when interaction handler throws
|
|
560
|
+
it('should return 500 error in RFC 9457 format when interaction handler throws a regular error', async () => {
|
|
492
561
|
const errorMessage = 'Interaction execution failed';
|
|
493
562
|
jest.mocked(mockInteraction.handler).mockRejectedValueOnce(new Error(errorMessage));
|
|
494
563
|
|
|
@@ -500,11 +569,43 @@ describe('ToolsService', () => {
|
|
|
500
569
|
const response = await toolsService.processRequest(interactionRequest, mockToolFunction);
|
|
501
570
|
|
|
502
571
|
expect(response.status).toBe(500);
|
|
572
|
+
expect(response.bodyJSON).toEqual({
|
|
573
|
+
title: 'Internal Server Error',
|
|
574
|
+
status: 500,
|
|
575
|
+
detail: errorMessage,
|
|
576
|
+
instance: mockInteraction.endpoint
|
|
577
|
+
});
|
|
578
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
503
579
|
expect(logger.error).toHaveBeenCalledWith(
|
|
504
580
|
`Error in function ${mockInteraction.name}:`,
|
|
505
581
|
expect.any(Error)
|
|
506
582
|
);
|
|
507
583
|
});
|
|
584
|
+
|
|
585
|
+
it('should return custom status code when interaction handler throws ToolError', async () => {
|
|
586
|
+
const toolError = new ToolError('Webhook validation failed', 400, 'Invalid signature');
|
|
587
|
+
jest.mocked(mockInteraction.handler).mockRejectedValueOnce(toolError);
|
|
588
|
+
|
|
589
|
+
const interactionRequest = createMockRequest({
|
|
590
|
+
path: '/test-interaction',
|
|
591
|
+
bodyJSON: { data: { param1: 'test-value' } }
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
const response = await toolsService.processRequest(interactionRequest, mockToolFunction);
|
|
595
|
+
|
|
596
|
+
expect(response.status).toBe(400);
|
|
597
|
+
expect(response.bodyJSON).toEqual({
|
|
598
|
+
title: 'Webhook validation failed',
|
|
599
|
+
status: 400,
|
|
600
|
+
detail: 'Invalid signature',
|
|
601
|
+
instance: mockInteraction.endpoint
|
|
602
|
+
});
|
|
603
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
604
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
605
|
+
`Error in function ${mockInteraction.name}:`,
|
|
606
|
+
expect.any(ToolError)
|
|
607
|
+
);
|
|
608
|
+
});
|
|
508
609
|
});
|
|
509
610
|
|
|
510
611
|
describe('error cases', () => {
|