From e2def868d4d2c44417069ebbbc97ef7e2bfdb52f Mon Sep 17 00:00:00 2001 From: moritzraho Date: Thu, 22 Jan 2026 21:56:14 +0100 Subject: [PATCH 1/8] feat: support ims compatible api params --- src/{AuthErrors.js => errors.js} | 1 + src/ims.js | 48 ++++++++++++++++++++++++-------- test/AuthErrors.test.js | 2 +- test/index.test.js | 2 +- 4 files changed, 40 insertions(+), 13 deletions(-) rename src/{AuthErrors.js => errors.js} (94%) diff --git a/src/AuthErrors.js b/src/errors.js similarity index 94% rename from src/AuthErrors.js rename to src/errors.js index 09cd9d2..5e0d6ed 100644 --- a/src/AuthErrors.js +++ b/src/errors.js @@ -40,6 +40,7 @@ const E = ErrorWrapper( // Error codes E('IMS_TOKEN_ERROR', 'Error calling IMS to get access token: %s') E('MISSING_PARAMETERS', 'Missing required parameters: %s') +E('BAD_CREDENTIALS_FORMAT', 'Credentials must be either an object or a stringified object') E('GENERIC_ERROR', 'An unexpected error occurred: %s') export { codes, messages } diff --git a/src/ims.js b/src/ims.js index bc479e4..6476243 100644 --- a/src/ims.js +++ b/src/ims.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. */ -import { codes } from './AuthErrors.js' +import { codes } from './errors.js' /** * IMS Base URLs @@ -32,18 +32,44 @@ function getImsUrl (environment) { * Validates required parameters for client credentials flow * * @private - * @param {object} params - Parameters to validate - * @param {string} params.clientId - The client ID - * @param {string} params.clientSecret - The client secret - * @param {string} params.orgId - The organization ID - * @param {string[]} params.scopes - Array of scopes + * @param {object} credentials - Parameters to validate * @throws {Error} If any required parameters are missing */ -function validateClientCredentialsParams ({ clientId, clientSecret, orgId, scopes }) { +function getAndValidateCredentials (credentials) { + if (typeof credentials === 'object' && credentials !== null && !isArray(credentials)) { + // copy object (first level), to avoid side effects + credentials = { ...credentials } + } else { + throw new codes.BAD_CREDENTIALS_FORMAT({ + sdkDetails: { credentialsType: typeof credentials } + }) + } + + // sugar: support both the ims API compatible variant and JS camelCase + if (credentials.client_id) { + credentials.clientId = credentials.client_id + delete credentials.client_id + } + if (credentials.org_id) { + credentials.orgId = credentials.org_id + delete credentials.org_id + } + if (credentials.client_secret) { + credentials.clientSecret = credentials.client_secret + delete credentials.client_secret + } + + const { clientId, clientSecret, orgId, scopes } = credentials const missingParams = [] - if (!clientId) missingParams.push('clientId') - if (!clientSecret) missingParams.push('clientSecret') - if (!orgId) missingParams.push('orgId') + if (!clientId) { + missingParams.push('clientId') + } + if (!clientSecret) { + missingParams.push('clientSecret') + } + if (!orgId) { + missingParams.push('orgId') + } if (missingParams.length > 0) { throw new codes.MISSING_PARAMETERS({ @@ -65,7 +91,7 @@ function validateClientCredentialsParams ({ clientId, clientSecret, orgId, scope * @returns {Promise} Promise that resolves with the token response * @throws {Error} If there's an error getting the access token */ -export async function getAccessTokenByClientCredentials ({ clientId, clientSecret, orgId, scopes = [], environment = 'prod' }) { +export async function getAccessTokenByClientCredentials (credentials, env ) { validateClientCredentialsParams({ clientId, clientSecret, orgId, scopes }) const imsBaseUrl = getImsUrl(environment) diff --git a/test/AuthErrors.test.js b/test/AuthErrors.test.js index c841b76..c1a3eb7 100644 --- a/test/AuthErrors.test.js +++ b/test/AuthErrors.test.js @@ -10,7 +10,7 @@ governing permissions and limitations under the License. */ import { describe, test, expect } from 'vitest' -import { codes, messages } from '../src/AuthErrors.js' +import { codes, messages } from '../src/errors.js' describe('AuthErrors', () => { test('codes object is defined', () => { diff --git a/test/index.test.js b/test/index.test.js index 09f6761..77757bb 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -12,7 +12,7 @@ governing permissions and limitations under the License. import { describe, test, expect, beforeEach, vi } from 'vitest' import { generateAccessToken, invalidateCache } from '../src/index.js' import { getAccessTokenByClientCredentials } from '../src/ims.js' -import { codes } from '../src/AuthErrors.js' +import { codes } from '../src/errors.js' // Mock fetch globally global.fetch = vi.fn() From 25978724f1d38ec48f12686e05dc790ef4ce9891 Mon Sep 17 00:00:00 2001 From: moritzraho Date: Thu, 22 Jan 2026 22:20:53 +0100 Subject: [PATCH 2/8] fix: cache key, imsEnv input and validation as first step --- src/ims.js | 44 +++++++++++++++++--------------------------- src/index.js | 29 +++++++++++++++++------------ 2 files changed, 34 insertions(+), 39 deletions(-) diff --git a/src/ims.js b/src/ims.js index 6476243..d4f660f 100644 --- a/src/ims.js +++ b/src/ims.js @@ -21,43 +21,33 @@ const IMS_BASE_URL_STAGE = 'https://ims-na1-stg1.adobelogin.com' * Gets the IMS base URL based on the environment * * @private - * @param {string} environment - The environment ('prod' or 'stage') + * @param {string} env - The environment ('prod' or 'stage') * @returns {string} The IMS base URL */ -function getImsUrl (environment) { - return environment === 'stage' ? IMS_BASE_URL_STAGE : IMS_BASE_URL_PROD +function getImsUrl (env) { + return env === 'stage' ? IMS_BASE_URL_STAGE : IMS_BASE_URL_PROD } /** * Validates required parameters for client credentials flow * * @private - * @param {object} credentials - Parameters to validate + * @param {object} params - Parameters to validate + * @returns {object} Validated credentials object * @throws {Error} If any required parameters are missing */ -function getAndValidateCredentials (credentials) { - if (typeof credentials === 'object' && credentials !== null && !isArray(credentials)) { - // copy object (first level), to avoid side effects - credentials = { ...credentials } - } else { +export function getAndValidateCredentials (params) { + if (!(typeof params === 'object' && params !== null && !Array.isArray(params))) { throw new codes.BAD_CREDENTIALS_FORMAT({ - sdkDetails: { credentialsType: typeof credentials } + sdkDetails: { paramsType: typeof params } }) } - // sugar: support both the ims API compatible variant and JS camelCase - if (credentials.client_id) { - credentials.clientId = credentials.client_id - delete credentials.client_id - } - if (credentials.org_id) { - credentials.orgId = credentials.org_id - delete credentials.org_id - } - if (credentials.client_secret) { - credentials.clientSecret = credentials.client_secret - delete credentials.client_secret - } + const credentials = {} + credentials.clientId = params.clientId || params.client_id + credentials.clientSecret = params.clientSecret || params.client_secret + credentials.orgId = params.orgId || params.org_id + credentials.scopes = params.scopes || [] const { clientId, clientSecret, orgId, scopes } = credentials const missingParams = [] @@ -77,6 +67,8 @@ function getAndValidateCredentials (credentials) { sdkDetails: { clientId, orgId, scopes } }) } + + return credentials } /** @@ -91,10 +83,8 @@ function getAndValidateCredentials (credentials) { * @returns {Promise} Promise that resolves with the token response * @throws {Error} If there's an error getting the access token */ -export async function getAccessTokenByClientCredentials (credentials, env ) { - validateClientCredentialsParams({ clientId, clientSecret, orgId, scopes }) - - const imsBaseUrl = getImsUrl(environment) +export async function getAccessTokenByClientCredentials ({ clientId, clientSecret, orgId, scopes = [], env } ) { + const imsBaseUrl = getImsUrl(env) // Prepare form data using URLSearchParams (native Node.js) const formData = new URLSearchParams() diff --git a/src/index.js b/src/index.js index 252de05..06d9701 100644 --- a/src/index.js +++ b/src/index.js @@ -9,26 +9,27 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import { getAccessTokenByClientCredentials } from './ims.js' +import { getAccessTokenByClientCredentials, getAndValidateCredentials } from './ims.js' import TTLCache from '@isaacs/ttlcache' // Token cache with TTL -// Opinionated for now, we could make it confiurable in the future if needed -mg +// Opinionated for now, we could make it configurable in the future if needed -mg const tokenCache = new TTLCache({ ttl: 5 * 60 * 1000 }) // 5 minutes in milliseconds /** * Generates a cache key for token storage * * @private - * @param {string} clientId - The client ID - * @param {string} orgId - The organization ID - * @param {string} environment - The environment - * @param {string[]} scopes - Array of scopes + * @param {object} credentials - The credentials object + * @param {string} credentials.clientId - The client ID + * @param {string} credentials.orgId - The organization ID + * @param {string} credentials.env - The env + * @param {string[]} credentials.scopes - Array of scopes * @returns {string} The cache key */ -function getCacheKey (clientId, orgId, environment, scopes) { +function getCacheKey ({clientId, orgId, env, scopes, clientSecret}) { const scopeKey = scopes.length > 0 ? scopes.sort().join(',') : 'none' - return `${clientId}:${orgId}:${environment}:${scopeKey}` + return crypto.createHash('sha1').update(`${clientId}:${orgId}:${scopeKey}:${clientSecret}:${env}`).digest('hex') } /** @@ -48,20 +49,24 @@ export function invalidateCache () { * @param {string} params.clientSecret - The client secret * @param {string} params.orgId - The organization ID * @param {string[]} [params.scopes=[]] - Array of scopes to request - * @param {string} [params.environment='prod'] - The IMS environment ('prod' or 'stage') + * @param {string} [imsEnv='prod'] - The IMS environment ('prod' or 'stage') * @returns {Promise} Promise that resolves with the token response * @throws {Error} If there's an error getting the access token */ -export async function generateAccessToken ({ clientId, clientSecret, orgId, scopes = [], environment = 'prod' }) { +export async function generateAccessToken (params, imsEnv = 'prod' ) { + const credentials = getAndValidateCredentials(params) + + const credAndEnv = { ...credentials, env: imsEnv } + // Check cache first - const cacheKey = getCacheKey(clientId, orgId, environment, scopes) + const cacheKey = getCacheKey(credAndEnv) const cachedToken = tokenCache.get(cacheKey) if (cachedToken) { return cachedToken } // Get token from IMS - const token = await getAccessTokenByClientCredentials({ clientId, clientSecret, orgId, scopes, environment }) + const token = await getAccessTokenByClientCredentials(credAndEnv) // Cache the token tokenCache.set(cacheKey, token) From d9fbad17a04ca0b3c0b63a6e4006a2dbad5485bb Mon Sep 17 00:00:00 2001 From: moritzraho Date: Thu, 22 Jan 2026 22:26:16 +0100 Subject: [PATCH 3/8] fix: scopes must be an array --- src/errors.js | 1 + src/ims.js | 6 ++++++ src/index.js | 1 + 3 files changed, 8 insertions(+) diff --git a/src/errors.js b/src/errors.js index 5e0d6ed..f7b6584 100644 --- a/src/errors.js +++ b/src/errors.js @@ -41,6 +41,7 @@ const E = ErrorWrapper( E('IMS_TOKEN_ERROR', 'Error calling IMS to get access token: %s') E('MISSING_PARAMETERS', 'Missing required parameters: %s') E('BAD_CREDENTIALS_FORMAT', 'Credentials must be either an object or a stringified object') +E('BAD_SCOPES_FORMAT', 'Scopes must be an array') E('GENERIC_ERROR', 'An unexpected error occurred: %s') export { codes, messages } diff --git a/src/ims.js b/src/ims.js index d4f660f..ddea2c7 100644 --- a/src/ims.js +++ b/src/ims.js @@ -43,6 +43,12 @@ export function getAndValidateCredentials (params) { }) } + if (params.scopes && !Array.isArray(params.scopes)) { + throw new codes.BAD_SCOPES_FORMAT({ + sdkDetails: { scopesType: typeof params.scopes } + }) + } + const credentials = {} credentials.clientId = params.clientId || params.client_id credentials.clientSecret = params.clientSecret || params.client_secret diff --git a/src/index.js b/src/index.js index 06d9701..58eb1a2 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ governing permissions and limitations under the License. import { getAccessTokenByClientCredentials, getAndValidateCredentials } from './ims.js' import TTLCache from '@isaacs/ttlcache' +import crypto from 'crypto' // Token cache with TTL // Opinionated for now, we could make it configurable in the future if needed -mg From b15ac7ef3878770a06b5c5e27fbe808c32c1db79 Mon Sep 17 00:00:00 2001 From: moritzraho Date: Thu, 22 Jan 2026 22:28:19 +0100 Subject: [PATCH 4/8] chore: tests --- test/index.test.js | 508 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 456 insertions(+), 52 deletions(-) diff --git a/test/index.test.js b/test/index.test.js index 77757bb..33eaf37 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -11,7 +11,7 @@ governing permissions and limitations under the License. import { describe, test, expect, beforeEach, vi } from 'vitest' import { generateAccessToken, invalidateCache } from '../src/index.js' -import { getAccessTokenByClientCredentials } from '../src/ims.js' +import { getAccessTokenByClientCredentials, getAndValidateCredentials } from '../src/ims.js' import { codes } from '../src/errors.js' // Mock fetch globally @@ -142,7 +142,7 @@ describe('getAccessTokenByClientCredentials', () => { ) }) - test('uses stage IMS URL when environment is stage', async () => { + test('uses stage IMS URL when env is stage', async () => { fetch.mockResolvedValueOnce({ ok: true, status: 200, @@ -150,7 +150,7 @@ describe('getAccessTokenByClientCredentials', () => { json: async () => mockSuccessResponse }) - await getAccessTokenByClientCredentials({ ...validParams, environment: 'stage' }) + await getAccessTokenByClientCredentials({ ...validParams, env: 'stage' }) expect(fetch).toHaveBeenCalledWith( 'https://ims-na1-stg1.adobelogin.com/ims/token/v2', @@ -158,7 +158,7 @@ describe('getAccessTokenByClientCredentials', () => { ) }) - test('uses production IMS URL when environment is prod', async () => { + test('uses production IMS URL when env is prod', async () => { fetch.mockResolvedValueOnce({ ok: true, status: 200, @@ -166,7 +166,7 @@ describe('getAccessTokenByClientCredentials', () => { json: async () => mockSuccessResponse }) - await getAccessTokenByClientCredentials({ ...validParams, environment: 'prod' }) + await getAccessTokenByClientCredentials({ ...validParams, env: 'prod' }) expect(fetch).toHaveBeenCalledWith( 'https://ims-na1.adobelogin.com/ims/token/v2', @@ -174,7 +174,7 @@ describe('getAccessTokenByClientCredentials', () => { ) }) - test('uses production IMS URL for unknown environment values', async () => { + test('uses production IMS URL for unknown env values', async () => { fetch.mockResolvedValueOnce({ ok: true, status: 200, @@ -182,7 +182,7 @@ describe('getAccessTokenByClientCredentials', () => { json: async () => mockSuccessResponse }) - await getAccessTokenByClientCredentials({ ...validParams, environment: 'invalid' }) + await getAccessTokenByClientCredentials({ ...validParams, env: 'invalid' }) expect(fetch).toHaveBeenCalledWith( 'https://ims-na1.adobelogin.com/ims/token/v2', @@ -190,49 +190,6 @@ describe('getAccessTokenByClientCredentials', () => { ) }) - test('throws MISSING_PARAMETERS error when clientId is missing', async () => { - const { clientId, ...paramsWithoutClientId } = validParams - - await expect(getAccessTokenByClientCredentials(paramsWithoutClientId)) - .rejects - .toThrow(codes.MISSING_PARAMETERS) - }) - - test('throws MISSING_PARAMETERS error when clientSecret is missing', async () => { - const { clientSecret, ...paramsWithoutClientSecret } = validParams - - await expect(getAccessTokenByClientCredentials(paramsWithoutClientSecret)) - .rejects - .toThrow(codes.MISSING_PARAMETERS) - }) - - test('throws MISSING_PARAMETERS error when orgId is missing', async () => { - const { orgId, ...paramsWithoutOrgId } = validParams - - await expect(getAccessTokenByClientCredentials(paramsWithoutOrgId)) - .rejects - .toThrow(codes.MISSING_PARAMETERS) - }) - - test('throws MISSING_PARAMETERS error with multiple missing params', async () => { - await expect(getAccessTokenByClientCredentials({ scopes: ['test'] })) - .rejects - .toThrow(codes.MISSING_PARAMETERS) - - // Additional validation - let error - try { - await getAccessTokenByClientCredentials({ scopes: ['test'] }) - } catch (e) { - error = e - } - expect(error.name).toBe('AuthSDKError') - expect(error.code).toBe('MISSING_PARAMETERS') - expect(error.message).toContain('clientId') - expect(error.message).toContain('clientSecret') - expect(error.message).toContain('orgId') - }) - test('throws IMS_TOKEN_ERROR when API returns error response', async () => { const mockErrorResponse = { ok: false, @@ -512,10 +469,10 @@ describe('generateAccessToken - with caching', () => { json: async () => mockSuccessResponse }) - await generateAccessToken({ ...validParams, environment: 'prod' }) + await generateAccessToken(validParams, 'prod') expect(fetch).toHaveBeenCalledTimes(1) - await generateAccessToken({ ...validParams, environment: 'stage' }) + await generateAccessToken(validParams, 'stage') expect(fetch).toHaveBeenCalledTimes(2) }) @@ -549,6 +506,22 @@ describe('generateAccessToken - with caching', () => { expect(fetch).toHaveBeenCalledTimes(1) // Should use cache }) + test('empty scopes use same cache entry', async () => { + fetch.mockResolvedValue({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + const paramsNoScopes = { ...validParams, scopes: [] } + await generateAccessToken(paramsNoScopes) + expect(fetch).toHaveBeenCalledTimes(1) + + await generateAccessToken(paramsNoScopes) + expect(fetch).toHaveBeenCalledTimes(1) // Should use cache + }) + test('invalidateCache clears the cache', async () => { fetch.mockResolvedValue({ ok: true, @@ -601,3 +574,434 @@ describe('invalidateCache', () => { expect(() => invalidateCache()).not.toThrow() }) }) + +describe('getAndValidateCredentials', () => { + test('is a function', () => { + expect(typeof getAndValidateCredentials).toBe('function') + }) + + test('validates and returns credentials with camelCase params', () => { + const params = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: ['openid'] + } + + const result = getAndValidateCredentials(params) + + expect(result).toEqual({ + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: ['openid'] + }) + }) + + test('validates and returns credentials with snake_case params', () => { + const params = { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + org_id: 'test-org-id', + scopes: ['openid'] + } + + const result = getAndValidateCredentials(params) + + expect(result).toEqual({ + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: ['openid'] + }) + }) + + test('prefers camelCase over snake_case when both are provided', () => { + const params = { + clientId: 'camel-client-id', + client_id: 'snake-client-id', + clientSecret: 'camel-secret', + client_secret: 'snake-secret', + orgId: 'camel-org-id', + org_id: 'snake-org-id' + } + + const result = getAndValidateCredentials(params) + + expect(result.clientId).toBe('camel-client-id') + expect(result.clientSecret).toBe('camel-secret') + expect(result.orgId).toBe('camel-org-id') + }) + + test('defaults scopes to empty array when not provided', () => { + const params = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id' + } + + const result = getAndValidateCredentials(params) + + expect(result.scopes).toEqual([]) + }) + + test('throws BAD_CREDENTIALS_FORMAT when params is null', () => { + expect(() => getAndValidateCredentials(null)) + .toThrow(codes.BAD_CREDENTIALS_FORMAT) + + let error + try { + getAndValidateCredentials(null) + } catch (e) { + error = e + } + expect(error.name).toBe('AuthSDKError') + expect(error.code).toBe('BAD_CREDENTIALS_FORMAT') + }) + + test('throws BAD_CREDENTIALS_FORMAT when params is undefined', () => { + expect(() => getAndValidateCredentials(undefined)) + .toThrow(codes.BAD_CREDENTIALS_FORMAT) + }) + + test('throws BAD_CREDENTIALS_FORMAT when params is an array', () => { + expect(() => getAndValidateCredentials(['test'])) + .toThrow(codes.BAD_CREDENTIALS_FORMAT) + }) + + test('throws BAD_CREDENTIALS_FORMAT when params is a string', () => { + expect(() => getAndValidateCredentials('test')) + .toThrow(codes.BAD_CREDENTIALS_FORMAT) + }) + + test('throws BAD_CREDENTIALS_FORMAT when params is a number', () => { + expect(() => getAndValidateCredentials(123)) + .toThrow(codes.BAD_CREDENTIALS_FORMAT) + }) + + test('throws MISSING_PARAMETERS when clientId is missing', () => { + const params = { + clientSecret: 'test-client-secret', + orgId: 'test-org-id' + } + + expect(() => getAndValidateCredentials(params)) + .toThrow(codes.MISSING_PARAMETERS) + }) + + test('throws MISSING_PARAMETERS when clientSecret is missing', () => { + const params = { + clientId: 'test-client-id', + orgId: 'test-org-id' + } + + expect(() => getAndValidateCredentials(params)) + .toThrow(codes.MISSING_PARAMETERS) + }) + + test('throws MISSING_PARAMETERS when orgId is missing', () => { + const params = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret' + } + + expect(() => getAndValidateCredentials(params)) + .toThrow(codes.MISSING_PARAMETERS) + }) + + test('throws MISSING_PARAMETERS with all missing params listed', () => { + let error + try { + getAndValidateCredentials({}) + } catch (e) { + error = e + } + + expect(error.name).toBe('AuthSDKError') + expect(error.code).toBe('MISSING_PARAMETERS') + expect(error.message).toContain('clientId') + expect(error.message).toContain('clientSecret') + expect(error.message).toContain('orgId') + }) + + test('throws BAD_SCOPES_FORMAT when scopes is a string', () => { + const params = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: 'openid' + } + + expect(() => getAndValidateCredentials(params)) + .toThrow(codes.BAD_SCOPES_FORMAT) + + let error + try { + getAndValidateCredentials(params) + } catch (e) { + error = e + } + expect(error.name).toBe('AuthSDKError') + expect(error.code).toBe('BAD_SCOPES_FORMAT') + expect(error.sdkDetails.scopesType).toBe('string') + }) + + test('throws BAD_SCOPES_FORMAT when scopes is an object', () => { + const params = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: { scope: 'openid' } + } + + expect(() => getAndValidateCredentials(params)) + .toThrow(codes.BAD_SCOPES_FORMAT) + }) + + test('throws BAD_SCOPES_FORMAT when scopes is a number', () => { + const params = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: 123 + } + + expect(() => getAndValidateCredentials(params)) + .toThrow(codes.BAD_SCOPES_FORMAT) + }) + + test('accepts scopes as an array', () => { + const params = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: ['openid', 'profile'] + } + + const result = getAndValidateCredentials(params) + expect(result.scopes).toEqual(['openid', 'profile']) + }) +}) + +describe('generateAccessToken - BAD_SCOPES_FORMAT error', () => { + test('throws BAD_SCOPES_FORMAT when scopes is a string', async () => { + const params = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: 'openid' + } + + await expect(generateAccessToken(params)) + .rejects + .toThrow(codes.BAD_SCOPES_FORMAT) + }) +}) + +describe('generateAccessToken - snake_case params support', () => { + const snakeCaseParams = { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + org_id: 'test-org-id', + scopes: ['openid'] + } + + const mockSuccessResponse = { + access_token: 'test-access-token', + token_type: 'bearer', + expires_in: 86399 + } + + beforeEach(() => { + vi.clearAllMocks() + invalidateCache() + }) + + test('accepts snake_case parameters', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + const result = await generateAccessToken(snakeCaseParams) + + expect(result).toEqual(mockSuccessResponse) + expect(fetch).toHaveBeenCalledTimes(1) + }) + + test('sends correct form data with snake_case input params', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await generateAccessToken(snakeCaseParams) + + const callArgs = fetch.mock.calls[0][1] + const body = callArgs.body + + expect(body).toContain('client_id=test-client-id') + expect(body).toContain('client_secret=test-client-secret') + expect(body).toContain('org_id=test-org-id') + }) + + test('uses stage IMS URL when imsEnv is stage', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await generateAccessToken(snakeCaseParams, 'stage') + + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1-stg1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) + }) + + test('uses prod IMS URL by default', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await generateAccessToken(snakeCaseParams) + + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) + }) +}) + +describe('generateAccessToken - imsEnv parameter', () => { + const validParams = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: ['openid'] + } + + const mockSuccessResponse = { + access_token: 'test-access-token', + token_type: 'bearer', + expires_in: 86399 + } + + beforeEach(() => { + vi.clearAllMocks() + invalidateCache() + }) + + test('uses stage IMS URL when imsEnv is stage', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await generateAccessToken(validParams, 'stage') + + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1-stg1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) + }) + + test('uses prod IMS URL when imsEnv is prod', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await generateAccessToken(validParams, 'prod') + + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) + }) + + test('defaults to prod IMS URL when imsEnv not provided', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await generateAccessToken(validParams) + + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) + }) + + test('uses prod IMS URL for unknown imsEnv values', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await generateAccessToken(validParams, 'unknown') + + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) + }) +}) + +describe('generateAccessToken - BAD_CREDENTIALS_FORMAT error', () => { + test('throws BAD_CREDENTIALS_FORMAT when params is null', async () => { + await expect(generateAccessToken(null)) + .rejects + .toThrow(codes.BAD_CREDENTIALS_FORMAT) + }) + + test('throws BAD_CREDENTIALS_FORMAT when params is undefined', async () => { + await expect(generateAccessToken(undefined)) + .rejects + .toThrow(codes.BAD_CREDENTIALS_FORMAT) + }) + + test('throws BAD_CREDENTIALS_FORMAT when params is an array', async () => { + await expect(generateAccessToken(['test'])) + .rejects + .toThrow(codes.BAD_CREDENTIALS_FORMAT) + }) + + test('throws BAD_CREDENTIALS_FORMAT when params is a string', async () => { + await expect(generateAccessToken('test')) + .rejects + .toThrow(codes.BAD_CREDENTIALS_FORMAT) + }) + + test('BAD_CREDENTIALS_FORMAT error includes sdk details', async () => { + let error + try { + await generateAccessToken(null) + } catch (e) { + error = e + } + + expect(error.name).toBe('AuthSDKError') + expect(error.code).toBe('BAD_CREDENTIALS_FORMAT') + expect(error.sdkDetails).toBeDefined() + expect(error.sdkDetails.paramsType).toBe('object') // typeof null === 'object' + }) +}) From 6875939db06dec7c4622afd7320b1b77b4071e8f Mon Sep 17 00:00:00 2001 From: moritzraho Date: Thu, 22 Jan 2026 22:41:36 +0100 Subject: [PATCH 5/8] feat: read from include-ims-credentials --- src/constants.js | 2 + src/index.js | 10 ++- test/index.test.js | 186 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 src/constants.js diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..f061cb7 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,2 @@ +export const IMS_OAUTH_S2S_INPUT = '__ims_oauth_s2s' +export const IMS_ENV_INPUT = '__ims_env' diff --git a/src/index.js b/src/index.js index 58eb1a2..5ebaeaf 100644 --- a/src/index.js +++ b/src/index.js @@ -12,6 +12,7 @@ governing permissions and limitations under the License. import { getAccessTokenByClientCredentials, getAndValidateCredentials } from './ims.js' import TTLCache from '@isaacs/ttlcache' import crypto from 'crypto' +import { IMS_ENV_INPUT, IMS_OAUTH_S2S_INPUT } from './constants.js' // Token cache with TTL // Opinionated for now, we could make it configurable in the future if needed -mg @@ -54,7 +55,14 @@ export function invalidateCache () { * @returns {Promise} Promise that resolves with the token response * @throws {Error} If there's an error getting the access token */ -export async function generateAccessToken (params, imsEnv = 'prod' ) { +export async function generateAccessToken (params, imsEnv) { + // integrate with the runtime environment and include-ims-credentials annotation + if (params?.[IMS_OAUTH_S2S_INPUT]) { + imsEnv = imsEnv || params[IMS_ENV_INPUT] + params = params[IMS_OAUTH_S2S_INPUT] + } + imsEnv = imsEnv || params?.[IMS_ENV_INPUT] || 'prod' + const credentials = getAndValidateCredentials(params) const credAndEnv = { ...credentials, env: imsEnv } diff --git a/test/index.test.js b/test/index.test.js index 33eaf37..860f36d 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -966,6 +966,192 @@ describe('generateAccessToken - imsEnv parameter', () => { }) }) +describe('generateAccessToken - include-ims-credentials annotation support', () => { + const validCredentials = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: ['openid'] + } + + const mockSuccessResponse = { + access_token: 'test-access-token', + token_type: 'bearer', + expires_in: 86399 + } + + beforeEach(() => { + vi.clearAllMocks() + invalidateCache() + }) + + test('extracts credentials from __ims_oauth_s2s property when present', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + const params = { + __ims_oauth_s2s: validCredentials, + someOtherProperty: 'ignored' + } + + const result = await generateAccessToken(params) + + expect(result).toEqual(mockSuccessResponse) + expect(fetch).toHaveBeenCalledTimes(1) + + const callArgs = fetch.mock.calls[0][1] + const body = callArgs.body + expect(body).toContain('client_id=test-client-id') + expect(body).toContain('client_secret=test-client-secret') + expect(body).toContain('org_id=test-org-id') + }) + + test('uses __ims_env from params when imsEnv argument is not provided', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + const params = { + ...validCredentials, + __ims_env: 'stage' + } + + await generateAccessToken(params) + + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1-stg1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) + }) + + test('explicit imsEnv argument takes precedence over __ims_env in params', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + const params = { + ...validCredentials, + __ims_env: 'stage' + } + + await generateAccessToken(params, 'prod') + + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) + }) + + test('supports both __ims_oauth_s2s and __ims_env together', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + const params = { + __ims_oauth_s2s: validCredentials, + __ims_env: 'stage' + } + + const result = await generateAccessToken(params) + + expect(result).toEqual(mockSuccessResponse) + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1-stg1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) + }) + + test('defaults to prod when no imsEnv argument and no __ims_env in params', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await generateAccessToken(validCredentials) + + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) + }) + + test('uses credentials directly when __ims_oauth_s2s is not present', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + const result = await generateAccessToken(validCredentials) + + expect(result).toEqual(mockSuccessResponse) + expect(fetch).toHaveBeenCalledTimes(1) + }) + + test('caches correctly with __ims_oauth_s2s params', async () => { + fetch.mockResolvedValue({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + const params = { + __ims_oauth_s2s: validCredentials, + __ims_env: 'prod' + } + + // First call - should fetch + await generateAccessToken(params) + expect(fetch).toHaveBeenCalledTimes(1) + + // Second call with same params - should use cache + await generateAccessToken(params) + expect(fetch).toHaveBeenCalledTimes(1) + }) + + test('different __ims_env values result in different cache entries', async () => { + fetch.mockResolvedValue({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + const paramsStage = { + __ims_oauth_s2s: validCredentials, + __ims_env: 'stage' + } + + const paramsProd = { + __ims_oauth_s2s: validCredentials, + __ims_env: 'prod' + } + + await generateAccessToken(paramsStage) + expect(fetch).toHaveBeenCalledTimes(1) + + await generateAccessToken(paramsProd) + expect(fetch).toHaveBeenCalledTimes(2) + }) +}) + describe('generateAccessToken - BAD_CREDENTIALS_FORMAT error', () => { test('throws BAD_CREDENTIALS_FORMAT when params is null', async () => { await expect(generateAccessToken(null)) From ca3ab070131e413b31611ba708231826fff483e5 Mon Sep 17 00:00:00 2001 From: moritzraho Date: Mon, 9 Feb 2026 10:36:31 +0100 Subject: [PATCH 6/8] detect stage namespace and default to stage ims env --- src/index.js | 11 ++-- test/index.test.js | 128 ++++++++++++++++++++++----------------------- 2 files changed, 69 insertions(+), 70 deletions(-) diff --git a/src/index.js b/src/index.js index 5ebaeaf..0b693b2 100644 --- a/src/index.js +++ b/src/index.js @@ -57,11 +57,7 @@ export function invalidateCache () { */ export async function generateAccessToken (params, imsEnv) { // integrate with the runtime environment and include-ims-credentials annotation - if (params?.[IMS_OAUTH_S2S_INPUT]) { - imsEnv = imsEnv || params[IMS_ENV_INPUT] - params = params[IMS_OAUTH_S2S_INPUT] - } - imsEnv = imsEnv || params?.[IMS_ENV_INPUT] || 'prod' + imsEnv = imsEnv || params?.[IMS_ENV_INPUT] || (ioRuntimeStageNamespace() ? 'stage' : 'prod') const credentials = getAndValidateCredentials(params) @@ -82,3 +78,8 @@ export async function generateAccessToken (params, imsEnv) { return token } + + +function ioRuntimeStageNamespace () { + return process.env.__OW_NAMESPACE && process.env.__OW_NAMESPACE.startsWith('development-') +} \ No newline at end of file diff --git a/test/index.test.js b/test/index.test.js index 860f36d..a77d646 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -966,7 +966,7 @@ describe('generateAccessToken - imsEnv parameter', () => { }) }) -describe('generateAccessToken - include-ims-credentials annotation support', () => { +describe('generateAccessToken - __ims_env param support', () => { const validCredentials = { clientId: 'test-client-id', clientSecret: 'test-client-secret', @@ -985,31 +985,6 @@ describe('generateAccessToken - include-ims-credentials annotation support', () invalidateCache() }) - test('extracts credentials from __ims_oauth_s2s property when present', async () => { - fetch.mockResolvedValueOnce({ - ok: true, - status: 200, - headers: createMockHeaders(), - json: async () => mockSuccessResponse - }) - - const params = { - __ims_oauth_s2s: validCredentials, - someOtherProperty: 'ignored' - } - - const result = await generateAccessToken(params) - - expect(result).toEqual(mockSuccessResponse) - expect(fetch).toHaveBeenCalledTimes(1) - - const callArgs = fetch.mock.calls[0][1] - const body = callArgs.body - expect(body).toContain('client_id=test-client-id') - expect(body).toContain('client_secret=test-client-secret') - expect(body).toContain('org_id=test-org-id') - }) - test('uses __ims_env from params when imsEnv argument is not provided', async () => { fetch.mockResolvedValueOnce({ ok: true, @@ -1051,8 +1026,37 @@ describe('generateAccessToken - include-ims-credentials annotation support', () expect.any(Object) ) }) +}) - test('supports both __ims_oauth_s2s and __ims_env together', async () => { +describe('generateAccessToken - IO Runtime stage namespace auto-detection', () => { + const validCredentials = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: ['openid'] + } + + const mockSuccessResponse = { + access_token: 'test-access-token', + token_type: 'bearer', + expires_in: 86399 + } + + const originalEnv = process.env + + beforeEach(() => { + vi.clearAllMocks() + invalidateCache() + process.env = { ...originalEnv } + }) + + afterEach(() => { + process.env = originalEnv + }) + + test('defaults to stage when __OW_NAMESPACE starts with development-', async () => { + process.env.__OW_NAMESPACE = 'development-my-project' + fetch.mockResolvedValueOnce({ ok: true, status: 200, @@ -1060,21 +1064,17 @@ describe('generateAccessToken - include-ims-credentials annotation support', () json: async () => mockSuccessResponse }) - const params = { - __ims_oauth_s2s: validCredentials, - __ims_env: 'stage' - } - - const result = await generateAccessToken(params) + await generateAccessToken(validCredentials) - expect(result).toEqual(mockSuccessResponse) expect(fetch).toHaveBeenCalledWith( 'https://ims-na1-stg1.adobelogin.com/ims/token/v2', expect.any(Object) ) }) - test('defaults to prod when no imsEnv argument and no __ims_env in params', async () => { + test('defaults to prod when __OW_NAMESPACE does not start with development-', async () => { + process.env.__OW_NAMESPACE = 'production-my-project' + fetch.mockResolvedValueOnce({ ok: true, status: 200, @@ -1090,7 +1090,9 @@ describe('generateAccessToken - include-ims-credentials annotation support', () ) }) - test('uses credentials directly when __ims_oauth_s2s is not present', async () => { + test('defaults to prod when __OW_NAMESPACE is not set', async () => { + delete process.env.__OW_NAMESPACE + fetch.mockResolvedValueOnce({ ok: true, status: 200, @@ -1098,57 +1100,53 @@ describe('generateAccessToken - include-ims-credentials annotation support', () json: async () => mockSuccessResponse }) - const result = await generateAccessToken(validCredentials) + await generateAccessToken(validCredentials) - expect(result).toEqual(mockSuccessResponse) - expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) }) - test('caches correctly with __ims_oauth_s2s params', async () => { - fetch.mockResolvedValue({ + test('explicit imsEnv argument takes precedence over __OW_NAMESPACE auto-detection', async () => { + process.env.__OW_NAMESPACE = 'development-my-project' + + fetch.mockResolvedValueOnce({ ok: true, status: 200, headers: createMockHeaders(), json: async () => mockSuccessResponse }) - const params = { - __ims_oauth_s2s: validCredentials, - __ims_env: 'prod' - } + await generateAccessToken(validCredentials, 'prod') - // First call - should fetch - await generateAccessToken(params) - expect(fetch).toHaveBeenCalledTimes(1) - - // Second call with same params - should use cache - await generateAccessToken(params) - expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) }) - test('different __ims_env values result in different cache entries', async () => { - fetch.mockResolvedValue({ + test('__ims_env param takes precedence over __OW_NAMESPACE auto-detection', async () => { + process.env.__OW_NAMESPACE = 'development-my-project' + + fetch.mockResolvedValueOnce({ ok: true, status: 200, headers: createMockHeaders(), json: async () => mockSuccessResponse }) - const paramsStage = { - __ims_oauth_s2s: validCredentials, - __ims_env: 'stage' - } - - const paramsProd = { - __ims_oauth_s2s: validCredentials, + const params = { + ...validCredentials, __ims_env: 'prod' } - await generateAccessToken(paramsStage) - expect(fetch).toHaveBeenCalledTimes(1) + await generateAccessToken(params) - await generateAccessToken(paramsProd) - expect(fetch).toHaveBeenCalledTimes(2) + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) }) }) From 975fc279c317789e61324a7c7eaede740c99aafa Mon Sep 17 00:00:00 2001 From: moritzraho Date: Mon, 9 Feb 2026 14:21:28 +0100 Subject: [PATCH 7/8] fixes and cleanups --- src/constants.js | 12 ++++ src/errors.js | 2 +- src/ims.js | 25 ++++---- src/index.js | 15 ++++- test/ims.test.js | 145 ++++++++++++++++++++++++--------------------- test/index.test.js | 58 ++++++++++++++++++ 6 files changed, 174 insertions(+), 83 deletions(-) diff --git a/src/constants.js b/src/constants.js index f061cb7..f56287c 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,2 +1,14 @@ +/* +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. +*/ + +// support parameters set in the include-ims-credentials annotation export const IMS_OAUTH_S2S_INPUT = '__ims_oauth_s2s' export const IMS_ENV_INPUT = '__ims_env' diff --git a/src/errors.js b/src/errors.js index f7b6584..f0bfdac 100644 --- a/src/errors.js +++ b/src/errors.js @@ -39,7 +39,7 @@ const E = ErrorWrapper( // Error codes E('IMS_TOKEN_ERROR', 'Error calling IMS to get access token: %s') -E('MISSING_PARAMETERS', 'Missing required parameters: %s') +E('MISSING_PARAMETERS', 'Missing required parameters: %s. You may want to set the include-ims-credentials annotation.') E('BAD_CREDENTIALS_FORMAT', 'Credentials must be either an object or a stringified object') E('BAD_SCOPES_FORMAT', 'Scopes must be an array') E('GENERIC_ERROR', 'An unexpected error occurred: %s') diff --git a/src/ims.js b/src/ims.js index c825945..9ce557b 100644 --- a/src/ims.js +++ b/src/ims.js @@ -33,20 +33,23 @@ function getImsUrl (env) { * * @private * @param {object} params - Parameters to validate - * @returns {object} Validated credentials object - * @throws {Error} If any required parameters are missing + * @returns {{ error, credentials }} Object with error (if any) and validated credentials object */ export function getAndValidateCredentials (params) { if (!(typeof params === 'object' && params !== null && !Array.isArray(params))) { - throw new codes.BAD_CREDENTIALS_FORMAT({ - sdkDetails: { paramsType: typeof params } - }) + return { + error: new codes.BAD_CREDENTIALS_FORMAT({ + sdkDetails: { paramsType: typeof params } + }) + } } if (params.scopes && !Array.isArray(params.scopes)) { - throw new codes.BAD_SCOPES_FORMAT({ - sdkDetails: { scopesType: typeof params.scopes } - }) + return { + error: new codes.BAD_SCOPES_FORMAT({ + sdkDetails: { scopesType: typeof params.scopes } + }) + } } const credentials = {} @@ -68,13 +71,13 @@ export function getAndValidateCredentials (params) { } if (missingParams.length > 0) { - throw new codes.MISSING_PARAMETERS({ + return { error: new codes.MISSING_PARAMETERS({ messageValues: missingParams.join(', '), sdkDetails: { clientId, orgId, scopes } - }) + }) } } - return credentials + return { credentials, error: null } } /** diff --git a/src/index.js b/src/index.js index d936ae0..1d44761 100644 --- a/src/index.js +++ b/src/index.js @@ -58,8 +58,19 @@ export function invalidateCache () { export async function generateAccessToken (params, imsEnv) { // integrate with the runtime environment and include-ims-credentials annotation imsEnv = imsEnv || params?.[IMS_ENV_INPUT] || (ioRuntimeStageNamespace() ? 'stage' : 'prod') - - const credentials = getAndValidateCredentials(params) + + let credentials + + // get parameters from params in priority otherwise try to load the credentials set to params.__ims_oauth_s2s by the annotation + const fromParams = getAndValidateCredentials(params) + credentials = fromParams.credentials + if (fromParams.error) { + const fromAnnotation = getAndValidateCredentials(params?.[IMS_OAUTH_S2S_INPUT]) + if (fromAnnotation.error) { + throw fromParams.error // still throw original error + } + credentials = fromAnnotation.credentials + } const credAndEnv = { ...credentials, env: imsEnv } diff --git a/test/ims.test.js b/test/ims.test.js index 703f622..0da52c5 100644 --- a/test/ims.test.js +++ b/test/ims.test.js @@ -374,7 +374,8 @@ describe('getAndValidateCredentials', () => { const result = getAndValidateCredentials(params) - expect(result).toEqual({ + expect(result.error).toBeNull() + expect(result.credentials).toEqual({ clientId: 'test-client-id', clientSecret: 'test-client-secret', orgId: 'test-org-id', @@ -392,7 +393,8 @@ describe('getAndValidateCredentials', () => { const result = getAndValidateCredentials(params) - expect(result).toEqual({ + expect(result.error).toBeNull() + expect(result.credentials).toEqual({ clientId: 'test-client-id', clientSecret: 'test-client-secret', orgId: 'test-org-id', @@ -412,9 +414,10 @@ describe('getAndValidateCredentials', () => { const result = getAndValidateCredentials(params) - expect(result.clientId).toBe('camel-client-id') - expect(result.clientSecret).toBe('camel-secret') - expect(result.orgId).toBe('camel-org-id') + expect(result.error).toBeNull() + expect(result.credentials.clientId).toBe('camel-client-id') + expect(result.credentials.clientSecret).toBe('camel-secret') + expect(result.credentials.orgId).toBe('camel-org-id') }) test('defaults scopes to empty array when not provided', () => { @@ -426,89 +429,94 @@ describe('getAndValidateCredentials', () => { const result = getAndValidateCredentials(params) - expect(result.scopes).toEqual([]) + expect(result.error).toBeNull() + expect(result.credentials.scopes).toEqual([]) }) - test('throws BAD_CREDENTIALS_FORMAT when params is null', () => { - expect(() => getAndValidateCredentials(null)) - .toThrow(codes.BAD_CREDENTIALS_FORMAT) + test('returns BAD_CREDENTIALS_FORMAT error when params is null', () => { + const result = getAndValidateCredentials(null) - let error - try { - getAndValidateCredentials(null) - } catch (e) { - error = e - } - expect(error.name).toBe('AuthSDKError') - expect(error.code).toBe('BAD_CREDENTIALS_FORMAT') + expect(result.error).toBeDefined() + expect(result.error.name).toBe('AuthSDKError') + expect(result.error.code).toBe('BAD_CREDENTIALS_FORMAT') }) - test('throws BAD_CREDENTIALS_FORMAT when params is undefined', () => { - expect(() => getAndValidateCredentials(undefined)) - .toThrow(codes.BAD_CREDENTIALS_FORMAT) + test('returns BAD_CREDENTIALS_FORMAT error when params is undefined', () => { + const result = getAndValidateCredentials(undefined) + + expect(result.error).toBeDefined() + expect(result.error.code).toBe('BAD_CREDENTIALS_FORMAT') }) - test('throws BAD_CREDENTIALS_FORMAT when params is an array', () => { - expect(() => getAndValidateCredentials(['test'])) - .toThrow(codes.BAD_CREDENTIALS_FORMAT) + test('returns BAD_CREDENTIALS_FORMAT error when params is an array', () => { + const result = getAndValidateCredentials(['test']) + + expect(result.error).toBeDefined() + expect(result.error.code).toBe('BAD_CREDENTIALS_FORMAT') }) - test('throws BAD_CREDENTIALS_FORMAT when params is a string', () => { - expect(() => getAndValidateCredentials('test')) - .toThrow(codes.BAD_CREDENTIALS_FORMAT) + test('returns BAD_CREDENTIALS_FORMAT error when params is a string', () => { + const result = getAndValidateCredentials('test') + + expect(result.error).toBeDefined() + expect(result.error.code).toBe('BAD_CREDENTIALS_FORMAT') }) - test('throws BAD_CREDENTIALS_FORMAT when params is a number', () => { - expect(() => getAndValidateCredentials(123)) - .toThrow(codes.BAD_CREDENTIALS_FORMAT) + test('returns BAD_CREDENTIALS_FORMAT error when params is a number', () => { + const result = getAndValidateCredentials(123) + + expect(result.error).toBeDefined() + expect(result.error.code).toBe('BAD_CREDENTIALS_FORMAT') }) - test('throws MISSING_PARAMETERS when clientId is missing', () => { + test('returns MISSING_PARAMETERS error when clientId is missing', () => { const params = { clientSecret: 'test-client-secret', orgId: 'test-org-id' } - expect(() => getAndValidateCredentials(params)) - .toThrow(codes.MISSING_PARAMETERS) + const result = getAndValidateCredentials(params) + + expect(result.error).toBeDefined() + expect(result.error.code).toBe('MISSING_PARAMETERS') }) - test('throws MISSING_PARAMETERS when clientSecret is missing', () => { + test('returns MISSING_PARAMETERS error when clientSecret is missing', () => { const params = { clientId: 'test-client-id', orgId: 'test-org-id' } - expect(() => getAndValidateCredentials(params)) - .toThrow(codes.MISSING_PARAMETERS) + const result = getAndValidateCredentials(params) + + expect(result.error).toBeDefined() + expect(result.error.code).toBe('MISSING_PARAMETERS') }) - test('throws MISSING_PARAMETERS when orgId is missing', () => { + test('returns MISSING_PARAMETERS error when orgId is missing', () => { const params = { clientId: 'test-client-id', clientSecret: 'test-client-secret' } - expect(() => getAndValidateCredentials(params)) - .toThrow(codes.MISSING_PARAMETERS) + const result = getAndValidateCredentials(params) + + expect(result.error).toBeDefined() + expect(result.error.code).toBe('MISSING_PARAMETERS') }) - test('throws MISSING_PARAMETERS with all missing params listed', () => { - let error - try { - getAndValidateCredentials({}) - } catch (e) { - error = e - } + test('returns MISSING_PARAMETERS with all missing params listed', () => { + const result = getAndValidateCredentials({}) - expect(error.name).toBe('AuthSDKError') - expect(error.code).toBe('MISSING_PARAMETERS') - expect(error.message).toContain('clientId') - expect(error.message).toContain('clientSecret') - expect(error.message).toContain('orgId') + expect(result.error).toBeDefined() + expect(result.error.name).toBe('AuthSDKError') + expect(result.error.code).toBe('MISSING_PARAMETERS') + expect(result.error.message).toContain('clientId') + expect(result.error.message).toContain('clientSecret') + expect(result.error.message).toContain('orgId') }) - test('throws BAD_SCOPES_FORMAT when scopes is a string', () => { + test('returns BAD_SCOPES_FORMAT error when scopes is a string', () => { const params = { clientId: 'test-client-id', clientSecret: 'test-client-secret', @@ -516,21 +524,15 @@ describe('getAndValidateCredentials', () => { scopes: 'openid' } - expect(() => getAndValidateCredentials(params)) - .toThrow(codes.BAD_SCOPES_FORMAT) + const result = getAndValidateCredentials(params) - let error - try { - getAndValidateCredentials(params) - } catch (e) { - error = e - } - expect(error.name).toBe('AuthSDKError') - expect(error.code).toBe('BAD_SCOPES_FORMAT') - expect(error.sdkDetails.scopesType).toBe('string') + expect(result.error).toBeDefined() + expect(result.error.name).toBe('AuthSDKError') + expect(result.error.code).toBe('BAD_SCOPES_FORMAT') + expect(result.error.sdkDetails.scopesType).toBe('string') }) - test('throws BAD_SCOPES_FORMAT when scopes is an object', () => { + test('returns BAD_SCOPES_FORMAT error when scopes is an object', () => { const params = { clientId: 'test-client-id', clientSecret: 'test-client-secret', @@ -538,11 +540,13 @@ describe('getAndValidateCredentials', () => { scopes: { scope: 'openid' } } - expect(() => getAndValidateCredentials(params)) - .toThrow(codes.BAD_SCOPES_FORMAT) + const result = getAndValidateCredentials(params) + + expect(result.error).toBeDefined() + expect(result.error.code).toBe('BAD_SCOPES_FORMAT') }) - test('throws BAD_SCOPES_FORMAT when scopes is a number', () => { + test('returns BAD_SCOPES_FORMAT error when scopes is a number', () => { const params = { clientId: 'test-client-id', clientSecret: 'test-client-secret', @@ -550,8 +554,10 @@ describe('getAndValidateCredentials', () => { scopes: 123 } - expect(() => getAndValidateCredentials(params)) - .toThrow(codes.BAD_SCOPES_FORMAT) + const result = getAndValidateCredentials(params) + + expect(result.error).toBeDefined() + expect(result.error.code).toBe('BAD_SCOPES_FORMAT') }) test('accepts scopes as an array', () => { @@ -563,6 +569,7 @@ describe('getAndValidateCredentials', () => { } const result = getAndValidateCredentials(params) - expect(result.scopes).toEqual(['openid', 'profile']) + expect(result.error).toBeNull() + expect(result.credentials.scopes).toEqual(['openid', 'profile']) }) }) diff --git a/test/index.test.js b/test/index.test.js index 13697aa..658cd94 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -12,6 +12,7 @@ governing permissions and limitations under the License. import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' import { generateAccessToken, invalidateCache } from '../src/index.js' import { codes } from '../src/errors.js' +import { IMS_OAUTH_S2S_INPUT } from '../src/constants.js' // Mock fetch globally global.fetch = vi.fn() @@ -64,6 +65,63 @@ describe('generateAccessToken', () => { }) }) +describe('generateAccessToken - include-ims-credentials annotation', () => { + const annotationCredentials = { + clientId: 'annotation-client-id', + clientSecret: 'annotation-client-secret', + orgId: 'annotation-org-id', + scopes: ['openid'] + } + + const mockSuccessResponse = { + access_token: 'annotation-access-token', + token_type: 'bearer', + expires_in: 86399 + } + + beforeEach(() => { + vi.clearAllMocks() + invalidateCache() + }) + + test('uses credentials from __ims_oauth_s2s when params has no credentials', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + const params = { + [IMS_OAUTH_S2S_INPUT]: annotationCredentials + } + + const result = await generateAccessToken(params) + + expect(result).toEqual(mockSuccessResponse) + expect(fetch).toHaveBeenCalledTimes(1) + const callArgs = fetch.mock.calls[0][1] + expect(callArgs.body).toContain('client_id=annotation-client-id') + expect(callArgs.body).toContain('org_id=annotation-org-id') + }) + + test('throws params error when __ims_oauth_s2s is missing and params has no credentials', async () => { + await expect(generateAccessToken({})) + .rejects + .toThrow(codes.MISSING_PARAMETERS) + }) + + test('throws params error when __ims_oauth_s2s has invalid credentials', async () => { + const params = { + [IMS_OAUTH_S2S_INPUT]: { clientId: 'only-id' } + } + + await expect(generateAccessToken(params)) + .rejects + .toThrow(codes.MISSING_PARAMETERS) + }) +}) + describe('generateAccessToken - with caching', () => { const validParams = { clientId: 'test-client-id', From 4513c66e622b280ae8bc48449e9da3d67634e74a Mon Sep 17 00:00:00 2001 From: moritzraho Date: Tue, 10 Feb 2026 13:36:07 +0100 Subject: [PATCH 8/8] readme update --- README.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 99c967b..ee2e452 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,17 @@ const { generateAccessToken } = require('@adobe/aio-lib-core-auth') async function main(params) { try { - // Note: Will cache for 5 min + // if the include-ims-credentials annotation is set, the library infers credentials from the Runtime params + const token = await generateAccessToken(params) + + // otherwise credentials can be passed manually const token = await generateAccessToken({ - clientId: params.IMS_CLIENT_ID, - clientSecret: params.IMS_CLIENT_SECRET, - orgId: params.IMS_ORG_ID, - scopes: params.IMS_SCOPES, + clientId, + clientSecret, + orgId, + scopes }) + console.log('Authentication successful:', token.access_token) } catch (error) { console.error('Authentication failed:', error) @@ -50,7 +54,7 @@ async function main(params) { } ``` -Note: The token is cached in the Runtime's container memory, a single Runtime action can run in multiple containers. +Note: The token is cached for 5 minutes in the Runtime's container memory. A single Runtime action can run in multiple containers, meaning the cache is not shared across actions. ### Invalidating the Token Cache in a Runtime action