Skip to content

Commit b151431

Browse files
feat: add appConfig value to auditUser cdn deploys (#888)
* feat: add appConfig value to auditUser cdn deploys * nit: firmly state that we throw an error if called with bad token * nit: added typechecking and tests * Rely on ims-lib getTokenData
1 parent 93d3d4f commit b151431

File tree

4 files changed

+197
-6
lines changed

4 files changed

+197
-6
lines changed

src/commands/app/deploy.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const {
2626
const rtLib = require('@adobe/aio-lib-runtime')
2727
const LogForwarding = require('../../lib/log-forwarding')
2828
const { sendAppAssetsDeployedAuditLog, sendAppDeployAuditLog } = require('../../lib/audit-logger')
29-
const { setRuntimeApiHostAndAuthHandler, getAccessToken } = require('../../lib/auth-helper')
29+
const { setRuntimeApiHostAndAuthHandler, getAccessToken, getTokenData } = require('../../lib/auth-helper')
3030
const logActions = require('../../lib/log-actions')
3131

3232
const PRE_DEPLOY_EVENT_REG = 'pre-deploy-event-reg'
@@ -68,6 +68,8 @@ class Deploy extends BuildCommand {
6868

6969
if (cliDetails?.accessToken) {
7070
try {
71+
// store user id from token data for cdn deploy audit metadata
72+
appInfo.auditUserId = getTokenData(cliDetails.accessToken)?.user_id
7173
// send audit log at start (don't wait for deployment to finish)
7274
await sendAppDeployAuditLog({
7375
accessToken: cliDetails?.accessToken,
@@ -130,8 +132,12 @@ class Deploy extends BuildCommand {
130132
// - break into smaller pieces deploy, allowing to first deploy all actions then all web assets
131133
for (let i = 0; i < keys.length; ++i) {
132134
const k = keys[i]
133-
const v = setRuntimeApiHostAndAuthHandler(values[i])
134-
135+
// auditUserId is only set if it is available in the token data
136+
// falsy because "", 0, false, null, undefined, NaN, etc. are all invalid values
137+
const v = {
138+
...(appInfo.auditUserId && { auditUserId: appInfo.auditUserId }),
139+
...setRuntimeApiHostAndAuthHandler(values[i])
140+
}
135141
await this.deploySingleConfig({ name: k, config: v, originalConfig: values[i], flags, spinner })
136142
if (cliDetails?.accessToken && v.app.hasFrontend && flags['web-assets']) {
137143
const opItems = getFilesCountWithExtension(v.web.distProd)

src/lib/auth-helper.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ OF ANY KIND, either express or implied. See the License for the specific languag
99
governing permissions and limitations under the License.
1010
*/
1111

12-
const { getToken, context } = require('@adobe/aio-lib-ims')
12+
const { getToken, context, getTokenData: getImsTokenData } = require('@adobe/aio-lib-ims')
1313
const { CLI } = require('@adobe/aio-lib-ims/src/context')
1414
const { getCliEnv } = require('@adobe/aio-lib-env')
1515
const aioLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-cli-plugin-app:auth-helper', { provider: 'debug' })
@@ -90,8 +90,29 @@ const setRuntimeApiHostAndAuthHandler = (_config) => {
9090
}
9191
}
9292

93+
/**
94+
* Decodes a JWT token and returns its payload as a JavaScript object.
95+
*
96+
* @function getTokenData
97+
* @param {string} token - The JWT token to decode
98+
* @returns {object|null} The decoded payload of the JWT token or null if the token is invalid or cannot be decoded
99+
*/
100+
const getTokenData = (token) => {
101+
if (typeof token !== 'string') {
102+
aioLogger.error('Invalid token provided to getTokenData :: not a string')
103+
return null
104+
}
105+
try {
106+
return getImsTokenData(token)
107+
} catch (e) {
108+
aioLogger.error('Error decoding token payload in getTokenData ::', e)
109+
return null
110+
}
111+
}
112+
93113
module.exports = {
94114
getAccessToken,
115+
getTokenData,
95116
bearerAuthHandler,
96117
setRuntimeApiHostAndAuthHandler
97118
}

test/commands/app/deploy.test.js

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,9 @@ beforeEach(() => {
198198
env: 'stage'
199199
}
200200
})
201+
authHelper.getTokenData.mockImplementation(() => {
202+
return null // default to null, tests can override
203+
})
201204
LogForwarding.init.mockResolvedValue(mockLogForwarding)
202205

203206
command = new TheCommand([])
@@ -670,6 +673,136 @@ describe('run', () => {
670673
expect(open).toHaveBeenCalledWith('http://prefix?fake=https://example.com')
671674
})
672675

676+
test('deploy should pass auditUserId to deployWeb config when user_id is present in token', async () => {
677+
const mockUserId = 'test-user-123'
678+
const mockToken = 'mock.token.value'
679+
680+
authHelper.getAccessToken.mockResolvedValueOnce({
681+
accessToken: mockToken,
682+
env: 'stage'
683+
})
684+
authHelper.getTokenData.mockReturnValueOnce({
685+
user_id: mockUserId
686+
})
687+
688+
command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig))
689+
mockWebLib.deployWeb.mockResolvedValue('https://example.com')
690+
691+
command.argv = []
692+
await command.run()
693+
694+
expect(command.error).toHaveBeenCalledTimes(0)
695+
expect(authHelper.getTokenData).toHaveBeenCalledWith(mockToken)
696+
expect(mockWebLib.deployWeb).toHaveBeenCalledWith(
697+
expect.objectContaining({
698+
auditUserId: mockUserId
699+
}),
700+
expect.any(Function)
701+
)
702+
})
703+
704+
test('deploy should NOT include auditUserId in config when user_id is undefined', async () => {
705+
const mockToken = 'mock.token.value'
706+
707+
authHelper.getAccessToken.mockResolvedValueOnce({
708+
accessToken: mockToken,
709+
env: 'stage'
710+
})
711+
authHelper.getTokenData.mockReturnValueOnce({
712+
// user_id is undefined
713+
})
714+
715+
command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig))
716+
mockWebLib.deployWeb.mockResolvedValue('https://example.com')
717+
718+
command.argv = []
719+
await command.run()
720+
721+
expect(command.error).toHaveBeenCalledTimes(0)
722+
expect(mockWebLib.deployWeb).toHaveBeenCalledWith(
723+
expect.not.objectContaining({
724+
auditUserId: expect.anything()
725+
}),
726+
expect.any(Function)
727+
)
728+
})
729+
730+
test('deploy should NOT include auditUserId in config when user_id is null', async () => {
731+
const mockToken = 'mock.token.value'
732+
733+
authHelper.getAccessToken.mockResolvedValueOnce({
734+
accessToken: mockToken,
735+
env: 'stage'
736+
})
737+
authHelper.getTokenData.mockReturnValueOnce({
738+
user_id: null
739+
})
740+
741+
command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig))
742+
mockWebLib.deployWeb.mockResolvedValue('https://example.com')
743+
744+
command.argv = []
745+
await command.run()
746+
747+
expect(command.error).toHaveBeenCalledTimes(0)
748+
expect(mockWebLib.deployWeb).toHaveBeenCalledWith(
749+
expect.not.objectContaining({
750+
auditUserId: expect.anything()
751+
}),
752+
expect.any(Function)
753+
)
754+
})
755+
756+
test('deploy should NOT include auditUserId in config when user_id is empty string', async () => {
757+
const mockToken = 'mock.token.value'
758+
759+
authHelper.getAccessToken.mockResolvedValueOnce({
760+
accessToken: mockToken,
761+
env: 'stage'
762+
})
763+
authHelper.getTokenData.mockReturnValueOnce({
764+
user_id: ''
765+
})
766+
767+
command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig))
768+
mockWebLib.deployWeb.mockResolvedValue('https://example.com')
769+
770+
command.argv = []
771+
await command.run()
772+
773+
expect(command.error).toHaveBeenCalledTimes(0)
774+
expect(mockWebLib.deployWeb).toHaveBeenCalledWith(
775+
expect.not.objectContaining({
776+
auditUserId: expect.anything()
777+
}),
778+
expect.any(Function)
779+
)
780+
})
781+
782+
test('deploy should NOT include auditUserId when getTokenData returns null', async () => {
783+
const mockToken = 'mock.token.value'
784+
785+
authHelper.getAccessToken.mockResolvedValueOnce({
786+
accessToken: mockToken,
787+
env: 'stage'
788+
})
789+
authHelper.getTokenData.mockReturnValueOnce(null)
790+
791+
command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig))
792+
mockWebLib.deployWeb.mockResolvedValue('https://example.com')
793+
794+
command.argv = []
795+
await command.run()
796+
797+
expect(command.error).toHaveBeenCalledTimes(0)
798+
expect(mockWebLib.deployWeb).toHaveBeenCalledWith(
799+
expect.not.objectContaining({
800+
auditUserId: expect.anything()
801+
}),
802+
expect.any(Function)
803+
)
804+
})
805+
673806
test('deploy should show action urls (web-export: true)', async () => {
674807
command.getAppExtConfigs.mockResolvedValueOnce(createAppConfig(command.appConfig))
675808
mockRuntimeLib.deployActions.mockResolvedValue({

test/commands/lib/auth-helper.test.js

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
const { getAccessToken, bearerAuthHandler, setRuntimeApiHostAndAuthHandler } = require('../../../src/lib/auth-helper')
2-
const { getToken, context } = require('@adobe/aio-lib-ims')
1+
const { getAccessToken, bearerAuthHandler, setRuntimeApiHostAndAuthHandler, getTokenData } = require('../../../src/lib/auth-helper')
2+
const { getToken, context, getTokenData: getImsTokenData } = require('@adobe/aio-lib-ims')
33
const { CLI } = require('@adobe/aio-lib-ims/src/context')
44
const { getCliEnv } = require('@adobe/aio-lib-env')
55

@@ -57,6 +57,37 @@ describe('getAccessToken', () => {
5757
})
5858
})
5959

60+
describe('getTokenData', () => {
61+
beforeEach(() => {
62+
jest.clearAllMocks()
63+
})
64+
65+
test('should call through to getImsTokenData to decode JWT token and return payload', () => {
66+
getImsTokenData.mockReturnValue({ user_id: '12345', name: 'Test User' })
67+
// Example JWT token with payload: {"user_id":"12345","name":"Test User"}
68+
const exampleToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMTIzNDUiLCJuYW1lIjoiVGVzdCBVc2VyIn0.sflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
69+
const result = getTokenData(exampleToken)
70+
expect(result).toEqual({ user_id: '12345', name: 'Test User' })
71+
})
72+
test('should return null for invalid token', () => {
73+
getImsTokenData.mockImplementation(() => { throw new Error('Invalid token') })
74+
const invalidToken = 'invalid.token.string'
75+
const result = getTokenData(invalidToken)
76+
expect(result).toBeNull()
77+
})
78+
test('should return null for malformed token', () => {
79+
getImsTokenData.mockImplementation(() => { throw new Error('Malformed token') })
80+
const malformedToken = 'malformedtoken'
81+
const result = getTokenData(malformedToken)
82+
expect(result).toBeNull()
83+
})
84+
test('should return null for non-string token', () => {
85+
const nonStringToken = 12345
86+
const result = getTokenData(nonStringToken)
87+
expect(result).toBeNull()
88+
})
89+
})
90+
6091
describe('bearerAuthHandler', () => {
6192
beforeEach(() => {
6293
jest.clearAllMocks()

0 commit comments

Comments
 (0)