diff --git a/src/utils.js b/src/utils.js index 08c805d..8a8d538 100644 --- a/src/utils.js +++ b/src/utils.js @@ -23,6 +23,7 @@ const archiver = require('archiver') const SupportedRuntimes = ['sequence', 'blackbox', 'nodejs:10', 'nodejs:12', 'nodejs:14', 'nodejs:16', 'nodejs:18', 'nodejs:20', 'nodejs:22', 'nodejs:24'] const { HttpProxyAgent } = require('http-proxy-agent') const PatchedHttpsProxyAgent = require('./PatchedHttpsProxyAgent.js') +const { getCliEnv, DEFAULT_ENV } = require('@adobe/aio-lib-env') // must cover 'deploy-service[-region][.env].app-builder[.int|.corp].adp.adobe.io/runtime const SUPPORTED_ADOBE_ANNOTATION_ENDPOINT_REGEXES = [ @@ -41,6 +42,7 @@ const ANNOTATION_WEB_EXPORT = 'web-export' const ANNOTATION_RAW_HTTP = 'raw-http' const ANNOTATION_REQUIRE_ADOBE_AUTH = 'require-adobe-auth' const ANNOTATION_REQUIRE_WHISK_AUTH = 'require-whisk-auth' +const ANNOTATION_INCLUDE_IMS_CREDENTIALS = 'include-ims-credentials' const VALUE_YES = 'yes' const VALUE_RAW = 'raw' @@ -1186,6 +1188,106 @@ function rewriteActionsWithAdobeAuthAnnotation (packages, deploymentPackages) { } } +/** + * This function implements the support for the `include-ims-credentials` annotation. + * It will expand the IMS_OAUTH_S2S environment variable into an input object stored under params.__ims_oauth_s2s and params.__ims_env + * + * @access private + * @param {ManifestPackages} packages the manifest packages + * @returns {ManifestPackages} newPackages, rewritten package with added inputs + */ +function rewriteActionsWithAdobeIncludeIMSCredentialsAnnotation (packages) { + // avoid side effects, do not modify input packages + const newPackages = cloneDeep(packages) + + const imsAuthObject = loadIMSCredentialsFromEnv() + + // traverse all actions in all packages + Object.keys(newPackages).forEach((key) => { + if (newPackages[key].actions) { + Object.keys(newPackages[key].actions).forEach((actionName) => { + const thisAction = newPackages[key].actions[actionName] + const newInputs = getIncludeIMSCredentialsAnnotationInputs(thisAction, imsAuthObject) + if (newInputs) { + if (!thisAction.inputs) { + thisAction.inputs = {} + } + Object.entries(newInputs).forEach(([k, v]) => { thisAction.inputs[k] = v }) + aioLogger.debug(`processed annotation '${ANNOTATION_INCLUDE_IMS_CREDENTIALS}' for action '${key}/${actionName}'.`) + } + }) + } + }) + return newPackages +} + +/** + * Load the IMS credentials from the environment variables. + * + * @returns {object} the IMS auth object + */ +function loadIMSCredentialsFromEnv () { + // constants + const IMS_OAUTH_S2S_ENV_KEY = 'IMS_OAUTH_S2S' + + const imsAuthObject = { + client_id: process.env[`${IMS_OAUTH_S2S_ENV_KEY}_CLIENT_ID`], + client_secret: process.env[`${IMS_OAUTH_S2S_ENV_KEY}_CLIENT_SECRET`], + org_id: process.env[`${IMS_OAUTH_S2S_ENV_KEY}_ORG_ID`], + scopes: (() => { + try { + return JSON.parse(process.env[`${IMS_OAUTH_S2S_ENV_KEY}_SCOPES`]) + } catch (e) { + return process.env[`${IMS_OAUTH_S2S_ENV_KEY}_SCOPES`] // pass in string as is + } + })() + } + return imsAuthObject +} + +/** + * Get the inputs for the include-ims-credentials annotation. + * Throws an error if the imsAuthObject is incomplete. + * + * @param {object} thisAction the action to process + * @param {object} imsAuthObject the IMS auth object + * @returns {object|undefined} the inputs or undefined with a warning + */ +function getIncludeIMSCredentialsAnnotationInputs (thisAction, imsAuthObject) { + const env = getCliEnv() || DEFAULT_ENV + + const IMS_OAUTH_S2S_INPUT = '__ims_oauth_s2s' + const IMS_ENV_INPUT = '__ims_env' + const IMS_OAUTH_S2S_ENV_KEY = 'IMS_OAUTH_S2S' + + // check if the annotation is defined + if (thisAction.annotations?.[ANNOTATION_INCLUDE_IMS_CREDENTIALS]) { + // check if the IMS credentials are loaded properly, if not emit a warning + if (Object.keys(imsAuthObject).length === 0) { + throw new Error(`Credentials for the project are missing, please ensure the Console Workspace is configured with an OAuth server to server credential. + Unset the annotation '${ANNOTATION_INCLUDE_IMS_CREDENTIALS}' if you don't want to include credentials.`) + } + const missingEnvVars = [] + if (!imsAuthObject.client_id) { + missingEnvVars.push(`${IMS_OAUTH_S2S_ENV_KEY}_CLIENT_ID`) + } + if (!imsAuthObject.client_secret) { + missingEnvVars.push(`${IMS_OAUTH_S2S_ENV_KEY}_CLIENT_SECRET`) + } + if (!imsAuthObject.org_id) { + missingEnvVars.push(`${IMS_OAUTH_S2S_ENV_KEY}_ORG_ID`) + } + if (!imsAuthObject.scopes) { + missingEnvVars.push(`${IMS_OAUTH_S2S_ENV_KEY}_SCOPES`) + } + if (missingEnvVars.length > 0) { + throw new Error(`Credentials for the project are incomplete. Missing '${missingEnvVars.join('|')}' env variables. + Unset the annotation '${ANNOTATION_INCLUDE_IMS_CREDENTIALS}' if you don't want to include credentials.`) + } + return { [IMS_OAUTH_S2S_INPUT]: { ...imsAuthObject }, [IMS_ENV_INPUT]: env } + } +} + /** * * Process the manifest and deployment content and returns deployment entities. @@ -1210,11 +1312,14 @@ function processPackage (packages, const isAdobeEndpoint = SUPPORTED_ADOBE_ANNOTATION_ENDPOINT_REGEXES.some(regex => regex.test(owOptions.apihost)) if (isAdobeEndpoint) { + // rewrite packages in case there are any `include-ims-credentials` annotations + const newPackages = rewriteActionsWithAdobeIncludeIMSCredentialsAnnotation(pkgs) + // rewrite packages in case there are any `require-adobe-auth` annotations // this is a temporary feature and will be replaced by a native support in Adobe I/O Runtime - const { newPackages, newDeploymentPackages } = rewriteActionsWithAdobeAuthAnnotation(pkgs, deploymentPkgs) - pkgs = newPackages - deploymentPkgs = newDeploymentPackages + const ret = rewriteActionsWithAdobeAuthAnnotation(newPackages, deploymentPkgs) + pkgs = ret.newPackages + deploymentPkgs = ret.newDeploymentPackages } const pkgAndDeps = [] @@ -2185,5 +2290,7 @@ module.exports = { dumpActionsBuiltInfo, safeParse, isSupportedActionKind, + getIncludeIMSCredentialsAnnotationInputs, + loadIMSCredentialsFromEnv, DEFAULT_PACKAGE_RESERVED_NAME } diff --git a/test/utils.test.js b/test/utils.test.js index 1107f71..ee5e3bc 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -2710,3 +2710,389 @@ describe('getProxyAgent', () => { expect(result).toBeInstanceOf(PatchedHttpsProxyAgent) }) }) + +describe('include-ims-credentials annotation', () => { + const fakeCode = 'fake action code' + let spy + const envKeys = ['IMS_OAUTH_S2S_CLIENT_ID', 'IMS_OAUTH_S2S_CLIENT_SECRET', 'IMS_OAUTH_S2S_ORG_ID', 'IMS_OAUTH_S2S_SCOPES'] + const originalEnv = {} + + beforeEach(() => { + spy = jest.spyOn(fs, 'readFileSync') + spy.mockImplementation(() => fakeCode) + envKeys.forEach(k => { originalEnv[k] = process.env[k] }) + }) + + afterEach(() => { + spy.mockRestore() + libEnv.getCliEnv.mockReturnValue(PROD_ENV) + envKeys.forEach(k => { + if (originalEnv[k] !== undefined) process.env[k] = originalEnv[k] + else delete process.env[k] + }) + }) + + test('action with include-ims-credentials annotation gets IMS inputs added', () => { + const imsCredentials = { + client_id: 'test-client', + client_secret: 'test-secret', + org_id: 'test-org', + scopes: ['https://example.com/scope'] + } + process.env.IMS_OAUTH_S2S_CLIENT_ID = imsCredentials.client_id + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = imsCredentials.client_secret + process.env.IMS_OAUTH_S2S_ORG_ID = imsCredentials.org_id + process.env.IMS_OAUTH_S2S_SCOPES = JSON.stringify(imsCredentials.scopes) + libEnv.getCliEnv.mockReturnValue(PROD_ENV) + + const packages = { + pkg1: { + actions: { + theaction: { + function: 'fake.js', + web: 'yes', + inputs: { existingInput: 'value' }, + annotations: { + 'include-ims-credentials': true + } + } + } + } + } + + const res = utils.processPackage(packages, {}, {}, {}, false, { apihost: 'https://adobeioruntime.net' }) + expect(res.actions[0].params).toEqual({ + existingInput: 'value', + __ims_oauth_s2s: imsCredentials, + __ims_env: PROD_ENV + }) + }) + + test('action with include-ims-credentials and no inputs property gets IMS inputs added', () => { + const imsCredentials = { + client_id: 'test-client', + client_secret: 'test-secret', + org_id: 'test-org', + scopes: ['https://example.com/scope'] + } + process.env.IMS_OAUTH_S2S_CLIENT_ID = imsCredentials.client_id + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = imsCredentials.client_secret + process.env.IMS_OAUTH_S2S_ORG_ID = imsCredentials.org_id + process.env.IMS_OAUTH_S2S_SCOPES = JSON.stringify(imsCredentials.scopes) + libEnv.getCliEnv.mockReturnValue(PROD_ENV) + + const packages = { + pkg1: { + actions: { + theaction: { + function: 'fake.js', + web: 'yes', + annotations: { 'include-ims-credentials': true } + } + } + } + } + + const res = utils.processPackage(packages, {}, {}, {}, false, { apihost: 'https://adobeioruntime.net' }) + expect(res.actions[0].params).toEqual({ + __ims_oauth_s2s: imsCredentials, + __ims_env: PROD_ENV + }) + }) + + test('only actions with include-ims-credentials get IMS inputs; other actions unchanged', () => { + const imsCredentials = { + client_id: 'c', + client_secret: 's', + org_id: 'o', + scopes: [] + } + process.env.IMS_OAUTH_S2S_CLIENT_ID = imsCredentials.client_id + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = imsCredentials.client_secret + process.env.IMS_OAUTH_S2S_ORG_ID = imsCredentials.org_id + process.env.IMS_OAUTH_S2S_SCOPES = '[]' + libEnv.getCliEnv.mockReturnValue(PROD_ENV) + + const packages = { + pkg1: { + actions: { + withIms: { + function: 'a.js', + web: 'yes', + inputs: {}, + annotations: { 'include-ims-credentials': true } + }, + withoutIms: { + function: 'b.js', + web: 'yes', + inputs: { only: 'this' }, + annotations: {} + } + } + } + } + + const res = utils.processPackage(packages, {}, {}, {}, false, { apihost: 'https://adobeioruntime.net' }) + const withImsAction = res.actions.find(a => a.name === 'pkg1/withIms') + const withoutImsAction = res.actions.find(a => a.name === 'pkg1/withoutIms') + expect(withImsAction.params).toHaveProperty('__ims_oauth_s2s', imsCredentials) + expect(withImsAction.params).toHaveProperty('__ims_env', PROD_ENV) + expect(withoutImsAction.params).toEqual({ only: 'this' }) + expect(withoutImsAction.params).not.toHaveProperty('__ims_oauth_s2s') + }) + + test('does not add IMS inputs when apihost is not Adobe endpoint', () => { + const imsCredentials = { + client_id: 'c', + client_secret: 's', + org_id: 'o', + scopes: [] + } + process.env.IMS_OAUTH_S2S_CLIENT_ID = imsCredentials.client_id + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = imsCredentials.client_secret + process.env.IMS_OAUTH_S2S_ORG_ID = imsCredentials.org_id + process.env.IMS_OAUTH_S2S_SCOPES = '[]' + + const packages = { + pkg1: { + actions: { + theaction: { + function: 'fake.js', + web: 'yes', + inputs: {}, + annotations: { 'include-ims-credentials': true } + } + } + } + } + + const res = utils.processPackage(packages, {}, {}, {}, false, { apihost: 'https://openwhisk.ng.bluemix.net' }) + const action = res.actions.find(a => a.name === 'pkg1/theaction') + expect(action.params && action.params.__ims_oauth_s2s).toBeUndefined() + expect(action.params && action.params.__ims_env).toBeUndefined() + }) + + test('action with include-ims-credentials annotation and no credentials throws', () => { + envKeys.forEach(k => delete process.env[k]) + + const packages = { + pkg1: { + actions: { + theaction: { + function: 'fake.js', + web: 'yes', + annotations: { + 'include-ims-credentials': true + } + } + } + } + } + + expect(() => { + utils.processPackage(packages, {}, {}, {}, false, { apihost: 'https://adobeioruntime.net' }) + }).toThrow(/Credentials for the project are incomplete/) + }) +}) + +describe('getIncludeIMSCredentialsAnnotationInputs', () => { + const fullImsAuthObject = { + client_id: 'test-client', + client_secret: 'test-secret', + org_id: 'test-org', + scopes: '["https://example.com/scope"]' + } + + afterEach(() => { + libEnv.getCliEnv.mockReturnValue(PROD_ENV) + }) + + test('returns undefined if annotation is not set', () => { + const action = { + annotations: {} + } + const result = utils.getIncludeIMSCredentialsAnnotationInputs(action, fullImsAuthObject) + expect(result).toBeUndefined() + }) + + test('returns undefined when annotation is false (falsy)', () => { + const action = { + annotations: { 'include-ims-credentials': false } + } + const result = utils.getIncludeIMSCredentialsAnnotationInputs(action, fullImsAuthObject) + expect(result).toBeUndefined() + }) + + test('returns undefined when annotations is undefined', () => { + const action = {} + const result = utils.getIncludeIMSCredentialsAnnotationInputs(action, fullImsAuthObject) + expect(result).toBeUndefined() + }) + + test('throws when a credential is empty string (falsy)', () => { + const action = { + annotations: { 'include-ims-credentials': true } + } + const imsAuthObject = { + client_id: 'cid', + client_secret: '', + org_id: 'oid', + scopes: '[]' + } + expect(() => { + utils.getIncludeIMSCredentialsAnnotationInputs(action, imsAuthObject) + }).toThrow(/IMS_OAUTH_S2S_CLIENT_SECRET/) + }) + + test('throws with single missing env var name when only client_id is missing', () => { + const action = { + annotations: { 'include-ims-credentials': true } + } + const imsAuthObject = { client_id: undefined, client_secret: 's', org_id: 'o', scopes: '[]' } + expect(() => { + utils.getIncludeIMSCredentialsAnnotationInputs(action, imsAuthObject) + }).toThrow(/IMS_OAUTH_S2S_CLIENT_ID/) + }) + + test('throws if annotation is set but imsAuthObject has no keys (empty)', () => { + const action = { + annotations: { 'include-ims-credentials': true } + } + expect(() => { + utils.getIncludeIMSCredentialsAnnotationInputs(action, {}) + }).toThrow(/Credentials for the project are missing/) + }) + + test('throws if annotation is set but credentials are incomplete (missing env vars)', () => { + const action = { + annotations: { 'include-ims-credentials': true } + } + const imsAuthObject = { client_id: 'test-client', client_secret: 'test-secret' } + expect(() => { + utils.getIncludeIMSCredentialsAnnotationInputs(action, imsAuthObject) + }).toThrow(/Credentials for the project are incomplete.*IMS_OAUTH_S2S_ORG_ID|IMS_OAUTH_S2S_SCOPES/) + }) + + test('throws with all missing env var names when none are set', () => { + const action = { + annotations: { 'include-ims-credentials': true } + } + const imsAuthObject = { client_id: undefined, client_secret: undefined, org_id: undefined, scopes: undefined } + expect(() => { + utils.getIncludeIMSCredentialsAnnotationInputs(action, imsAuthObject) + }).toThrow(/IMS_OAUTH_S2S_CLIENT_ID.*IMS_OAUTH_S2S_CLIENT_SECRET.*IMS_OAUTH_S2S_ORG_ID.*IMS_OAUTH_S2S_SCOPES/) + }) + + test('returns inputs with ims credentials and prod env', () => { + libEnv.getCliEnv.mockReturnValue(PROD_ENV) + const action = { + annotations: { 'include-ims-credentials': true } + } + const result = utils.getIncludeIMSCredentialsAnnotationInputs(action, fullImsAuthObject) + expect(result).toEqual({ + __ims_oauth_s2s: fullImsAuthObject, + __ims_env: PROD_ENV + }) + }) + + test('returns inputs with ims credentials and stage env', () => { + libEnv.getCliEnv.mockReturnValue(STAGE_ENV) + const action = { + annotations: { 'include-ims-credentials': true } + } + const result = utils.getIncludeIMSCredentialsAnnotationInputs(action, fullImsAuthObject) + expect(result).toEqual({ + __ims_oauth_s2s: fullImsAuthObject, + __ims_env: STAGE_ENV + }) + }) + + test('returns inputs with default env when getCliEnv returns null', () => { + libEnv.getCliEnv.mockReturnValue(null) + const action = { + annotations: { 'include-ims-credentials': true } + } + const result = utils.getIncludeIMSCredentialsAnnotationInputs(action, fullImsAuthObject) + expect(result).toEqual({ + __ims_oauth_s2s: fullImsAuthObject, + __ims_env: PROD_ENV + }) + }) +}) + +describe('loadIMSCredentialsFromEnv', () => { + const envKeys = ['IMS_OAUTH_S2S_CLIENT_ID', 'IMS_OAUTH_S2S_CLIENT_SECRET', 'IMS_OAUTH_S2S_ORG_ID', 'IMS_OAUTH_S2S_SCOPES'] + const originalEnv = {} + + beforeEach(() => { + envKeys.forEach(k => { originalEnv[k] = process.env[k] }) + }) + + afterEach(() => { + envKeys.forEach(k => { + if (originalEnv[k] !== undefined) process.env[k] = originalEnv[k] + else delete process.env[k] + }) + }) + + test('returns object with client_id, client_secret, org_id, scopes from env', () => { + process.env.IMS_OAUTH_S2S_CLIENT_ID = 'cid' + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'csecret' + process.env.IMS_OAUTH_S2S_ORG_ID = 'oid' + process.env.IMS_OAUTH_S2S_SCOPES = '["s1"]' + + const result = utils.loadIMSCredentialsFromEnv() + + expect(result).toEqual({ + client_id: 'cid', + client_secret: 'csecret', + org_id: 'oid', + scopes: ['s1'] + }) + }) + + test('returns object with undefined values when env vars are not set', () => { + envKeys.forEach(k => delete process.env[k]) + + const result = utils.loadIMSCredentialsFromEnv() + + expect(result).toEqual({ + client_id: undefined, + client_secret: undefined, + org_id: undefined, + scopes: undefined + }) + }) + + test('returns only set env vars and undefined for unset', () => { + delete process.env.IMS_OAUTH_S2S_CLIENT_ID + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'secret' + delete process.env.IMS_OAUTH_S2S_ORG_ID + process.env.IMS_OAUTH_S2S_SCOPES = '[]' + + const result = utils.loadIMSCredentialsFromEnv() + + expect(result).toEqual({ + client_id: undefined, + client_secret: 'secret', + org_id: undefined, + scopes: [] + }) + }) + + test('parses IMS_OAUTH_S2S_SCOPES when valid JSON array', () => { + process.env.IMS_OAUTH_S2S_SCOPES = '["scope1", "scope2"]' + + const result = utils.loadIMSCredentialsFromEnv() + + expect(result.scopes).toEqual(['scope1', 'scope2']) + }) + + test('returns scopes as raw string when IMS_OAUTH_S2S_SCOPES is invalid JSON', () => { + process.env.IMS_OAUTH_S2S_SCOPES = 'not json' + + const result = utils.loadIMSCredentialsFromEnv() + + expect(result.scopes).toBe('not json') + }) +})