From 8091373de809e499b15040b836c8ad534457c10f Mon Sep 17 00:00:00 2001 From: ajaz Date: Tue, 24 Feb 2026 20:14:59 +0530 Subject: [PATCH 1/6] feat: add Ims token fetch for db client --- package.json | 1 + src/DBBaseCommand.js | 32 ++-- src/constants/global.js | 1 + src/utils/authHelper.js | 150 ++++++++++++++++ test/DBBaseCommand.test.js | 51 ++++-- test/commands/app/db/provision.test.js | 6 +- test/jest.setup.js | 40 ++++- test/utils/authHelper.test.js | 238 +++++++++++++++++++++++++ 8 files changed, 490 insertions(+), 29 deletions(-) create mode 100644 src/utils/authHelper.js create mode 100644 test/utils/authHelper.test.js diff --git a/package.json b/package.json index 142799e..ab6739c 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "@adobe/aio-lib-core-logging": "^3", "@adobe/aio-lib-db": "^0.1.0-beta.8", "@adobe/aio-lib-env": "^3.0.1", + "@adobe/aio-lib-ims": "^8.0.0", "@adobe/aio-lib-state": "^5.3.0", "@inquirer/prompts": "^5", "@oclif/core": "^4", diff --git a/src/DBBaseCommand.js b/src/DBBaseCommand.js index 97d921c..f4783d8 100644 --- a/src/DBBaseCommand.js +++ b/src/DBBaseCommand.js @@ -12,10 +12,11 @@ governing permissions and limitations under the License. import { BaseCommand } from './BaseCommand.js' import config from '@adobe/aio-lib-core-config' -import { CONFIG_RUNTIME_AUTH, CONFIG_RUNTIME_NAMESPACE } from './constants/global.js' +import { CONFIG_RUNTIME_NAMESPACE } from './constants/global.js' import { AVAILABLE_REGIONS, CONFIG_DB_ENDPOINT, CONFIG_DB_REGION, DEFAULT_REGION } from './constants/db.js' import { Flags } from '@oclif/core' import { getCliEnv } from '@adobe/aio-lib-env' +import { getAccessToken } from './utils/authHelper.js' export class DBBaseCommand extends BaseCommand { async init () { @@ -33,12 +34,27 @@ export class DBBaseCommand extends BaseCommand { async initializeDBClient () { try { const region = this.flags?.region || config.get(CONFIG_DB_REGION) || DEFAULT_REGION + const runtimeNamespace = config.get(CONFIG_RUNTIME_NAMESPACE) + if (!runtimeNamespace) { + this.error( + `Database commands require App Builder project configuration. +Please make sure the 'AIO_RUNTIME_NAMESPACE' environment variable is configured` + ) + } + + const authToken = await getAccessToken() + if (!authToken) { + this.error( + 'Database commands require IMS token for authentication. Please make sure you have the required Ims credentials configured in environment variables.' + ) + } + // Get database configuration const dbConfig = { ow: { - namespace: config.get(CONFIG_RUNTIME_NAMESPACE), - auth: config.get(CONFIG_RUNTIME_AUTH) + namespace: runtimeNamespace }, + token: authToken, region } @@ -48,14 +64,6 @@ export class DBBaseCommand extends BaseCommand { this.error(`Invalid region '${region}' for the ${getCliEnv()} environment, must be one of: ${allowedRegions.join(', ')}`) } - // Validate required configuration - if (!(dbConfig.ow.namespace && dbConfig.ow.auth)) { - this.error( - `Database commands require App Builder project configuration. -Please make sure the 'AIO_RUNTIME_NAMESPACE' and 'AIO_RUNTIME_AUTH' environment variables are configured.` - ) - } - const endpointOverride = config.get(CONFIG_DB_ENDPOINT) if (endpointOverride) { process.env.AIO_DB_ENDPOINT = endpointOverride @@ -70,7 +78,7 @@ Please make sure the 'AIO_RUNTIME_NAMESPACE' and 'AIO_RUNTIME_AUTH' environment this.debugLogger?.info?.('Initializing DB client with config:', { namespace: dbConfig.ow.namespace, region: dbConfig.region, - hasAuth: !!dbConfig.ow.auth + hasToken: !!dbConfig.token }) // Initialize the database client diff --git a/src/constants/global.js b/src/constants/global.js index b9714f1..20282cc 100644 --- a/src/constants/global.js +++ b/src/constants/global.js @@ -12,3 +12,4 @@ governing permissions and limitations under the License. export const CONFIG_RUNTIME_NAMESPACE = 'runtime.namespace' export const CONFIG_RUNTIME_AUTH = 'runtime.auth' +export const CONFIG_IMS_CONTEXTS_PREFIX = 'ims.contexts' diff --git a/src/utils/authHelper.js b/src/utils/authHelper.js new file mode 100644 index 0000000..fff970e --- /dev/null +++ b/src/utils/authHelper.js @@ -0,0 +1,150 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +/** + * Helper to get the IMS access token using Adobe I/O SDK + * @returns {string} The IMS access token + */ + +import config from '@adobe/aio-lib-core-config' +import { CONFIG_IMS_CONTEXTS_PREFIX, CONFIG_RUNTIME_NAMESPACE } from '../constants/global.js' +import { getCliEnv } from '@adobe/aio-lib-env' + +const parseScopes = (scopes) => { + if (Array.isArray(scopes)) { + return scopes + } + + if (typeof scopes === 'string') { + try { + return JSON.parse(scopes) + } catch { + return scopes.split(',').map((scope) => scope.trim()).filter(Boolean) + } + } + + return [] +} + +const buildAuthConfig = (existingContext) => { + const clientId = existingContext?.client_id || process.env.IMS_OAUTH_S2S_CLIENT_ID + const clientSecret = existingContext?.client_secret || process.env.IMS_OAUTH_S2S_CLIENT_SECRET + const orgId = existingContext?.org_id || process.env.IMS_OAUTH_S2S_ORG_ID + const scopes = existingContext?.scopes || process.env.IMS_OAUTH_S2S_SCOPES + + if (!clientId || !clientSecret || !orgId || !scopes) { + throw new Error('Missing required credentials. Please set IMS_OAUTH_S2S_CLIENT_ID, IMS_OAUTH_S2S_CLIENT_SECRET, IMS_OAUTH_S2S_ORG_ID, and IMS_OAUTH_S2S_SCOPES environment variables.') + } + + return { + client_id: clientId, + client_secret: clientSecret, + org_id: orgId, + scopes: parseScopes(scopes) + } +} + +const extractAccessToken = (tokenResponse) => { + if (!tokenResponse) { + return null + } + + if (tokenResponse.payload?.access_token) { + return tokenResponse.payload.access_token + } + + if (tokenResponse.access_token) { + return tokenResponse.access_token + } + + if (typeof tokenResponse === 'string') { + return tokenResponse + } + + return null +} + +const getAccessTokenFromIms = async (ims, authConfig) => { + const tokenResponse = await ims.getAccessTokenByClientCredentials( + authConfig.client_id, + authConfig.client_secret, + authConfig.org_id, + authConfig.scopes + ) + + const accessToken = extractAccessToken(tokenResponse) + + if (!accessToken) { + throw new Error('Failed to generate access token. Please verify your credentials.') + } + + return accessToken +} + +/** + * Get or generate an IMS access token using OAuth Server-to-Server credentials. + * @returns {Promise} The access token + */ +export async function getAccessToken () { + const runtimeNamespace = config.get(CONFIG_RUNTIME_NAMESPACE) + + if (!runtimeNamespace) { + throw new Error('Runtime namespace is required. Please set CONFIG_RUNTIME_NAMESPACE.') + } + + const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.${runtimeNamespace}` + + try { + // eslint-disable-next-line node/no-unsupported-features/es-syntax + const ImsModule = await import('@adobe/aio-lib-ims') + const { Ims } = ImsModule + const ims = new Ims(getCliEnv()) + + // Check if context config already exists + const existingContext = config.get(imsContextKey) + + let authConfig = null + + if (existingContext) { + const currentToken = config.get(`${imsContextKey}.token`) + + // Check if token exists in selected context config and is valid + if (currentToken) { + const validToken = await ims.validateToken(currentToken) + if (validToken.valid) { + return currentToken + } + } + + // Otherwise, create auth config using existing context + authConfig = buildAuthConfig(existingContext) + } else { + // If no existing context, create auth config using environment configuration + authConfig = buildAuthConfig() + } + + // set context config + config.set(imsContextKey, authConfig) + + const accessToken = await getAccessTokenFromIms(ims, authConfig) + + // set token in context config for further use + config.set(`${imsContextKey}.token`, accessToken) + + return accessToken + } catch (error) { + if (error instanceof Error) { + throw error + } + throw new Error('Failed to retrieve access token: Unknown error') + } +} diff --git a/test/DBBaseCommand.test.js b/test/DBBaseCommand.test.js index c118af7..07760b2 100644 --- a/test/DBBaseCommand.test.js +++ b/test/DBBaseCommand.test.js @@ -10,13 +10,29 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ import { expect, jest } from '@jest/globals' -import { DBBaseCommand } from '../src/DBBaseCommand.js' -import { BaseCommand } from '../src/BaseCommand.js' import { AVAILABLE_REGIONS, DEFAULT_REGION } from '../src/constants/db.js' const mockInit = global.mockDBInit const mockDbInstance = global.mockDBInstance +// Mock getAccessToken for DBBaseCommand tests +const mockGetAccessToken = jest.fn() +jest.unstable_mockModule('../src/utils/authHelper.js', () => ({ + getAccessToken: mockGetAccessToken +})) + +let DBBaseCommand +let BaseCommand + +beforeAll(async () => { + // eslint-disable-next-line node/no-unsupported-features/es-syntax + const dbModule = await import('../src/DBBaseCommand.js') + // eslint-disable-next-line node/no-unsupported-features/es-syntax + const baseModule = await import('../src/BaseCommand.js') + DBBaseCommand = dbModule.DBBaseCommand + BaseCommand = baseModule.BaseCommand +}) + describe('prototype', () => { test('extends BaseCommand', () => { expect(DBBaseCommand.prototype instanceof BaseCommand).toBe(true) @@ -41,17 +57,22 @@ describe('init', () => { command.config = { runHook: jest.fn().mockResolvedValue({}) } + + // Reset and configure getAccessToken mock + mockGetAccessToken.mockReset() + mockGetAccessToken.mockResolvedValue('test-token') }) test('successful initialization', async () => { command.argv = [] await command.init() + expect(mockGetAccessToken).toHaveBeenCalled() expect(mockInit).toHaveBeenCalledWith({ ow: { - namespace: global.fakeConfig['runtime.namespace'], - auth: global.fakeConfig['runtime.auth'] + namespace: global.fakeConfig['runtime.namespace'] }, + token: 'test-token', region: DEFAULT_REGION }) expect(command.db).toBe(mockDbInstance) @@ -109,16 +130,16 @@ describe('init', () => { global.fakeConfig['runtime.namespace'] = null await expect(command.init()).rejects.toThrow( - 'Database commands require App Builder project configuration.\nPlease make sure the \'AIO_RUNTIME_NAMESPACE\' and \'AIO_RUNTIME_AUTH\' environment variables are configured.' + 'Database commands require App Builder project configuration.\nPlease make sure the \'AIO_RUNTIME_NAMESPACE\' environment variable is configured' ) }) - test('missing auth', async () => { + test('missing token', async () => { command.argv = [] - global.fakeConfig['runtime.auth'] = null + mockGetAccessToken.mockResolvedValue(null) await expect(command.init()).rejects.toThrow( - 'Database commands require App Builder project configuration.\nPlease make sure the \'AIO_RUNTIME_NAMESPACE\' and \'AIO_RUNTIME_AUTH\' environment variables are configured.' + 'Database commands require IMS token for authentication' ) }) @@ -166,7 +187,7 @@ describe('initializeDBClient', () => { expect.objectContaining({ namespace: global.fakeConfig['runtime.namespace'], region: 'amer', - hasAuth: true + hasToken: true }) ) }) @@ -193,9 +214,9 @@ describe('configuration', () => { test('dbConfig contains expected properties', async () => { const expectedConfig = { ow: { - namespace: global.fakeConfig['runtime.namespace'], - auth: global.fakeConfig['runtime.auth'] + namespace: global.fakeConfig['runtime.namespace'] }, + token: 'test-token', region: 'amer' } @@ -212,9 +233,9 @@ describe('configuration', () => { global.fakeConfig['db.endpoint'] = 'https://custom.db.com' const expectedConfig = { ow: { - namespace: global.fakeConfig['runtime.namespace'], - auth: global.fakeConfig['runtime.auth'] + namespace: global.fakeConfig['runtime.namespace'] }, + token: 'test-token', region: global.fakeConfig['db.region'] } @@ -229,9 +250,9 @@ describe('configuration', () => { test('dbConfig uses region flag', async () => { const expectedConfig = { ow: { - namespace: global.fakeConfig['runtime.namespace'], - auth: global.fakeConfig['runtime.auth'] + namespace: global.fakeConfig['runtime.namespace'] }, + token: 'test-token', region: 'emea' } diff --git a/test/commands/app/db/provision.test.js b/test/commands/app/db/provision.test.js index 5c4bd16..27fbf2c 100644 --- a/test/commands/app/db/provision.test.js +++ b/test/commands/app/db/provision.test.js @@ -277,7 +277,11 @@ describe('run', () => { test('provision with custom region', async () => { command.argv = ['--region', 'emea'] await command.init() - expect(global.mockDBInit).toHaveBeenCalledWith({ ow: expect.any(Object), region: 'emea' }) + expect(global.mockDBInit).toHaveBeenCalledWith({ + ow: expect.any(Object), + token: expect.any(String), + region: 'emea' + }) mockProvisionStatus.mockRejectedValue(new Error('not found')) mockConfirm.mockResolvedValue(true) diff --git a/test/jest.setup.js b/test/jest.setup.js index 0d95630..4310703 100644 --- a/test/jest.setup.js +++ b/test/jest.setup.js @@ -79,6 +79,22 @@ jest.unstable_mockModule('@adobe/aio-lib-env', () => ({ })) global.getCliEnvMock = () => mockEnv +// mock ims +const validateTokenMock = jest.fn() +const getAccessTokenByClientCredentialsMock = jest.fn() +const ImsMock = jest.fn().mockImplementation(() => ({ + validateToken: validateTokenMock, + getAccessTokenByClientCredentials: getAccessTokenByClientCredentialsMock +})) +jest.unstable_mockModule('@adobe/aio-lib-ims', () => ({ + Ims: ImsMock +})) +global.getImsMock = () => ({ + ImsMock, + validateTokenMock, + getAccessTokenByClientCredentialsMock +}) + beforeEach(() => { // trap console log stdout.start() @@ -91,10 +107,21 @@ beforeEach(() => { 'runtime.auth': 'auth', 'state.endpoint': null, 'db.endpoint': null, - 'db.region': null + 'db.region': null, + 'ims.contexts.test-namespace': { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + org_id: 'test-org-id', + scopes: ['test-scope'] + }, + 'ims.contexts.test-namespace.token': 'test-access-token' } delete process.env.AIO_STATE_ENDPOINT delete process.env.AIO_DB_ENDPOINT + process.env.IMS_OAUTH_S2S_CLIENT_ID = 'test-client-id' + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'test-client-secret' + process.env.IMS_OAUTH_S2S_ORG_ID = 'test-org-id' + process.env.IMS_OAUTH_S2S_SCOPES = '["test-scope"]' mockInit.mockReset() mockInit.mockResolvedValue(mockInstance) @@ -108,5 +135,16 @@ beforeEach(() => { mockEnv.mockReset() mockEnv.mockReturnValue('prod') + + config.set = (key, value) => { + global.fakeConfig[key] = value + } + + const imsMocks = global.getImsMock() + imsMocks.ImsMock.mockClear() + imsMocks.validateTokenMock.mockReset() + imsMocks.getAccessTokenByClientCredentialsMock.mockReset() + imsMocks.validateTokenMock.mockResolvedValue({ valid: true }) + imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue({ payload: { access_token: 'test-access-token' } }) }) afterEach(() => { stdout.stop(); stderr.stop() }) diff --git a/test/utils/authHelper.test.js b/test/utils/authHelper.test.js new file mode 100644 index 0000000..be8492e --- /dev/null +++ b/test/utils/authHelper.test.js @@ -0,0 +1,238 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { jest } from '@jest/globals' +import config from '@adobe/aio-lib-core-config' +import { CONFIG_IMS_CONTEXTS_PREFIX } from '../../src/constants/global.js' + +let getAccessToken + +beforeAll(async () => { + // eslint-disable-next-line node/no-unsupported-features/es-syntax + const authModule = await import('../../src/utils/authHelper.js') + getAccessToken = authModule.getAccessToken +}) + +describe('getAccessToken()', () => { + beforeEach(() => { + config.set = jest.fn() + const imsMocks = global.getImsMock() + imsMocks.validateTokenMock.mockReset() + imsMocks.getAccessTokenByClientCredentialsMock.mockReset() + imsMocks.ImsMock.mockClear() + delete process.env.IMS_OAUTH_S2S_CLIENT_ID + delete process.env.IMS_OAUTH_S2S_CLIENT_SECRET + delete process.env.IMS_OAUTH_S2S_ORG_ID + delete process.env.IMS_OAUTH_S2S_SCOPES + }) + + test('throws when runtime namespace is missing', async () => { + global.fakeConfig['runtime.namespace'] = null + await expect(getAccessToken()).rejects.toThrow('Runtime namespace is required. Please set CONFIG_RUNTIME_NAMESPACE.') + }) + + test('returns cached token when valid', async () => { + const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` + global.fakeConfig[imsContextKey] = { + client_id: 'client-id', + client_secret: 'client-secret', + org_id: 'org-id', + scopes: ['scope-a'] + } + global.fakeConfig[`${imsContextKey}.token`] = 'cached-token' + + const imsMocks = global.getImsMock() + imsMocks.validateTokenMock.mockResolvedValue({ valid: true }) + + await expect(getAccessToken()).resolves.toBe('cached-token') + expect(imsMocks.getAccessTokenByClientCredentialsMock).not.toHaveBeenCalled() + expect(config.set).not.toHaveBeenCalled() + }) + + test('refreshes token when cached token is invalid', async () => { + const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` + global.fakeConfig[imsContextKey] = { + client_id: 'client-id', + client_secret: 'client-secret', + org_id: 'org-id', + scopes: 'scope-a,scope-b' + } + global.fakeConfig[`${imsContextKey}.token`] = 'cached-token' + + const imsMocks = global.getImsMock() + imsMocks.validateTokenMock.mockResolvedValue({ valid: false }) + imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue({ payload: { access_token: 'new-token' } }) + + await expect(getAccessToken()).resolves.toBe('new-token') + expect(imsMocks.getAccessTokenByClientCredentialsMock).toHaveBeenCalledWith( + 'client-id', + 'client-secret', + 'org-id', + ['scope-a', 'scope-b'] + ) + expect(config.set).toHaveBeenCalledWith( + imsContextKey, + expect.objectContaining({ + client_id: 'client-id', + client_secret: 'client-secret', + org_id: 'org-id', + scopes: ['scope-a', 'scope-b'] + }) + ) + expect(config.set).toHaveBeenCalledWith(`${imsContextKey}.token`, 'new-token') + }) + + test('builds auth config from environment when no context exists', async () => { + const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` + global.fakeConfig[imsContextKey] = null + + process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' + process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' + process.env.IMS_OAUTH_S2S_SCOPES = '["scope-x","scope-y"]' + + const imsMocks = global.getImsMock() + imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue('token-string') + + await expect(getAccessToken()).resolves.toBe('token-string') + expect(imsMocks.getAccessTokenByClientCredentialsMock).toHaveBeenCalledWith( + 'env-client-id', + 'env-client-secret', + 'env-org-id', + ['scope-x', 'scope-y'] + ) + expect(config.set).toHaveBeenCalledWith( + imsContextKey, + expect.objectContaining({ + client_id: 'env-client-id', + client_secret: 'env-client-secret', + org_id: 'env-org-id', + scopes: ['scope-x', 'scope-y'] + }) + ) + expect(config.set).toHaveBeenCalledWith(`${imsContextKey}.token`, 'token-string') + }) + + test('uses array scopes from existing context', async () => { + const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` + global.fakeConfig[imsContextKey] = { + client_id: 'client-id', + client_secret: 'client-secret', + org_id: 'org-id', + scopes: ['scope-a', 'scope-b'] + } + global.fakeConfig[`${imsContextKey}.token`] = 'cached-token' + + const imsMocks = global.getImsMock() + imsMocks.validateTokenMock.mockResolvedValue({ valid: false }) + imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue({ access_token: 'array-scope-token' }) + + await expect(getAccessToken()).resolves.toBe('array-scope-token') + expect(imsMocks.getAccessTokenByClientCredentialsMock).toHaveBeenCalledWith( + 'client-id', + 'client-secret', + 'org-id', + ['scope-a', 'scope-b'] + ) + }) + + test('returns empty scopes array for non-string, non-array scopes', async () => { + const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` + global.fakeConfig[imsContextKey] = { + client_id: 'client-id', + client_secret: 'client-secret', + org_id: 'org-id', + scopes: 123 + } + global.fakeConfig[`${imsContextKey}.token`] = 'cached-token' + + const imsMocks = global.getImsMock() + imsMocks.validateTokenMock.mockResolvedValue({ valid: false }) + imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue({ access_token: 'empty-scope-token' }) + + await expect(getAccessToken()).resolves.toBe('empty-scope-token') + expect(imsMocks.getAccessTokenByClientCredentialsMock).toHaveBeenCalledWith( + 'client-id', + 'client-secret', + 'org-id', + [] + ) + }) + + test('throws when required credentials are missing', async () => { + const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` + global.fakeConfig[imsContextKey] = null + global.fakeConfig[`${imsContextKey}.token`] = null + delete process.env.IMS_OAUTH_S2S_CLIENT_ID + delete process.env.IMS_OAUTH_S2S_CLIENT_SECRET + delete process.env.IMS_OAUTH_S2S_ORG_ID + delete process.env.IMS_OAUTH_S2S_SCOPES + + await expect(getAccessToken()).rejects.toThrow( + 'Missing required credentials.' + ) + }) + + test('throws when token response is empty', async () => { + const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` + global.fakeConfig[imsContextKey] = { + client_id: 'client-id', + client_secret: 'client-secret', + org_id: 'org-id', + scopes: ['scope-a'] + } + global.fakeConfig[`${imsContextKey}.token`] = null + + const imsMocks = global.getImsMock() + imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue(null) + + await expect(getAccessToken()).rejects.toThrow( + 'Failed to generate access token. Please verify your credentials.' + ) + }) + + test('throws when token response is missing access token', async () => { + const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` + global.fakeConfig[imsContextKey] = { + client_id: 'client-id', + client_secret: 'client-secret', + org_id: 'org-id', + scopes: ['scope-a'] + } + global.fakeConfig[`${imsContextKey}.token`] = null + + const imsMocks = global.getImsMock() + imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue({}) + + await expect(getAccessToken()).rejects.toThrow( + 'Failed to generate access token. Please verify your credentials.' + ) + }) + + test('throws unknown error when IMS client rejects with non-error', async () => { + const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` + global.fakeConfig[imsContextKey] = { + client_id: 'client-id', + client_secret: 'client-secret', + org_id: 'org-id', + scopes: ['scope-a'] + } + global.fakeConfig[`${imsContextKey}.token`] = 'cached-token' + + const imsMocks = global.getImsMock() + imsMocks.validateTokenMock.mockResolvedValue({ valid: false }) + imsMocks.getAccessTokenByClientCredentialsMock.mockRejectedValue('boom') + + await expect(getAccessToken()).rejects.toThrow( + 'Failed to retrieve access token: Unknown error' + ) + }) +}) From ab542035dc790a084ee3159d366b1f7283b91733 Mon Sep 17 00:00:00 2001 From: ajaz Date: Fri, 27 Feb 2026 19:25:09 +0530 Subject: [PATCH 2/6] address review comments --- package.json | 2 +- src/utils/authHelper.js | 10 +--------- test/utils/authHelper.test.js | 24 +++++++++++++++++++++--- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index ab6739c..5162428 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "dependencies": { "@adobe/aio-lib-core-config": "^5", "@adobe/aio-lib-core-logging": "^3", - "@adobe/aio-lib-db": "^0.1.0-beta.8", + "@adobe/aio-lib-db": "^0.2.0-beta.1", "@adobe/aio-lib-env": "^3.0.1", "@adobe/aio-lib-ims": "^8.0.0", "@adobe/aio-lib-state": "^5.3.0", diff --git a/src/utils/authHelper.js b/src/utils/authHelper.js index fff970e..2c2632b 100644 --- a/src/utils/authHelper.js +++ b/src/utils/authHelper.js @@ -58,18 +58,10 @@ const extractAccessToken = (tokenResponse) => { return null } - if (tokenResponse.payload?.access_token) { + if (tokenResponse?.payload?.access_token) { return tokenResponse.payload.access_token } - if (tokenResponse.access_token) { - return tokenResponse.access_token - } - - if (typeof tokenResponse === 'string') { - return tokenResponse - } - return null } diff --git a/test/utils/authHelper.test.js b/test/utils/authHelper.test.js index be8492e..22fc357 100644 --- a/test/utils/authHelper.test.js +++ b/test/utils/authHelper.test.js @@ -100,7 +100,7 @@ describe('getAccessToken()', () => { process.env.IMS_OAUTH_S2S_SCOPES = '["scope-x","scope-y"]' const imsMocks = global.getImsMock() - imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue('token-string') + imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue({ payload: { access_token: 'token-string' } }) await expect(getAccessToken()).resolves.toBe('token-string') expect(imsMocks.getAccessTokenByClientCredentialsMock).toHaveBeenCalledWith( @@ -133,7 +133,7 @@ describe('getAccessToken()', () => { const imsMocks = global.getImsMock() imsMocks.validateTokenMock.mockResolvedValue({ valid: false }) - imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue({ access_token: 'array-scope-token' }) + imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue({ payload: { access_token: 'array-scope-token' } }) await expect(getAccessToken()).resolves.toBe('array-scope-token') expect(imsMocks.getAccessTokenByClientCredentialsMock).toHaveBeenCalledWith( @@ -156,7 +156,7 @@ describe('getAccessToken()', () => { const imsMocks = global.getImsMock() imsMocks.validateTokenMock.mockResolvedValue({ valid: false }) - imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue({ access_token: 'empty-scope-token' }) + imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue({ payload: { access_token: 'empty-scope-token' } }) await expect(getAccessToken()).resolves.toBe('empty-scope-token') expect(imsMocks.getAccessTokenByClientCredentialsMock).toHaveBeenCalledWith( @@ -217,6 +217,24 @@ describe('getAccessToken()', () => { ) }) + test('throws when token response is a raw string', async () => { + const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` + global.fakeConfig[imsContextKey] = { + client_id: 'client-id', + client_secret: 'client-secret', + org_id: 'org-id', + scopes: ['scope-a'] + } + global.fakeConfig[`${imsContextKey}.token`] = null + + const imsMocks = global.getImsMock() + imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue('raw-token-string') + + await expect(getAccessToken()).rejects.toThrow( + 'Failed to generate access token. Please verify your credentials.' + ) + }) + test('throws unknown error when IMS client rejects with non-error', async () => { const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` global.fakeConfig[imsContextKey] = { From 123ef3e0f341acbe5147f362f4e4e2ec2fb1670e Mon Sep 17 00:00:00 2001 From: ajaz Date: Mon, 2 Mar 2026 17:37:39 +0530 Subject: [PATCH 3/6] refactor: use ims lib for token and context handling --- src/constants/global.js | 3 +- src/utils/authHelper.js | 115 +++++------------ test/jest.setup.js | 32 +++-- test/utils/authHelper.test.js | 233 ++++++++++------------------------ 4 files changed, 114 insertions(+), 269 deletions(-) diff --git a/src/constants/global.js b/src/constants/global.js index 20282cc..20d56ec 100644 --- a/src/constants/global.js +++ b/src/constants/global.js @@ -12,4 +12,5 @@ governing permissions and limitations under the License. export const CONFIG_RUNTIME_NAMESPACE = 'runtime.namespace' export const CONFIG_RUNTIME_AUTH = 'runtime.auth' -export const CONFIG_IMS_CONTEXTS_PREFIX = 'ims.contexts' +export const CONFIG_IMS_TECHNICAL_ACCOUNT_EMAIL_PLACEHOLDER = 'dummy@techacct.adobe.com' +export const CONFIG_IMS_TECHNICAL_ACCOUNT_ID_PLACEHOLDER = 'dummy@techacct.adobe.com' diff --git a/src/utils/authHelper.js b/src/utils/authHelper.js index 2c2632b..13067ec 100644 --- a/src/utils/authHelper.js +++ b/src/utils/authHelper.js @@ -16,30 +16,26 @@ governing permissions and limitations under the License. */ import config from '@adobe/aio-lib-core-config' -import { CONFIG_IMS_CONTEXTS_PREFIX, CONFIG_RUNTIME_NAMESPACE } from '../constants/global.js' -import { getCliEnv } from '@adobe/aio-lib-env' - -const parseScopes = (scopes) => { - if (Array.isArray(scopes)) { - return scopes - } - - if (typeof scopes === 'string') { - try { - return JSON.parse(scopes) - } catch { - return scopes.split(',').map((scope) => scope.trim()).filter(Boolean) - } +import { CONFIG_RUNTIME_NAMESPACE, CONFIG_IMS_TECHNICAL_ACCOUNT_EMAIL_PLACEHOLDER, CONFIG_IMS_TECHNICAL_ACCOUNT_ID_PLACEHOLDER } from '../constants/global.js' +const normalizeArrayString = (value) => { + try { + const parsed = JSON.parse(value) + console.log(parsed) + return Array.isArray(parsed) ? JSON.stringify(parsed) : '[]' + } catch { + const items = value.split(',').map((entry) => entry.trim()).filter(Boolean) + console.log(items) + return JSON.stringify(items) } - - return [] } -const buildAuthConfig = (existingContext) => { - const clientId = existingContext?.client_id || process.env.IMS_OAUTH_S2S_CLIENT_ID - const clientSecret = existingContext?.client_secret || process.env.IMS_OAUTH_S2S_CLIENT_SECRET - const orgId = existingContext?.org_id || process.env.IMS_OAUTH_S2S_ORG_ID - const scopes = existingContext?.scopes || process.env.IMS_OAUTH_S2S_SCOPES +const buildAuthConfig = () => { + const clientId = process.env.IMS_OAUTH_S2S_CLIENT_ID + const clientSecret = process.env.IMS_OAUTH_S2S_CLIENT_SECRET + const orgId = process.env.IMS_OAUTH_S2S_ORG_ID + const scopes = process.env.IMS_OAUTH_S2S_SCOPES + const technicalAccountEmail = process.env.IMS_OAUTH_S2S_TECHNICAL_ACCOUNT_EMAIL || CONFIG_IMS_TECHNICAL_ACCOUNT_EMAIL_PLACEHOLDER + const technicalAccountId = process.env.IMS_OAUTH_S2S_TECHNICAL_ACCOUNT_ID || CONFIG_IMS_TECHNICAL_ACCOUNT_ID_PLACEHOLDER if (!clientId || !clientSecret || !orgId || !scopes) { throw new Error('Missing required credentials. Please set IMS_OAUTH_S2S_CLIENT_ID, IMS_OAUTH_S2S_CLIENT_SECRET, IMS_OAUTH_S2S_ORG_ID, and IMS_OAUTH_S2S_SCOPES environment variables.') @@ -47,41 +43,14 @@ const buildAuthConfig = (existingContext) => { return { client_id: clientId, - client_secret: clientSecret, - org_id: orgId, - scopes: parseScopes(scopes) + client_secrets: normalizeArrayString(clientSecret), + ims_org_id: orgId, + scopes: normalizeArrayString(scopes), + technical_account_email: technicalAccountEmail, + technical_account_id: technicalAccountId } } -const extractAccessToken = (tokenResponse) => { - if (!tokenResponse) { - return null - } - - if (tokenResponse?.payload?.access_token) { - return tokenResponse.payload.access_token - } - - return null -} - -const getAccessTokenFromIms = async (ims, authConfig) => { - const tokenResponse = await ims.getAccessTokenByClientCredentials( - authConfig.client_id, - authConfig.client_secret, - authConfig.org_id, - authConfig.scopes - ) - - const accessToken = extractAccessToken(tokenResponse) - - if (!accessToken) { - throw new Error('Failed to generate access token. Please verify your credentials.') - } - - return accessToken -} - /** * Get or generate an IMS access token using OAuth Server-to-Server credentials. * @returns {Promise} The access token @@ -93,45 +62,19 @@ export async function getAccessToken () { throw new Error('Runtime namespace is required. Please set CONFIG_RUNTIME_NAMESPACE.') } - const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.${runtimeNamespace}` - try { // eslint-disable-next-line node/no-unsupported-features/es-syntax - const ImsModule = await import('@adobe/aio-lib-ims') - const { Ims } = ImsModule - const ims = new Ims(getCliEnv()) + const imsLib = await import('@adobe/aio-lib-ims') + const { context, getToken } = imsLib.default || imsLib + const authConfig = buildAuthConfig() - // Check if context config already exists - const existingContext = config.get(imsContextKey) + await context.set(runtimeNamespace, authConfig, true) + const accessToken = await getToken(runtimeNamespace) - let authConfig = null - - if (existingContext) { - const currentToken = config.get(`${imsContextKey}.token`) - - // Check if token exists in selected context config and is valid - if (currentToken) { - const validToken = await ims.validateToken(currentToken) - if (validToken.valid) { - return currentToken - } - } - - // Otherwise, create auth config using existing context - authConfig = buildAuthConfig(existingContext) - } else { - // If no existing context, create auth config using environment configuration - authConfig = buildAuthConfig() + if (!accessToken) { + throw new Error('Failed to generate access token. Please verify your credentials.') } - // set context config - config.set(imsContextKey, authConfig) - - const accessToken = await getAccessTokenFromIms(ims, authConfig) - - // set token in context config for further use - config.set(`${imsContextKey}.token`, accessToken) - return accessToken } catch (error) { if (error instanceof Error) { diff --git a/test/jest.setup.js b/test/jest.setup.js index 4310703..c523980 100644 --- a/test/jest.setup.js +++ b/test/jest.setup.js @@ -80,19 +80,18 @@ jest.unstable_mockModule('@adobe/aio-lib-env', () => ({ global.getCliEnvMock = () => mockEnv // mock ims -const validateTokenMock = jest.fn() -const getAccessTokenByClientCredentialsMock = jest.fn() -const ImsMock = jest.fn().mockImplementation(() => ({ - validateToken: validateTokenMock, - getAccessTokenByClientCredentials: getAccessTokenByClientCredentialsMock -})) -jest.unstable_mockModule('@adobe/aio-lib-ims', () => ({ - Ims: ImsMock -})) +const imsContextSetMock = jest.fn() +const imsGetTokenMock = jest.fn() +const imsModule = { context: { set: imsContextSetMock }, getToken: imsGetTokenMock } +jest.unstable_mockModule('@adobe/aio-lib-ims', () => { + if (global.__ims_no_default) { + return imsModule + } + return { ...imsModule, default: imsModule } +}) global.getImsMock = () => ({ - ImsMock, - validateTokenMock, - getAccessTokenByClientCredentialsMock + imsContextSetMock, + imsGetTokenMock }) beforeEach(() => { @@ -141,10 +140,9 @@ beforeEach(() => { } const imsMocks = global.getImsMock() - imsMocks.ImsMock.mockClear() - imsMocks.validateTokenMock.mockReset() - imsMocks.getAccessTokenByClientCredentialsMock.mockReset() - imsMocks.validateTokenMock.mockResolvedValue({ valid: true }) - imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue({ payload: { access_token: 'test-access-token' } }) + imsMocks.imsContextSetMock.mockReset() + imsMocks.imsGetTokenMock.mockReset() + imsMocks.imsGetTokenMock.mockResolvedValue('test-access-token') + global.__ims_no_default = false }) afterEach(() => { stdout.stop(); stderr.stop() }) diff --git a/test/utils/authHelper.test.js b/test/utils/authHelper.test.js index 22fc357..a5a3e2d 100644 --- a/test/utils/authHelper.test.js +++ b/test/utils/authHelper.test.js @@ -10,28 +10,29 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ import { jest } from '@jest/globals' -import config from '@adobe/aio-lib-core-config' -import { CONFIG_IMS_CONTEXTS_PREFIX } from '../../src/constants/global.js' +const loadGetAccessToken = async () => { + // eslint-disable-next-line node/no-unsupported-features/es-syntax + const authModule = await import('../../src/utils/authHelper.js') + return authModule.getAccessToken +} let getAccessToken beforeAll(async () => { - // eslint-disable-next-line node/no-unsupported-features/es-syntax - const authModule = await import('../../src/utils/authHelper.js') - getAccessToken = authModule.getAccessToken + getAccessToken = await loadGetAccessToken() }) describe('getAccessToken()', () => { beforeEach(() => { - config.set = jest.fn() const imsMocks = global.getImsMock() - imsMocks.validateTokenMock.mockReset() - imsMocks.getAccessTokenByClientCredentialsMock.mockReset() - imsMocks.ImsMock.mockClear() + imsMocks.imsContextSetMock.mockReset() + imsMocks.imsGetTokenMock.mockReset() delete process.env.IMS_OAUTH_S2S_CLIENT_ID delete process.env.IMS_OAUTH_S2S_CLIENT_SECRET delete process.env.IMS_OAUTH_S2S_ORG_ID delete process.env.IMS_OAUTH_S2S_SCOPES + delete process.env.IMS_OAUTH_S2S_TECHNICAL_ACCOUNT_EMAIL + delete process.env.IMS_OAUTH_S2S_TECHNICAL_ACCOUNT_ID }) test('throws when runtime namespace is missing', async () => { @@ -39,138 +40,69 @@ describe('getAccessToken()', () => { await expect(getAccessToken()).rejects.toThrow('Runtime namespace is required. Please set CONFIG_RUNTIME_NAMESPACE.') }) - test('returns cached token when valid', async () => { - const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` - global.fakeConfig[imsContextKey] = { - client_id: 'client-id', - client_secret: 'client-secret', - org_id: 'org-id', - scopes: ['scope-a'] - } - global.fakeConfig[`${imsContextKey}.token`] = 'cached-token' - - const imsMocks = global.getImsMock() - imsMocks.validateTokenMock.mockResolvedValue({ valid: true }) - - await expect(getAccessToken()).resolves.toBe('cached-token') - expect(imsMocks.getAccessTokenByClientCredentialsMock).not.toHaveBeenCalled() - expect(config.set).not.toHaveBeenCalled() - }) - - test('refreshes token when cached token is invalid', async () => { - const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` - global.fakeConfig[imsContextKey] = { - client_id: 'client-id', - client_secret: 'client-secret', - org_id: 'org-id', - scopes: 'scope-a,scope-b' - } - global.fakeConfig[`${imsContextKey}.token`] = 'cached-token' - - const imsMocks = global.getImsMock() - imsMocks.validateTokenMock.mockResolvedValue({ valid: false }) - imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue({ payload: { access_token: 'new-token' } }) - - await expect(getAccessToken()).resolves.toBe('new-token') - expect(imsMocks.getAccessTokenByClientCredentialsMock).toHaveBeenCalledWith( - 'client-id', - 'client-secret', - 'org-id', - ['scope-a', 'scope-b'] - ) - expect(config.set).toHaveBeenCalledWith( - imsContextKey, - expect.objectContaining({ - client_id: 'client-id', - client_secret: 'client-secret', - org_id: 'org-id', - scopes: ['scope-a', 'scope-b'] - }) - ) - expect(config.set).toHaveBeenCalledWith(`${imsContextKey}.token`, 'new-token') - }) - - test('builds auth config from environment when no context exists', async () => { - const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` - global.fakeConfig[imsContextKey] = null - + test('builds auth config from environment and returns token (json scopes)', async () => { process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' process.env.IMS_OAUTH_S2S_SCOPES = '["scope-x","scope-y"]' const imsMocks = global.getImsMock() - imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue({ payload: { access_token: 'token-string' } }) + imsMocks.imsGetTokenMock.mockResolvedValue('token-string') await expect(getAccessToken()).resolves.toBe('token-string') - expect(imsMocks.getAccessTokenByClientCredentialsMock).toHaveBeenCalledWith( - 'env-client-id', - 'env-client-secret', - 'env-org-id', - ['scope-x', 'scope-y'] - ) - expect(config.set).toHaveBeenCalledWith( - imsContextKey, + expect(imsMocks.imsContextSetMock).toHaveBeenCalledWith( + 'test-namespace', expect.objectContaining({ client_id: 'env-client-id', - client_secret: 'env-client-secret', - org_id: 'env-org-id', - scopes: ['scope-x', 'scope-y'] - }) + client_secrets: '["env-client-secret"]', + ims_org_id: 'env-org-id', + scopes: '["scope-x","scope-y"]', + technical_account_email: 'dummy@techacct.adobe.com', + technical_account_id: 'dummy@techacct.adobe.com' + }), + true ) - expect(config.set).toHaveBeenCalledWith(`${imsContextKey}.token`, 'token-string') }) - test('uses array scopes from existing context', async () => { - const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` - global.fakeConfig[imsContextKey] = { - client_id: 'client-id', - client_secret: 'client-secret', - org_id: 'org-id', - scopes: ['scope-a', 'scope-b'] - } - global.fakeConfig[`${imsContextKey}.token`] = 'cached-token' + test('builds auth config from environment and returns token (csv scopes)', async () => { + process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' + process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' + process.env.IMS_OAUTH_S2S_SCOPES = 'scope-a, scope-b' const imsMocks = global.getImsMock() - imsMocks.validateTokenMock.mockResolvedValue({ valid: false }) - imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue({ payload: { access_token: 'array-scope-token' } }) + imsMocks.imsGetTokenMock.mockResolvedValue('csv-token') - await expect(getAccessToken()).resolves.toBe('array-scope-token') - expect(imsMocks.getAccessTokenByClientCredentialsMock).toHaveBeenCalledWith( - 'client-id', - 'client-secret', - 'org-id', - ['scope-a', 'scope-b'] + await expect(getAccessToken()).resolves.toBe('csv-token') + expect(imsMocks.imsContextSetMock).toHaveBeenCalledWith( + 'test-namespace', + expect.objectContaining({ + scopes: '["scope-a","scope-b"]' + }), + true ) }) - test('returns empty scopes array for non-string, non-array scopes', async () => { - const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` - global.fakeConfig[imsContextKey] = { - client_id: 'client-id', - client_secret: 'client-secret', - org_id: 'org-id', - scopes: 123 - } - global.fakeConfig[`${imsContextKey}.token`] = 'cached-token' + test('builds auth config with empty scopes array when scope list is null', async () => { + process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' + process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' + process.env.IMS_OAUTH_S2S_SCOPES = 'null' const imsMocks = global.getImsMock() - imsMocks.validateTokenMock.mockResolvedValue({ valid: false }) - imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue({ payload: { access_token: 'empty-scope-token' } }) + imsMocks.imsGetTokenMock.mockResolvedValue('empty-scope-token') await expect(getAccessToken()).resolves.toBe('empty-scope-token') - expect(imsMocks.getAccessTokenByClientCredentialsMock).toHaveBeenCalledWith( - 'client-id', - 'client-secret', - 'org-id', - [] + expect(imsMocks.imsContextSetMock).toHaveBeenCalledWith( + 'test-namespace', + expect.objectContaining({ + scopes: '[]' + }), + true ) }) test('throws when required credentials are missing', async () => { - const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` - global.fakeConfig[imsContextKey] = null - global.fakeConfig[`${imsContextKey}.token`] = null delete process.env.IMS_OAUTH_S2S_CLIENT_ID delete process.env.IMS_OAUTH_S2S_CLIENT_SECRET delete process.env.IMS_OAUTH_S2S_ORG_ID @@ -182,75 +114,46 @@ describe('getAccessToken()', () => { }) test('throws when token response is empty', async () => { - const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` - global.fakeConfig[imsContextKey] = { - client_id: 'client-id', - client_secret: 'client-secret', - org_id: 'org-id', - scopes: ['scope-a'] - } - global.fakeConfig[`${imsContextKey}.token`] = null + process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' + process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' + process.env.IMS_OAUTH_S2S_SCOPES = 'scope-a' const imsMocks = global.getImsMock() - imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue(null) + imsMocks.imsGetTokenMock.mockResolvedValue(null) await expect(getAccessToken()).rejects.toThrow( 'Failed to generate access token. Please verify your credentials.' ) }) - test('throws when token response is missing access token', async () => { - const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` - global.fakeConfig[imsContextKey] = { - client_id: 'client-id', - client_secret: 'client-secret', - org_id: 'org-id', - scopes: ['scope-a'] - } - global.fakeConfig[`${imsContextKey}.token`] = null + test('throws unknown error when IMS client rejects with non-error', async () => { + process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' + process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' + process.env.IMS_OAUTH_S2S_SCOPES = 'scope-a' const imsMocks = global.getImsMock() - imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue({}) + imsMocks.imsGetTokenMock.mockRejectedValue('boom') await expect(getAccessToken()).rejects.toThrow( - 'Failed to generate access token. Please verify your credentials.' + 'Failed to retrieve access token: Unknown error' ) }) - test('throws when token response is a raw string', async () => { - const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` - global.fakeConfig[imsContextKey] = { - client_id: 'client-id', - client_secret: 'client-secret', - org_id: 'org-id', - scopes: ['scope-a'] - } - global.fakeConfig[`${imsContextKey}.token`] = null + test('works when ims lib does not provide a default export', async () => { + global.__ims_no_default = true + jest.resetModules() + getAccessToken = await loadGetAccessToken() - const imsMocks = global.getImsMock() - imsMocks.getAccessTokenByClientCredentialsMock.mockResolvedValue('raw-token-string') - - await expect(getAccessToken()).rejects.toThrow( - 'Failed to generate access token. Please verify your credentials.' - ) - }) - - test('throws unknown error when IMS client rejects with non-error', async () => { - const imsContextKey = `${CONFIG_IMS_CONTEXTS_PREFIX}.test-namespace` - global.fakeConfig[imsContextKey] = { - client_id: 'client-id', - client_secret: 'client-secret', - org_id: 'org-id', - scopes: ['scope-a'] - } - global.fakeConfig[`${imsContextKey}.token`] = 'cached-token' + process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' + process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' + process.env.IMS_OAUTH_S2S_SCOPES = '["scope-a"]' const imsMocks = global.getImsMock() - imsMocks.validateTokenMock.mockResolvedValue({ valid: false }) - imsMocks.getAccessTokenByClientCredentialsMock.mockRejectedValue('boom') + imsMocks.imsGetTokenMock.mockResolvedValue('token-string') - await expect(getAccessToken()).rejects.toThrow( - 'Failed to retrieve access token: Unknown error' - ) + await expect(getAccessToken()).resolves.toBe('token-string') }) }) From 793e5d0bdd7bc45e25c592b01a23ee1391b3335f Mon Sep 17 00:00:00 2001 From: ajaz Date: Mon, 2 Mar 2026 17:59:21 +0530 Subject: [PATCH 4/6] fix: tests --- src/utils/authHelper.js | 7 +++---- test/jest.setup.js | 1 + test/utils/authHelper.test.js | 17 ++++++++++++----- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/utils/authHelper.js b/src/utils/authHelper.js index 13067ec..cb1cde9 100644 --- a/src/utils/authHelper.js +++ b/src/utils/authHelper.js @@ -15,8 +15,7 @@ governing permissions and limitations under the License. * @returns {string} The IMS access token */ -import config from '@adobe/aio-lib-core-config' -import { CONFIG_RUNTIME_NAMESPACE, CONFIG_IMS_TECHNICAL_ACCOUNT_EMAIL_PLACEHOLDER, CONFIG_IMS_TECHNICAL_ACCOUNT_ID_PLACEHOLDER } from '../constants/global.js' +import { CONFIG_IMS_TECHNICAL_ACCOUNT_EMAIL_PLACEHOLDER, CONFIG_IMS_TECHNICAL_ACCOUNT_ID_PLACEHOLDER } from '../constants/global.js' const normalizeArrayString = (value) => { try { const parsed = JSON.parse(value) @@ -56,10 +55,10 @@ const buildAuthConfig = () => { * @returns {Promise} The access token */ export async function getAccessToken () { - const runtimeNamespace = config.get(CONFIG_RUNTIME_NAMESPACE) + const runtimeNamespace = process.env.AIO_RUNTIME_NAMESPACE if (!runtimeNamespace) { - throw new Error('Runtime namespace is required. Please set CONFIG_RUNTIME_NAMESPACE.') + throw new Error('Runtime namespace is required. Please set AIO_RUNTIME_NAMESPACE environment variable.') } try { diff --git a/test/jest.setup.js b/test/jest.setup.js index c523980..a32ada8 100644 --- a/test/jest.setup.js +++ b/test/jest.setup.js @@ -117,6 +117,7 @@ beforeEach(() => { } delete process.env.AIO_STATE_ENDPOINT delete process.env.AIO_DB_ENDPOINT + process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace' process.env.IMS_OAUTH_S2S_CLIENT_ID = 'test-client-id' process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'test-client-secret' process.env.IMS_OAUTH_S2S_ORG_ID = 'test-org-id' diff --git a/test/utils/authHelper.test.js b/test/utils/authHelper.test.js index a5a3e2d..46798fe 100644 --- a/test/utils/authHelper.test.js +++ b/test/utils/authHelper.test.js @@ -27,6 +27,7 @@ describe('getAccessToken()', () => { const imsMocks = global.getImsMock() imsMocks.imsContextSetMock.mockReset() imsMocks.imsGetTokenMock.mockReset() + delete process.env.AIO_RUNTIME_NAMESPACE delete process.env.IMS_OAUTH_S2S_CLIENT_ID delete process.env.IMS_OAUTH_S2S_CLIENT_SECRET delete process.env.IMS_OAUTH_S2S_ORG_ID @@ -36,11 +37,11 @@ describe('getAccessToken()', () => { }) test('throws when runtime namespace is missing', async () => { - global.fakeConfig['runtime.namespace'] = null - await expect(getAccessToken()).rejects.toThrow('Runtime namespace is required. Please set CONFIG_RUNTIME_NAMESPACE.') + await expect(getAccessToken()).rejects.toThrow('Runtime namespace is required. Please set AIO_RUNTIME_NAMESPACE environment variable.') }) test('builds auth config from environment and returns token (json scopes)', async () => { + process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace' process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' @@ -65,15 +66,16 @@ describe('getAccessToken()', () => { }) test('builds auth config from environment and returns token (csv scopes)', async () => { + process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace' process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' process.env.IMS_OAUTH_S2S_SCOPES = 'scope-a, scope-b' const imsMocks = global.getImsMock() - imsMocks.imsGetTokenMock.mockResolvedValue('csv-token') + imsMocks.imsGetTokenMock.mockResolvedValue('token') - await expect(getAccessToken()).resolves.toBe('csv-token') + await expect(getAccessToken()).resolves.toBe('token') expect(imsMocks.imsContextSetMock).toHaveBeenCalledWith( 'test-namespace', expect.objectContaining({ @@ -84,6 +86,7 @@ describe('getAccessToken()', () => { }) test('builds auth config with empty scopes array when scope list is null', async () => { + process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace' process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' @@ -103,6 +106,7 @@ describe('getAccessToken()', () => { }) test('throws when required credentials are missing', async () => { + process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace' delete process.env.IMS_OAUTH_S2S_CLIENT_ID delete process.env.IMS_OAUTH_S2S_CLIENT_SECRET delete process.env.IMS_OAUTH_S2S_ORG_ID @@ -114,6 +118,7 @@ describe('getAccessToken()', () => { }) test('throws when token response is empty', async () => { + process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace' process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' @@ -128,13 +133,14 @@ describe('getAccessToken()', () => { }) test('throws unknown error when IMS client rejects with non-error', async () => { + process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace' process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' process.env.IMS_OAUTH_S2S_SCOPES = 'scope-a' const imsMocks = global.getImsMock() - imsMocks.imsGetTokenMock.mockRejectedValue('boom') + imsMocks.imsGetTokenMock.mockRejectedValue('failed to get token') await expect(getAccessToken()).rejects.toThrow( 'Failed to retrieve access token: Unknown error' @@ -146,6 +152,7 @@ describe('getAccessToken()', () => { jest.resetModules() getAccessToken = await loadGetAccessToken() + process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace' process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' From 6a492a629d8fc45b47c4613fba3923f496688b58 Mon Sep 17 00:00:00 2001 From: ajaz Date: Mon, 2 Mar 2026 18:07:36 +0530 Subject: [PATCH 5/6] fix: minor --- src/utils/authHelper.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/utils/authHelper.js b/src/utils/authHelper.js index cb1cde9..c5fcd0e 100644 --- a/src/utils/authHelper.js +++ b/src/utils/authHelper.js @@ -19,11 +19,9 @@ import { CONFIG_IMS_TECHNICAL_ACCOUNT_EMAIL_PLACEHOLDER, CONFIG_IMS_TECHNICAL_AC const normalizeArrayString = (value) => { try { const parsed = JSON.parse(value) - console.log(parsed) return Array.isArray(parsed) ? JSON.stringify(parsed) : '[]' } catch { const items = value.split(',').map((entry) => entry.trim()).filter(Boolean) - console.log(items) return JSON.stringify(items) } } From 6af65a2959a2fac8876586333509110989c033af Mon Sep 17 00:00:00 2001 From: ajaz Date: Tue, 3 Mar 2026 15:34:19 +0530 Subject: [PATCH 6/6] fix: ims context name and address review comments --- src/utils/authHelper.js | 11 +++++------ test/utils/authHelper.test.js | 24 +++--------------------- 2 files changed, 8 insertions(+), 27 deletions(-) diff --git a/src/utils/authHelper.js b/src/utils/authHelper.js index c5fcd0e..d403970 100644 --- a/src/utils/authHelper.js +++ b/src/utils/authHelper.js @@ -14,7 +14,7 @@ governing permissions and limitations under the License. * Helper to get the IMS access token using Adobe I/O SDK * @returns {string} The IMS access token */ - +import aioLibIms from '@adobe/aio-lib-ims' import { CONFIG_IMS_TECHNICAL_ACCOUNT_EMAIL_PLACEHOLDER, CONFIG_IMS_TECHNICAL_ACCOUNT_ID_PLACEHOLDER } from '../constants/global.js' const normalizeArrayString = (value) => { try { @@ -60,13 +60,12 @@ export async function getAccessToken () { } try { - // eslint-disable-next-line node/no-unsupported-features/es-syntax - const imsLib = await import('@adobe/aio-lib-ims') - const { context, getToken } = imsLib.default || imsLib const authConfig = buildAuthConfig() + const imsContextName = `oauth_s2s_${runtimeNamespace}` - await context.set(runtimeNamespace, authConfig, true) - const accessToken = await getToken(runtimeNamespace) + const { context, getToken } = aioLibIms + await context.set(imsContextName, authConfig, true) + const accessToken = await getToken(imsContextName) if (!accessToken) { throw new Error('Failed to generate access token. Please verify your credentials.') diff --git a/test/utils/authHelper.test.js b/test/utils/authHelper.test.js index 46798fe..d84219c 100644 --- a/test/utils/authHelper.test.js +++ b/test/utils/authHelper.test.js @@ -9,7 +9,6 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import { jest } from '@jest/globals' const loadGetAccessToken = async () => { // eslint-disable-next-line node/no-unsupported-features/es-syntax const authModule = await import('../../src/utils/authHelper.js') @@ -52,7 +51,7 @@ describe('getAccessToken()', () => { await expect(getAccessToken()).resolves.toBe('token-string') expect(imsMocks.imsContextSetMock).toHaveBeenCalledWith( - 'test-namespace', + 'oauth_s2s_test-namespace', expect.objectContaining({ client_id: 'env-client-id', client_secrets: '["env-client-secret"]', @@ -77,7 +76,7 @@ describe('getAccessToken()', () => { await expect(getAccessToken()).resolves.toBe('token') expect(imsMocks.imsContextSetMock).toHaveBeenCalledWith( - 'test-namespace', + 'oauth_s2s_test-namespace', expect.objectContaining({ scopes: '["scope-a","scope-b"]' }), @@ -97,7 +96,7 @@ describe('getAccessToken()', () => { await expect(getAccessToken()).resolves.toBe('empty-scope-token') expect(imsMocks.imsContextSetMock).toHaveBeenCalledWith( - 'test-namespace', + 'oauth_s2s_test-namespace', expect.objectContaining({ scopes: '[]' }), @@ -146,21 +145,4 @@ describe('getAccessToken()', () => { 'Failed to retrieve access token: Unknown error' ) }) - - test('works when ims lib does not provide a default export', async () => { - global.__ims_no_default = true - jest.resetModules() - getAccessToken = await loadGetAccessToken() - - process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace' - process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' - process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' - process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' - process.env.IMS_OAUTH_S2S_SCOPES = '["scope-a"]' - - const imsMocks = global.getImsMock() - imsMocks.imsGetTokenMock.mockResolvedValue('token-string') - - await expect(getAccessToken()).resolves.toBe('token-string') - }) })