From 7b544aab978ec7508de3a58ac587ea5415feed06 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 23 Oct 2025 19:39:40 -0700 Subject: [PATCH 1/8] feat: add appConfig value to auditUser cdn deploys --- src/commands/app/deploy.js | 6 ++++-- src/lib/auth-helper.js | 13 +++++++++++++ test/commands/lib/auth-helper.test.js | 13 ++++++++++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/commands/app/deploy.js b/src/commands/app/deploy.js index 84b400f9..034b56d8 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, @@ -131,7 +133,7 @@ class Deploy extends BuildCommand { for (let i = 0; i < keys.length; ++i) { const k = keys[i] const v = setRuntimeApiHostAndAuthHandler(values[i]) - + v.auditUserId = appInfo.auditUserId 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 23357fe2..3a054806 100644 --- a/src/lib/auth-helper.js +++ b/src/lib/auth-helper.js @@ -90,8 +90,21 @@ 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} The decoded payload of the JWT token + */ +const getTokenData = (token) => { + const [, payload] = token.split('.', 3) + return JSON.parse(Buffer.from(payload, 'base64')) +} + module.exports = { getAccessToken, + getTokenData, bearerAuthHandler, setRuntimeApiHostAndAuthHandler } diff --git a/test/commands/lib/auth-helper.test.js b/test/commands/lib/auth-helper.test.js index 4580fc5a..10e85cee 100644 --- a/test/commands/lib/auth-helper.test.js +++ b/test/commands/lib/auth-helper.test.js @@ -1,4 +1,4 @@ -const { getAccessToken, bearerAuthHandler, setRuntimeApiHostAndAuthHandler } = require('../../../src/lib/auth-helper') +const { getAccessToken, bearerAuthHandler, setRuntimeApiHostAndAuthHandler, getTokenData } = require('../../../src/lib/auth-helper') const { getToken, context } = require('@adobe/aio-lib-ims') const { CLI } = require('@adobe/aio-lib-ims/src/context') const { getCliEnv } = require('@adobe/aio-lib-env') @@ -57,6 +57,17 @@ describe('getAccessToken', () => { }) }) +describe('getTokenData', () => { + test('should decode JWT token and return payload', () => { + // 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' }) + }) +}) + describe('bearerAuthHandler', () => { beforeEach(() => { jest.clearAllMocks() From 160f55e4820b14fa24ff4a9e903027536acd55dc Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 23 Oct 2025 19:47:35 -0700 Subject: [PATCH 2/8] nit: firmly state that we throw an error if called with bad token --- src/lib/auth-helper.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/auth-helper.js b/src/lib/auth-helper.js index 1e181a75..1c67ae82 100644 --- a/src/lib/auth-helper.js +++ b/src/lib/auth-helper.js @@ -96,6 +96,7 @@ const setRuntimeApiHostAndAuthHandler = (_config) => { * @function getTokenData * @param {string} token - The JWT token to decode * @returns {object} The decoded payload of the JWT token + * @throws */ const getTokenData = (token) => { const [, payload] = token.split('.', 3) From b6384f94998393aae05577d9334913cf6a2d65ce Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 27 Oct 2025 21:33:15 -0700 Subject: [PATCH 3/8] nit: added typechecking and tests --- src/lib/auth-helper.js | 21 +++++++++++++++++++-- test/commands/lib/auth-helper.test.js | 19 ++++++++++++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/lib/auth-helper.js b/src/lib/auth-helper.js index 1c67ae82..08fe829a 100644 --- a/src/lib/auth-helper.js +++ b/src/lib/auth-helper.js @@ -96,11 +96,28 @@ const setRuntimeApiHostAndAuthHandler = (_config) => { * @function getTokenData * @param {string} token - The JWT token to decode * @returns {object} The decoded payload of the JWT token - * @throws + * @returnss {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; + } const [, payload] = token.split('.', 3) - return JSON.parse(Buffer.from(payload, 'base64')) + if (!payload) { + aioLogger.error('Invalid token provided to getTokenData :: not a jwt') + return null; + } + try { + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/') + // add padding if necessary + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4) + const decoded = Buffer.from(padded, 'base64').toString('utf-8') + return JSON.parse(decoded) + } catch (e) { + aioLogger.error('Error decoding token payload in getTokenData ::', e) + return null; + } } module.exports = { diff --git a/test/commands/lib/auth-helper.test.js b/test/commands/lib/auth-helper.test.js index 161fa28f..a966e885 100644 --- a/test/commands/lib/auth-helper.test.js +++ b/test/commands/lib/auth-helper.test.js @@ -61,11 +61,24 @@ describe('getTokenData', () => { test('should decode JWT token and return payload', () => { // 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', () => { + const invalidToken = 'invalid.token.string' + const result = getTokenData(invalidToken) + expect(result).toBeNull() + }) + test('should return null for 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', () => { @@ -88,7 +101,7 @@ describe('bearerAuthHandler', () => { describe('setRuntimeApiHostAndAuthHandler', () => { const DEPLOY_SERVICE_ENDPOINTS = { prod: 'https://deploy-service.app-builder.adp.adobe.io', - stage: 'https://deploy-service.stg.app-builder.adp.adobe.io' + stage: 'https://deploy-service.stg.app-builder.corp.adp.adobe.io' } beforeEach(() => { From 2d1d3b0bab378a40d42baf18a52a04f70fbeebe8 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 6 Nov 2025 15:07:47 -0800 Subject: [PATCH 4/8] nit: add auditUserId in a way that is acceptable by copilot :fingerscrossed: --- src/commands/app/deploy.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/commands/app/deploy.js b/src/commands/app/deploy.js index 034b56d8..d0906f8f 100644 --- a/src/commands/app/deploy.js +++ b/src/commands/app/deploy.js @@ -132,8 +132,7 @@ 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]) - v.auditUserId = appInfo.auditUserId + const v = { 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) From 344095d1df1fd4578243e0a9cf40e73486cd196c Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 6 Nov 2025 15:22:49 -0800 Subject: [PATCH 5/8] nit: fixed nits --- src/commands/app/deploy.js | 2 +- src/lib/auth-helper.js | 9 ++++----- test/commands/lib/auth-helper.test.js | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/commands/app/deploy.js b/src/commands/app/deploy.js index d0906f8f..a9995d24 100644 --- a/src/commands/app/deploy.js +++ b/src/commands/app/deploy.js @@ -132,7 +132,7 @@ 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 = { auditUserId: appInfo.auditUserId, ...setRuntimeApiHostAndAuthHandler(values[i])} + const v = { 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 08fe829a..94a09338 100644 --- a/src/lib/auth-helper.js +++ b/src/lib/auth-helper.js @@ -95,18 +95,17 @@ const setRuntimeApiHostAndAuthHandler = (_config) => { * * @function getTokenData * @param {string} token - The JWT token to decode - * @returns {object} The decoded payload of the JWT token - * @returnss {null} If the token is invalid or cannot be decoded + * @returns {object} 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; + return null } const [, payload] = token.split('.', 3) if (!payload) { aioLogger.error('Invalid token provided to getTokenData :: not a jwt') - return null; + return null } try { const base64 = payload.replace(/-/g, '+').replace(/_/g, '/') @@ -116,7 +115,7 @@ const getTokenData = (token) => { return JSON.parse(decoded) } catch (e) { aioLogger.error('Error decoding token payload in getTokenData ::', e) - return null; + return null } } diff --git a/test/commands/lib/auth-helper.test.js b/test/commands/lib/auth-helper.test.js index a966e885..4c27f463 100644 --- a/test/commands/lib/auth-helper.test.js +++ b/test/commands/lib/auth-helper.test.js @@ -101,7 +101,7 @@ describe('bearerAuthHandler', () => { describe('setRuntimeApiHostAndAuthHandler', () => { const DEPLOY_SERVICE_ENDPOINTS = { prod: 'https://deploy-service.app-builder.adp.adobe.io', - stage: 'https://deploy-service.stg.app-builder.corp.adp.adobe.io' + stage: 'https://deploy-service.stg.app-builder.adp.adobe.io' } beforeEach(() => { From c4ea3beb4e524c31a8a048c0c80a0bc4bec11d86 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 6 Nov 2025 15:32:54 -0800 Subject: [PATCH 6/8] nit: jsdoc returns {object|null} Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/lib/auth-helper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/auth-helper.js b/src/lib/auth-helper.js index 94a09338..7233e6ac 100644 --- a/src/lib/auth-helper.js +++ b/src/lib/auth-helper.js @@ -95,7 +95,7 @@ const setRuntimeApiHostAndAuthHandler = (_config) => { * * @function getTokenData * @param {string} token - The JWT token to decode - * @returns {object} The decoded payload of the JWT token or null if the token is invalid or cannot be decoded + * @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') { From e219dc5829c343910011bc27c86d2a43d30c9806 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Fri, 7 Nov 2025 15:03:30 -0800 Subject: [PATCH 7/8] Rely on ims-lib getTokenData --- src/lib/auth-helper.js | 13 ++----------- test/commands/lib/auth-helper.test.js | 11 +++++++++-- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/lib/auth-helper.js b/src/lib/auth-helper.js index 7233e6ac..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' }) @@ -102,17 +102,8 @@ const getTokenData = (token) => { aioLogger.error('Invalid token provided to getTokenData :: not a string') return null } - const [, payload] = token.split('.', 3) - if (!payload) { - aioLogger.error('Invalid token provided to getTokenData :: not a jwt') - return null - } try { - const base64 = payload.replace(/-/g, '+').replace(/_/g, '/') - // add padding if necessary - const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4) - const decoded = Buffer.from(padded, 'base64').toString('utf-8') - return JSON.parse(decoded) + return getImsTokenData(token) } catch (e) { aioLogger.error('Error decoding token payload in getTokenData ::', e) return null diff --git a/test/commands/lib/auth-helper.test.js b/test/commands/lib/auth-helper.test.js index 4c27f463..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, getTokenData } = require('../../../src/lib/auth-helper') -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') @@ -58,18 +58,25 @@ describe('getAccessToken', () => { }) describe('getTokenData', () => { - test('should decode JWT token and return payload', () => { + 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() From cbe32e0285e20bcc3c958f73f1f676d455cc340e Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Fri, 7 Nov 2025 16:14:47 -0800 Subject: [PATCH 8/8] nit: code clarity, full test coverage --- src/commands/app/deploy.js | 7 +- test/commands/app/deploy.test.js | 133 +++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/src/commands/app/deploy.js b/src/commands/app/deploy.js index a9995d24..d868f36a 100644 --- a/src/commands/app/deploy.js +++ b/src/commands/app/deploy.js @@ -132,7 +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 = { auditUserId: appInfo.auditUserId, ...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/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({