From ed67b6389a11ef40f3601d513f7ccf1c15395c4c Mon Sep 17 00:00:00 2001 From: Jason Sherman Date: Thu, 20 Jun 2024 13:42:13 -0700 Subject: [PATCH] remove External API configuration for sending the user info encrypted (still sends user info). add a few new user info headers. Signed-off-by: Jason Sherman --- .../components/forms/manage/ExternalAPIs.vue | 85 ------------------- .../trans/chefs/en/en.json | 8 +- app/src/components/jwtService.js | 10 +++ .../20240521210143_046_external_api.js | 4 - .../forms/common/models/tables/externalAPI.js | 4 - app/src/forms/form/externalApi/service.js | 13 --- app/src/forms/proxy/controller.js | 2 +- app/src/forms/proxy/service.js | 46 +++++----- app/tests/unit/forms/proxy/service.spec.js | 33 +++---- docs/chefs-external-api-configuration.md | 45 +++------- 10 files changed, 65 insertions(+), 185 deletions(-) diff --git a/app/frontend/src/components/forms/manage/ExternalAPIs.vue b/app/frontend/src/components/forms/manage/ExternalAPIs.vue index 93c73403e..93d4da611 100644 --- a/app/frontend/src/components/forms/manage/ExternalAPIs.vue +++ b/app/frontend/src/components/forms/manage/ExternalAPIs.vue @@ -41,9 +41,6 @@ export default { userTokenHeader: null, userTokenBearer: false, sendUserInfo: false, - userInfoHeader: null, - userInfoEncrypted: false, - userInfoEncryptionKey: null, }, show: false, }, @@ -78,17 +75,6 @@ export default { return true; }, ]), - userInfoHeaderRules: ref([ - (v) => { - if ( - this.editDialog.item.sendUserInfo && - this.editDialog.item.userInfoEncrypted - ) { - return !!v || this.$t('trans.externalAPI.userInfoFieldRequired'); - } - return true; - }, - ]), }; }, computed: { @@ -96,7 +82,6 @@ export default { ...mapWritableState(useFormStore, ['form']), }, async mounted() { - await this.getExternalAPIAlgorithmList(); await this.getExternalAPIStatusCodes(); await this.fetchExternalAPIs(); }, @@ -125,19 +110,6 @@ export default { this.loading = false; } }, - async getExternalAPIAlgorithmList() { - try { - const result = await formService.externalAPIAlgorithmList(this.form.id); - this.externalAPIAlgorithmList = result.data; - } catch (e) { - this.addNotification({ - text: i18n.t('trans.externalAPI.fetchAlgoListError'), - consoleError: i18n.t('trans.externalAPI.fetchAlgoListError', { - error: e.message, - }), - }); - } - }, async getExternalAPIStatusCodes() { try { const result = await formService.externalAPIStatusCodes(this.form.id); @@ -168,10 +140,6 @@ export default { userTokenHeader: null, userTokenBearer: false, sendUserInfo: false, - userInfoHeader: null, - userInfoEncrypted: false, - userInfoEncryptionKey: null, - userInfoEncryptionAlgo: null, }, show: false, }; @@ -472,59 +440,6 @@ export default { - - - - - - - - - - - - -
diff --git a/app/frontend/src/internationalization/trans/chefs/en/en.json b/app/frontend/src/internationalization/trans/chefs/en/en.json index bb108e4fe..a9dccfe1e 100644 --- a/app/frontend/src/internationalization/trans/chefs/en/en.json +++ b/app/frontend/src/internationalization/trans/chefs/en/en.json @@ -97,7 +97,6 @@ "editTitle": "Edit External API Configuration", "fetchError": "An error occurred while fetching the External API.", "fetchListError": "An error occurred while fetching the External API list.", - "fetchAlgoListError": "An error occurred while fetching the External API Encryption Algorithm list.", "fetchStatusListError": "An error occurred while fetching the External API Status Code list.", "save": "Save", "formName": "Name", @@ -110,16 +109,11 @@ "formUserTokenHeader": "User Token Header Name", "formUserTokenBearer": "User Token as Bearer Token", "formSendUserInfo": "Send User Information", - "formUserInfoEncrypted": "Encrypt User Information", - "formUserInfoHeader": "Encrypt User Information Header Name", - "formUserInfoEncryptionKey": "User Information Encryption Key", - "formUserInfoEncryptionAlgo": "User Information Encryption Algorithm", "formNameReq": "Name is required.", "formNameMaxChars": "Name must be 255 characters or less", "validEndpointRequired": "Please enter a valid endpoint starting with http:// or https://", "apiKeyFieldRequired": "Required when 'Send API Key' selected.", - "userTokenFieldRequired": "Required when 'Send User Token' selected.", - "userInfoFieldRequired": "Required when 'Send User Information' & 'Encrypt User Information' selected." + "userTokenFieldRequired": "Required when 'Send User Token' selected." }, "formSettings": { "pressToAddMultiEmail": "Press enter or , or space to add multiple email addresses", diff --git a/app/src/components/jwtService.js b/app/src/components/jwtService.js index ead50c6f0..dd69cc5b7 100644 --- a/app/src/components/jwtService.js +++ b/app/src/components/jwtService.js @@ -49,6 +49,16 @@ class JwtService { return payload; } + async getUnverifiedPayload(token) { + // no verification, this is just to read fields + try { + const { payload } = await jose.jwtVerify(token, JWKS, {}); + return payload; + } catch (e) { + return null; + } + } + async validateAccessToken(token) { try { await this._verify(token); diff --git a/app/src/db/migrations/20240521210143_046_external_api.js b/app/src/db/migrations/20240521210143_046_external_api.js index 254f41e34..7f8075221 100644 --- a/app/src/db/migrations/20240521210143_046_external_api.js +++ b/app/src/db/migrations/20240521210143_046_external_api.js @@ -44,10 +44,6 @@ exports.up = function (knex) { table.boolean('userTokenBearer').defaultTo(true); table.boolean('sendUserInfo').defaultTo(false); - table.string('userInfoHeader'); - table.boolean('userInfoEncrypted').defaultTo(false); - table.string('userInfoEncryptionKey'); - table.string('userInfoEncryptionAlgo'); stamps(knex, table); }) ) diff --git a/app/src/forms/common/models/tables/externalAPI.js b/app/src/forms/common/models/tables/externalAPI.js index c0e1f2da0..020749b9b 100644 --- a/app/src/forms/common/models/tables/externalAPI.js +++ b/app/src/forms/common/models/tables/externalAPI.js @@ -93,10 +93,6 @@ class ExternalAPI extends Timestamps(Model) { userTokenHeader: { type: ['string', 'null'] }, userTokenBearer: { type: 'boolean', default: true }, sendUserInfo: { type: 'boolean', default: false }, - userInfoHeader: { type: ['string', 'null'] }, - userInfoEncrypted: { type: 'boolean', default: false }, - userInfoEncryptionKey: { type: ['string', 'null'] }, - userInfoEncryptionAlgo: { type: ['string', 'null'] }, ...stamps, }, additionalProperties: false, diff --git a/app/src/forms/form/externalApi/service.js b/app/src/forms/form/externalApi/service.js index e99c87f9d..c51af85dc 100644 --- a/app/src/forms/form/externalApi/service.js +++ b/app/src/forms/form/externalApi/service.js @@ -41,19 +41,6 @@ const service = { throw new Problem(422, `'userTokenHeader' is required when 'sendUserToken' is true.`); } } - if (data.sendUserInfo) { - if (data.userInfoEncrypted && !data.userInfoHeader) { - throw new Problem(422, `'userInfoHeader' is required when 'sendUserInfo' and 'userInfoEncrypted' are true.`); - } - if (data.userInfoEncrypted) { - if (!Object.values(ENCRYPTION_ALGORITHMS).includes(data.userInfoEncryptionAlgo)) { - throw new Problem(422, `'${data.userInfoEncryptionAlgo}' is not a valid Encryption Algorithm.`); - } - if (!data.userInfoEncryptionKey) { - throw new Problem(422, `'userInfoEncryptionKey' is required when 'userInfoEncrypted' is true.`); - } - } - } }, checkAllowSendUserToken: (data, allowSendUserToken) => { diff --git a/app/src/forms/proxy/controller.js b/app/src/forms/proxy/controller.js index 51153ffed..a2b8865ce 100644 --- a/app/src/forms/proxy/controller.js +++ b/app/src/forms/proxy/controller.js @@ -25,7 +25,7 @@ module.exports = { // add path to endpoint url if included in headers... const extUrl = service.createExternalAPIUrl(req.headers, extAPI.endpointUrl); // build list of request headers based on configuration... - const extHeaders = service.createExternalAPIHeaders(extAPI, proxyHeaderInfo); + const extHeaders = await service.createExternalAPIHeaders(extAPI, proxyHeaderInfo); let axiosInstance = axios.create({ headers: extHeaders, }); diff --git a/app/src/forms/proxy/service.js b/app/src/forms/proxy/service.js index 0f85d097b..845119648 100644 --- a/app/src/forms/proxy/service.js +++ b/app/src/forms/proxy/service.js @@ -1,4 +1,6 @@ const { encryptionService } = require('../../components/encryptionService'); +const jwtService = require('../../components/jwtService'); + const { ExternalAPI } = require('../../forms/common/models'); const headerValue = (headers, key) => { @@ -68,7 +70,7 @@ const service = { } return endpointUrl; }, - createExternalAPIHeaders: (externalAPI, proxyHeaderInfo) => { + createExternalAPIHeaders: async (externalAPI, proxyHeaderInfo) => { const result = {}; if (externalAPI.sendApiKey) { result[externalAPI.apiKeyHeader] = externalAPI.apiKey; @@ -87,26 +89,30 @@ const service = { throw new Error('Cannot create user headers for External API without populated proxy header info object.'); } - if (externalAPI.userInfoEncrypted) { - // do not send the token - delete proxyHeaderInfo['token']; - const encUserInfo = encryptionService.encryptExternal(externalAPI.userInfoEncryptionAlgo, externalAPI.userInfoEncryptionKey, proxyHeaderInfo); - result[externalAPI.userInfoHeader] = encUserInfo; - } else { - // user information (no token) - let prefix = 'X-CHEFS-USER'; - let fields = ['userId', 'username', 'firstName', 'lastName', 'fullName', 'email', 'idp']; - fields.forEach((field) => { - if (proxyHeaderInfo[field]) { - result[`${prefix}-${field}`.toUpperCase()] = proxyHeaderInfo[field]; - } - }); - // form information... - prefix = 'X-CHEFS-FORM'; - fields = ['formId', 'versionId', 'submissionId']; + // user information (no token) + let prefix = 'X-CHEFS-USER'; + let fields = ['userId', 'username', 'firstName', 'lastName', 'fullName', 'email', 'idp']; + fields.forEach((field) => { + if (proxyHeaderInfo[field]) { + result[`${prefix}-${field}`.toUpperCase()] = proxyHeaderInfo[field]; + } + }); + // form information... + prefix = 'X-CHEFS-FORM'; + fields = ['formId', 'versionId', 'submissionId']; + fields.forEach((field) => { + if (proxyHeaderInfo[field]) { + result[`${prefix}-${field}`.toUpperCase()] = proxyHeaderInfo[field]; + } + }); + // grab raw token values... + const payload = await jwtService.getUnverifiedPayload(proxyHeaderInfo['token']); + if (payload) { + prefix = 'X-CHEFS-TOKEN'; + fields = ['sub', 'iat', 'exp']; fields.forEach((field) => { - if (proxyHeaderInfo[field]) { - result[`${prefix}-${field}`.toUpperCase()] = proxyHeaderInfo[field]; + if (payload[field]) { + result[`${prefix}-${field}`.toUpperCase()] = payload[field]; } }); } diff --git a/app/tests/unit/forms/proxy/service.spec.js b/app/tests/unit/forms/proxy/service.spec.js index 9ea783c36..1871963ca 100644 --- a/app/tests/unit/forms/proxy/service.spec.js +++ b/app/tests/unit/forms/proxy/service.spec.js @@ -198,24 +198,24 @@ describe('Proxy Service', () => { it('should throw error with no headers', async () => { const externalAPI = undefined; const proxyHeaderInfo = goodProxyHeaderInfo; - expect(() => service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo)).toThrow(); + await expect(service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo)).rejects.toThrow(); }); it('should throw error with no current user and sending user information', async () => { const externalAPI = goodExternalApi; const proxyHeaderInfo = undefined; - expect(() => service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo)).toThrow(); + await expect(service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo)).rejects.toThrow(); }); it('should throw error with invalid current user and sending user information', async () => { const externalAPI = goodExternalApi; const proxyHeaderInfo = {}; - expect(() => service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo)).toThrow(); + await expect(service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo)).rejects.toThrow(); }); it('should NOT throw error with no current user and not sending user information', async () => { const externalAPI = Object.assign({}, goodExternalApi); externalAPI.sendUserToken = false; externalAPI.sendUserInfo = false; const proxyHeaderInfo = undefined; - const result = service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo); + const result = await service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo); expect(result).toBeTruthy(); }); it('should NOT throw error with invalid current user and not sending user information', async () => { @@ -223,25 +223,21 @@ describe('Proxy Service', () => { externalAPI.sendUserToken = false; externalAPI.sendUserInfo = false; const proxyHeaderInfo = {}; - const result = service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo); + const result = await service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo); expect(result).toBeTruthy(); }); it('should return populated headers', async () => { const externalAPI = Object.assign({}, goodExternalApi); const proxyHeaderInfo = Object.assign({}, goodProxyHeaderInfo); - const result = service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo); + const result = await service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo); expect(result).toBeTruthy(); // with the defaul external API config we should have headers for... // api key expect(result[externalAPI.apiKeyHeader]).toBe(externalAPI.apiKey); // user token (with Bearer) expect(result[externalAPI.userTokenHeader]).toBe(`Bearer ${token}`); - // user info (encrypted) - expect(result[externalAPI.userInfoHeader]).toBeTruthy(); - const decrypted = encryptionService.decryptExternal(externalAPI.userInfoEncryptionAlgo, externalAPI.userInfoEncryptionKey, result[externalAPI.userInfoHeader]); - expect(JSON.parse(decrypted)).toMatchObject(proxyHeaderInfo); - // but no unencrypted user info headers - expect(result['X-CHEFS-USER-EMAIL']).toBeFalsy(); + // use r + expect(result['X-CHEFS-USER-EMAIL']).toBeTruthy(); }); it('should return only api key headers', async () => { const externalAPI = Object.assign({}, goodExternalApi); @@ -249,7 +245,7 @@ describe('Proxy Service', () => { externalAPI.sendUserToken = false; externalAPI.sendUserInfo = false; const proxyHeaderInfo = Object.assign({}, goodProxyHeaderInfo); - const result = service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo); + const result = await service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo); expect(result).toBeTruthy(); // api key expect(result[externalAPI.apiKeyHeader]).toBe(externalAPI.apiKey); @@ -267,7 +263,7 @@ describe('Proxy Service', () => { externalAPI.sendUserInfo = false; externalAPI.userTokenBearer = false; const userInfo = Object.assign({}, goodProxyHeaderInfo); - const result = service.createExternalAPIHeaders(externalAPI, userInfo); + const result = await service.createExternalAPIHeaders(externalAPI, userInfo); expect(result).toBeTruthy(); // no api key expect(result[externalAPI.apiKeyHeader]).toBeFalsy(); @@ -275,7 +271,7 @@ describe('Proxy Service', () => { expect(result[externalAPI.userTokenHeader]).toBe(token); // no user info (encrypted) expect(result[externalAPI.userInfoHeader]).toBeFalsy(); - // no unencrypted user info headers + // no user info headers expect(result['X-CHEFS-USER-EMAIL']).toBeFalsy(); }); it('should return only unencrypted user info headers', async () => { @@ -283,18 +279,15 @@ describe('Proxy Service', () => { externalAPI.sendApiKey = false; externalAPI.sendUserToken = false; externalAPI.sendUserInfo = true; - externalAPI.userInfoEncrypted = false; const userInfo = Object.assign({}, goodProxyHeaderInfo); - const result = service.createExternalAPIHeaders(externalAPI, userInfo); + const result = await service.createExternalAPIHeaders(externalAPI, userInfo); expect(result).toBeTruthy(); // with the defaul external API config we should have headers for... // no api key expect(result[externalAPI.apiKeyHeader]).toBeFalsy(); // no user token (with Bearer) expect(result[externalAPI.userTokenHeader]).toBeFalsy(); - // no user info (encrypted) - expect(result[externalAPI.userInfoHeader]).toBeFalsy(); - // unencrypted user info headers + // user info headers expect(result['X-CHEFS-USER-EMAIL']).toBe(userInfo.email); expect(result['X-CHEFS-FORM-FORMID']).toBe(userInfo.formId); }); diff --git a/docs/chefs-external-api-configuration.md b/docs/chefs-external-api-configuration.md index a625a79a3..469238e24 100644 --- a/docs/chefs-external-api-configuration.md +++ b/docs/chefs-external-api-configuration.md @@ -38,34 +38,26 @@ See the following table for description of the External API form fields. "apiKeyHeader": "X-API-KEY", "apiKey": "", "sendUserInfo": true, - "userInfoEncrypted": false, - "userInfoHeader": "X-USER-INFO", - "userInfoEncryptionKey": "999999999", - "userInfoEncryptionAlgo": "aes-256-gcm", "code": "Submitted" } ``` -| Attribute | Form Field | Purpose | -| ---------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------- | -| formId | N/A | CHEFS form id | -| name | Name | Name should be unique per form and should easily identify this API | -| endpointUrl | Endpoint URL | Endpoint URL for the API (could be a full path or just a base path) | -| sendApiKey | Send API Key | boolean - send an API Key in a header | -| apiKeyHeader | API Key Header Name | the name for the API Key header | -| apiKey | API Key Value | The value for the API Key, stored encrypted in the db. | -| sendUserInfo | Send User Information | boolean - send current user information in headers | -| userInfoEncrypted | Encrypt User Information | boolean - whether we encrypt the current user information | -| userInfoHeader | Encrypt User Information Header Name | when userInfoEncrypted = true, this is the name for the user info header | -| userInfoEncryptionKey | User Information Encryption Key | encryption key supplied by Form Designer, stored encrypted in the db. | -| userInfoEncryptionAlgo | User Information Encryption Algorithm | A CHEFS supported encryption algorithm. | -| code | N/A | Status Code. Only CHEFS Admin users can change this value. Used for the approval process. | +| Attribute | Form Field | Purpose | +| ------------ | --------------------- | ----------------------------------------------------------------------------------------- | +| formId | N/A | CHEFS form id | +| name | Name | Name should be unique per form and should easily identify this API | +| endpointUrl | Endpoint URL | Endpoint URL for the API (could be a full path or just a base path) | +| sendApiKey | Send API Key | boolean - send an API Key in a header | +| apiKeyHeader | API Key Header Name | the name for the API Key header | +| apiKey | API Key Value | The value for the API Key, stored encrypted in the db. | +| sendUserInfo | Send User Information | boolean - send current user information in headers | +| code | N/A | Status Code. Only CHEFS Admin users can change this value. Used for the approval process. | **Recommendation** - secure the External API with an API Key and send user information in plain text. ### User Info Object -User (and form) context is provided to the External API in headers. This information can be encrypted and passed as a single header (the receiving API will decrypt) or plain text in multiple headers. +User (and form) context is provided to the External API in headers. This information is passed as plain text in multiple headers. The user information initially comes from the user's token, as such the values for each attribute may differ depending on which Identity Provider authenticated them. | Attribute | Header | Purpose | @@ -80,21 +72,12 @@ The user information initially comes from the user's token, as such the values f | fullName | X-CHEFS-USER-FULLNAME | user's Full name (ex. Smitty, Alex CITZ:EX) | | email | X-CHEFS-USER-EMAIL | user's email (ex. alex.smitty@gov.bc.ca) | | idp | X-CHEFS-USER-IDP | the Identity Provider code (ex. 'idir') | +| sub | X-CHEFS-TOKEN-SUB | the `Subject` attribute from the user token | +| iat | X-CHEFS-TOKEN-IAT | the `Issued At` timestamp from the user token | +| exp | X-CHEFS-TOKEN-EXP | the `Expired` timestamp attribute from the user token | **Note** - Although the current user (the form submitter) token is available, there is no guarantee that it has not timed out. It will be stored upon entry/load of the form and is not refreshed. -#### User Info Encryption - -The Form Designer will provide the encryption key for the selected encryption algorithm. This algorithm and key will encrypt the User Info into an encrypted value and passed in a single header (with header name = `userInfoHeader` value). - -The receiving api will have the same encryption key and algorithm and will decrypt on their end. They can then parse the decrypted user info object. - -The `userInfoEncryptionKey` is stored in the CHEFS database in an encrypted format. Only CHEFS can read and write the encrypted value. Decrypting the value is done only on demand when creating the header payload. - -Currently there is only one algorithm: `AES-256-gcm`. A node implementation can be found in the CHEFS source code (see [encryptionService/Aes256Gcm](../app/src/components/encryptionService.js)). It is up to the owner of the External API to provide their own implementation. - -Keys for `aes-256-gcm` should be sha256 hashes: 256 bits/32 bytes/64 characters. - ## Configuring Form Component For this example, we assume populating a drop down/select component...