diff --git a/src/commands/app/deploy.js b/src/commands/app/deploy.js index 84b400f9..d868f36a 100644 --- a/src/commands/app/deploy.js +++ b/src/commands/app/deploy.js @@ -26,7 +26,7 @@ const { const rtLib = require('@adobe/aio-lib-runtime') const LogForwarding = require('../../lib/log-forwarding') const { sendAppAssetsDeployedAuditLog, sendAppDeployAuditLog } = require('../../lib/audit-logger') -const { setRuntimeApiHostAndAuthHandler, getAccessToken } = require('../../lib/auth-helper') +const { setRuntimeApiHostAndAuthHandler, getAccessToken, getTokenData } = require('../../lib/auth-helper') const logActions = require('../../lib/log-actions') const PRE_DEPLOY_EVENT_REG = 'pre-deploy-event-reg' @@ -68,6 +68,8 @@ class Deploy extends BuildCommand { if (cliDetails?.accessToken) { try { + // store user id from token data for cdn deploy audit metadata + appInfo.auditUserId = getTokenData(cliDetails.accessToken)?.user_id // send audit log at start (don't wait for deployment to finish) await sendAppDeployAuditLog({ accessToken: cliDetails?.accessToken, @@ -130,8 +132,12 @@ class Deploy extends BuildCommand { // - break into smaller pieces deploy, allowing to first deploy all actions then all web assets for (let i = 0; i < keys.length; ++i) { const k = keys[i] - const v = setRuntimeApiHostAndAuthHandler(values[i]) - + // auditUserId is only set if it is available in the token data + // falsy because "", 0, false, null, undefined, NaN, etc. are all invalid values + const v = { + ...(appInfo.auditUserId && { auditUserId: appInfo.auditUserId }), + ...setRuntimeApiHostAndAuthHandler(values[i]) + } await this.deploySingleConfig({ name: k, config: v, originalConfig: values[i], flags, spinner }) if (cliDetails?.accessToken && v.app.hasFrontend && flags['web-assets']) { const opItems = getFilesCountWithExtension(v.web.distProd) diff --git a/src/lib/auth-helper.js b/src/lib/auth-helper.js index 84fc1fce..033ba9be 100644 --- a/src/lib/auth-helper.js +++ b/src/lib/auth-helper.js @@ -9,7 +9,7 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -const { getToken, context } = require('@adobe/aio-lib-ims') +const { getToken, context, getTokenData: getImsTokenData } = require('@adobe/aio-lib-ims') const { CLI } = require('@adobe/aio-lib-ims/src/context') const { getCliEnv } = require('@adobe/aio-lib-env') const aioLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-cli-plugin-app:auth-helper', { provider: 'debug' }) @@ -90,8 +90,29 @@ const setRuntimeApiHostAndAuthHandler = (_config) => { } } +/** + * Decodes a JWT token and returns its payload as a JavaScript object. + * + * @function getTokenData + * @param {string} token - The JWT token to decode + * @returns {object|null} The decoded payload of the JWT token or null if the token is invalid or cannot be decoded + */ +const getTokenData = (token) => { + if (typeof token !== 'string') { + aioLogger.error('Invalid token provided to getTokenData :: not a string') + return null + } + try { + return getImsTokenData(token) + } catch (e) { + aioLogger.error('Error decoding token payload in getTokenData ::', e) + return null + } +} + module.exports = { getAccessToken, + getTokenData, bearerAuthHandler, setRuntimeApiHostAndAuthHandler } diff --git a/test/commands/app/deploy.test.js b/test/commands/app/deploy.test.js index c6b7ada3..fe30a244 100644 --- a/test/commands/app/deploy.test.js +++ b/test/commands/app/deploy.test.js @@ -198,6 +198,9 @@ beforeEach(() => { env: 'stage' } }) + authHelper.getTokenData.mockImplementation(() => { + return null // default to null, tests can override + }) LogForwarding.init.mockResolvedValue(mockLogForwarding) command = new TheCommand([]) @@ -670,6 +673,136 @@ describe('run', () => { expect(open).toHaveBeenCalledWith('http://prefix?fake=https://example.com') }) + test('deploy should pass auditUserId to deployWeb config when user_id is present in token', async () => { + const mockUserId = 'test-user-123' + const mockToken = 'mock.token.value' + + authHelper.getAccessToken.mockResolvedValueOnce({ + accessToken: mockToken, + env: 'stage' + }) + authHelper.getTokenData.mockReturnValueOnce({ + user_id: mockUserId + }) + + command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) + mockWebLib.deployWeb.mockResolvedValue('https://example.com') + + command.argv = [] + await command.run() + + expect(command.error).toHaveBeenCalledTimes(0) + expect(authHelper.getTokenData).toHaveBeenCalledWith(mockToken) + expect(mockWebLib.deployWeb).toHaveBeenCalledWith( + expect.objectContaining({ + auditUserId: mockUserId + }), + expect.any(Function) + ) + }) + + test('deploy should NOT include auditUserId in config when user_id is undefined', async () => { + const mockToken = 'mock.token.value' + + authHelper.getAccessToken.mockResolvedValueOnce({ + accessToken: mockToken, + env: 'stage' + }) + authHelper.getTokenData.mockReturnValueOnce({ + // user_id is undefined + }) + + command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) + mockWebLib.deployWeb.mockResolvedValue('https://example.com') + + command.argv = [] + await command.run() + + expect(command.error).toHaveBeenCalledTimes(0) + expect(mockWebLib.deployWeb).toHaveBeenCalledWith( + expect.not.objectContaining({ + auditUserId: expect.anything() + }), + expect.any(Function) + ) + }) + + test('deploy should NOT include auditUserId in config when user_id is null', async () => { + const mockToken = 'mock.token.value' + + authHelper.getAccessToken.mockResolvedValueOnce({ + accessToken: mockToken, + env: 'stage' + }) + authHelper.getTokenData.mockReturnValueOnce({ + user_id: null + }) + + command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) + mockWebLib.deployWeb.mockResolvedValue('https://example.com') + + command.argv = [] + await command.run() + + expect(command.error).toHaveBeenCalledTimes(0) + expect(mockWebLib.deployWeb).toHaveBeenCalledWith( + expect.not.objectContaining({ + auditUserId: expect.anything() + }), + expect.any(Function) + ) + }) + + test('deploy should NOT include auditUserId in config when user_id is empty string', async () => { + const mockToken = 'mock.token.value' + + authHelper.getAccessToken.mockResolvedValueOnce({ + accessToken: mockToken, + env: 'stage' + }) + authHelper.getTokenData.mockReturnValueOnce({ + user_id: '' + }) + + command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) + mockWebLib.deployWeb.mockResolvedValue('https://example.com') + + command.argv = [] + await command.run() + + expect(command.error).toHaveBeenCalledTimes(0) + expect(mockWebLib.deployWeb).toHaveBeenCalledWith( + expect.not.objectContaining({ + auditUserId: expect.anything() + }), + expect.any(Function) + ) + }) + + test('deploy should NOT include auditUserId when getTokenData returns null', async () => { + const mockToken = 'mock.token.value' + + authHelper.getAccessToken.mockResolvedValueOnce({ + accessToken: mockToken, + env: 'stage' + }) + authHelper.getTokenData.mockReturnValueOnce(null) + + command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) + mockWebLib.deployWeb.mockResolvedValue('https://example.com') + + command.argv = [] + await command.run() + + expect(command.error).toHaveBeenCalledTimes(0) + expect(mockWebLib.deployWeb).toHaveBeenCalledWith( + expect.not.objectContaining({ + auditUserId: expect.anything() + }), + expect.any(Function) + ) + }) + test('deploy should show action urls (web-export: true)', async () => { command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig)) mockRuntimeLib.deployActions.mockResolvedValue({ diff --git a/test/commands/lib/auth-helper.test.js b/test/commands/lib/auth-helper.test.js index cf2d4081..693a9027 100644 --- a/test/commands/lib/auth-helper.test.js +++ b/test/commands/lib/auth-helper.test.js @@ -1,5 +1,5 @@ -const { getAccessToken, bearerAuthHandler, setRuntimeApiHostAndAuthHandler } = require('../../../src/lib/auth-helper') -const { getToken, context } = require('@adobe/aio-lib-ims') +const { getAccessToken, bearerAuthHandler, setRuntimeApiHostAndAuthHandler, getTokenData } = require('../../../src/lib/auth-helper') +const { getToken, context, getTokenData: getImsTokenData } = require('@adobe/aio-lib-ims') const { CLI } = require('@adobe/aio-lib-ims/src/context') const { getCliEnv } = require('@adobe/aio-lib-env') @@ -57,6 +57,37 @@ describe('getAccessToken', () => { }) }) +describe('getTokenData', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('should call through to getImsTokenData to decode JWT token and return payload', () => { + getImsTokenData.mockReturnValue({ user_id: '12345', name: 'Test User' }) + // Example JWT token with payload: {"user_id":"12345","name":"Test User"} + const exampleToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMTIzNDUiLCJuYW1lIjoiVGVzdCBVc2VyIn0.sflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' + const result = getTokenData(exampleToken) + expect(result).toEqual({ user_id: '12345', name: 'Test User' }) + }) + test('should return null for invalid token', () => { + getImsTokenData.mockImplementation(() => { throw new Error('Invalid token') }) + const invalidToken = 'invalid.token.string' + const result = getTokenData(invalidToken) + expect(result).toBeNull() + }) + test('should return null for malformed token', () => { + getImsTokenData.mockImplementation(() => { throw new Error('Malformed token') }) + const malformedToken = 'malformedtoken' + const result = getTokenData(malformedToken) + expect(result).toBeNull() + }) + test('should return null for non-string token', () => { + const nonStringToken = 12345 + const result = getTokenData(nonStringToken) + expect(result).toBeNull() + }) +}) + describe('bearerAuthHandler', () => { beforeEach(() => { jest.clearAllMocks()