@optimizely-opal/opal-tool-ocp-sdk 1.0.0-OCP-1442.5 → 1.0.0-OCP-1449.1

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 (64) hide show
  1. package/README.md +114 -72
  2. package/dist/auth/AuthUtils.d.ts +12 -5
  3. package/dist/auth/AuthUtils.d.ts.map +1 -1
  4. package/dist/auth/AuthUtils.js +80 -25
  5. package/dist/auth/AuthUtils.js.map +1 -1
  6. package/dist/auth/AuthUtils.test.js +161 -117
  7. package/dist/auth/AuthUtils.test.js.map +1 -1
  8. package/dist/function/GlobalToolFunction.d.ts +1 -1
  9. package/dist/function/GlobalToolFunction.d.ts.map +1 -1
  10. package/dist/function/GlobalToolFunction.js +17 -4
  11. package/dist/function/GlobalToolFunction.js.map +1 -1
  12. package/dist/function/GlobalToolFunction.test.js +54 -8
  13. package/dist/function/GlobalToolFunction.test.js.map +1 -1
  14. package/dist/function/ToolFunction.d.ts +1 -1
  15. package/dist/function/ToolFunction.d.ts.map +1 -1
  16. package/dist/function/ToolFunction.js +24 -4
  17. package/dist/function/ToolFunction.js.map +1 -1
  18. package/dist/function/ToolFunction.test.js +260 -8
  19. package/dist/function/ToolFunction.test.js.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/logging/ToolLogger.d.ts.map +1 -1
  25. package/dist/logging/ToolLogger.js +13 -12
  26. package/dist/logging/ToolLogger.js.map +1 -1
  27. package/dist/logging/ToolLogger.test.js +171 -0
  28. package/dist/logging/ToolLogger.test.js.map +1 -1
  29. package/dist/service/Service.d.ts +88 -2
  30. package/dist/service/Service.d.ts.map +1 -1
  31. package/dist/service/Service.js +227 -55
  32. package/dist/service/Service.js.map +1 -1
  33. package/dist/service/Service.test.js +464 -36
  34. package/dist/service/Service.test.js.map +1 -1
  35. package/dist/types/ToolError.d.ts +59 -0
  36. package/dist/types/ToolError.d.ts.map +1 -0
  37. package/dist/types/ToolError.js +79 -0
  38. package/dist/types/ToolError.js.map +1 -0
  39. package/dist/types/ToolError.test.d.ts +2 -0
  40. package/dist/types/ToolError.test.d.ts.map +1 -0
  41. package/dist/types/ToolError.test.js +161 -0
  42. package/dist/types/ToolError.test.js.map +1 -0
  43. package/dist/validation/ParameterValidator.d.ts +5 -16
  44. package/dist/validation/ParameterValidator.d.ts.map +1 -1
  45. package/dist/validation/ParameterValidator.js +10 -3
  46. package/dist/validation/ParameterValidator.js.map +1 -1
  47. package/dist/validation/ParameterValidator.test.js +186 -146
  48. package/dist/validation/ParameterValidator.test.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/auth/AuthUtils.test.ts +176 -157
  51. package/src/auth/AuthUtils.ts +96 -33
  52. package/src/function/GlobalToolFunction.test.ts +54 -8
  53. package/src/function/GlobalToolFunction.ts +26 -6
  54. package/src/function/ToolFunction.test.ts +274 -8
  55. package/src/function/ToolFunction.ts +33 -7
  56. package/src/index.ts +1 -0
  57. package/src/logging/ToolLogger.test.ts +184 -0
  58. package/src/logging/ToolLogger.ts +13 -12
  59. package/src/service/Service.test.ts +577 -34
  60. package/src/service/Service.ts +286 -54
  61. package/src/types/ToolError.test.ts +192 -0
  62. package/src/types/ToolError.ts +95 -0
  63. package/src/validation/ParameterValidator.test.ts +185 -158
  64. package/src/validation/ParameterValidator.ts +17 -20
@@ -1,23 +1,30 @@
1
- import { getAppContext, logger } from '@zaiusinc/app-sdk';
1
+ import { getAppContext, logger, Request } 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
- * @returns true if the token is valid
10
+ * @throws {ToolError} If token validation fails
10
11
  */
11
- async function validateAccessToken(accessToken: string | undefined): Promise<boolean> {
12
+ async function validateAccessToken(accessToken: string | undefined): Promise<void> {
12
13
  try {
13
14
  if (!accessToken) {
14
- return false;
15
+ throw new ToolError('Forbidden', 403, 'OptiID access token is required');
15
16
  }
16
17
  const tokenVerifier = await getTokenVerifier();
17
- return await tokenVerifier.verify(accessToken);
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
- return false;
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 invalid
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 || authData?.provider?.toLowerCase() !== 'optiid') {
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
- * @returns true if the organization ID is valid
86
+ * @throws {ToolError} If organization ID is invalid or missing
45
87
  */
46
- function validateOrganizationId(customerId: string | undefined): boolean {
88
+ function validateOrganizationId(customerId: string | undefined): void {
47
89
  if (!customerId) {
48
90
  logger.error('Organisation ID is required but not provided');
49
- return false;
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
- return false;
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,68 @@ function shouldSkipAuth(request: any): boolean {
73
113
  *
74
114
  * @param request - The incoming request
75
115
  * @param validateOrg - Whether to validate organization ID
76
- * @returns true if authentication succeeds
116
+ * @throws {ToolError} If authentication fails
77
117
  */
78
- async function authenticateRequest(request: any, validateOrg: boolean): Promise<boolean> {
118
+ async function authenticateRequest(request: any, validateOrg: boolean): Promise<void> {
79
119
  if (shouldSkipAuth(request)) {
80
- return true;
120
+ return;
81
121
  }
82
122
 
83
- const authInfo = extractAuthData(request);
84
- if (!authInfo) {
85
- logger.error('OptiID token is required but not provided');
86
- return false;
87
- }
88
-
89
- const { authData, accessToken } = authInfo;
123
+ const { authData, accessToken } = extractAndValidateAuthData(request);
90
124
 
91
125
  // Validate organization ID if required
92
- if (validateOrg && !validateOrganizationId(authData.credentials?.customer_id)) {
93
- return false;
126
+ if (validateOrg) {
127
+ validateOrganizationId(authData.credentials?.customer_id);
94
128
  }
95
129
 
96
- return await validateAccessToken(accessToken);
130
+ await validateAccessToken(accessToken);
131
+ }
132
+
133
+ /**
134
+ * Authenticate internal requests using OptiID token from headers
135
+ * @param request The request object
136
+ * @returns true if authentication succeeds
137
+ */
138
+ export async function authenticateInternalRequest(request: Request): Promise<void> {
139
+ try {
140
+ const headers = request.headers;
141
+ const optiIdToken = headers?.get('Authorization') || headers?.get('authorization');
142
+
143
+ if (!optiIdToken) {
144
+ logger.info('OptiID token is required in Authorization header for internal requests');
145
+ throw new ToolError('Unauthorized', 401, 'OptiID token is required');
146
+ }
147
+
148
+ // Validate the token using TokenVerifier directly
149
+ const tokenVerifier = await getTokenVerifier();
150
+ const isValidToken = await tokenVerifier.verify(optiIdToken);
151
+
152
+ if (!isValidToken) {
153
+ logger.info('Invalid OptiID token provided for internal request');
154
+ throw new ToolError('Unauthorized', 401, 'Invalid OptiID token');
155
+ }
156
+ } catch (error: any) {
157
+ logger.error('Internal request authentication failed:', error);
158
+ throw new ToolError('Unauthorized', 401, 'Internal request authentication failed');
159
+ }
97
160
  }
98
161
 
99
162
  /**
100
163
  * Authenticate a request for regular functions (with organization validation)
101
164
  *
102
165
  * @param request - The incoming request
103
- * @returns true if authentication and authorization succeed
166
+ * @throws {ToolError} If authentication or authorization fails
104
167
  */
105
- export async function authenticateRegularRequest(request: any): Promise<boolean> {
106
- return await authenticateRequest(request, true);
168
+ export async function authenticateRegularRequest(request: any): Promise<void> {
169
+ await authenticateRequest(request, true);
107
170
  }
108
171
 
109
172
  /**
110
173
  * Authenticate a request for global functions (without organization validation)
111
174
  *
112
175
  * @param request - The incoming request
113
- * @returns true if authentication succeeds
176
+ * @throws {ToolError} If authentication fails
114
177
  */
115
- export async function authenticateGlobalRequest(request: any): Promise<boolean> {
116
- return await authenticateRequest(request, false);
178
+ export async function authenticateGlobalRequest(request: any): Promise<void> {
179
+ await authenticateRequest(request, false);
117
180
  }
@@ -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).toEqual(new Response(403, { error: 'Forbidden' }));
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).toEqual(new Response(403, { error: 'Forbidden' }));
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).toEqual(new Response(403, { error: 'Forbidden' }));
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).toEqual(new Response(403, { error: 'Forbidden' }));
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).toEqual(new Response(403, { error: 'Forbidden' }));
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).toEqual(new Response(403, { error: 'Forbidden' }));
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
- if (!(await this.authorizeRequest())) {
48
- return new Response(403, { error: 'Forbidden' });
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
- * @returns true if authentication succeeds
82
+ * @throws {ToolError} If authentication fails
63
83
  */
64
- private async authorizeRequest(): Promise<boolean> {
65
- return await authenticateGlobalRequest(this.request);
84
+ private async authorizeRequest(): Promise<void> {
85
+ await authenticateGlobalRequest(this.request);
66
86
  }
67
87
  }