diff --git a/README.md b/README.md index 99c967b..b6aa6b6 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: '@AdobeOrg', + 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 diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..f56287c --- /dev/null +++ b/src/constants.js @@ -0,0 +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 a2e81b5..bd9d1c3 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 e739e0a..76b4933 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 */ 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 @@ 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 3d29ade..2a92289 100644 --- a/src/index.js +++ b/src/index.js @@ -10,10 +10,13 @@ governing permissions and limitations under the License. */ const { getAccessTokenByClientCredentials, getAndValidateCredentials } = require('./ims.js') -const { codes, messages } = require('./errors.js') const { TTLCache } = require('@isaacs/ttlcache') const crypto = require('crypto') +// include-ims-credentials annotation input keys (keep in sync with src/constants.js) +const IMS_OAUTH_S2S_INPUT = '__ims_oauth_s2s' +const IMS_ENV_INPUT = '__ims_env' + // Token cache with TTL // 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 @@ -56,9 +59,21 @@ function invalidateCache () { * @throws {Error} If there's an error getting the access token */ async function generateAccessToken (params, imsEnv) { - imsEnv = imsEnv || (ioRuntimeStageNamespace() ? 'stage' : 'prod') + // integrate with the runtime environment and include-ims-credentials annotation + imsEnv = imsEnv || params?.[IMS_ENV_INPUT] || (ioRuntimeStageNamespace() ? 'stage' : 'prod') + + let credentials - const credentials = getAndValidateCredentials(params) + // 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.mjs b/test/ims.test.mjs index 60d64aa..285cc5c 100644 --- a/test/ims.test.mjs +++ b/test/ims.test.mjs @@ -259,6 +259,28 @@ describe('getAccessTokenByClientCredentials', () => { expect(error.name).toBe('AuthSDKError') expect(error.code).toBe('IMS_TOKEN_ERROR') expect(error.message).toContain('HTTP 503') + expect(error.sdkDetails.statusCode).toBe(503) + }) + + test('does not cache - always makes fresh API calls', async () => { + fetch.mockResolvedValue({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + // First call + await getAccessTokenByClientCredentials(validParams) + expect(fetch).toHaveBeenCalledTimes(1) + + // Second call - should make another API call (no cache) + await getAccessTokenByClientCredentials(validParams) + expect(fetch).toHaveBeenCalledTimes(2) + + // Third call - should make another API call (no cache) + await getAccessTokenByClientCredentials(validParams) + expect(fetch).toHaveBeenCalledTimes(3) }) test('throws GENERIC_ERROR on network failure', async () => { @@ -320,46 +342,6 @@ describe('getAccessTokenByClientCredentials', () => { }) }) -describe('getAccessTokenByClientCredentials - no caching', () => { - 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() - }) - - test('does not cache - always makes fresh API calls', async () => { - fetch.mockResolvedValue({ - ok: true, - status: 200, - headers: createMockHeaders(), - json: async () => mockSuccessResponse - }) - - // First call - await getAccessTokenByClientCredentials(validParams) - expect(fetch).toHaveBeenCalledTimes(1) - - // Second call - should make another API call (no cache) - await getAccessTokenByClientCredentials(validParams) - expect(fetch).toHaveBeenCalledTimes(2) - - // Third call - should make another API call (no cache) - await getAccessTokenByClientCredentials(validParams) - expect(fetch).toHaveBeenCalledTimes(3) - }) -}) - describe('getAndValidateCredentials', () => { test('is a function', () => { expect(typeof getAndValidateCredentials).toBe('function') @@ -375,7 +357,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', @@ -393,7 +376,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', @@ -413,9 +397,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', () => { @@ -427,89 +412,95 @@ 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('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.credentials).toBeUndefined() + 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('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('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('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('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('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('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('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', @@ -517,21 +508,15 @@ describe('getAndValidateCredentials', () => { scopes: 'openid' } - expect(() => getAndValidateCredentials(params)) - .toThrow('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', @@ -539,11 +524,13 @@ describe('getAndValidateCredentials', () => { scopes: { scope: 'openid' } } - expect(() => getAndValidateCredentials(params)) - .toThrow('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', @@ -551,8 +538,10 @@ describe('getAndValidateCredentials', () => { scopes: 123 } - expect(() => getAndValidateCredentials(params)) - .toThrow('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', () => { @@ -564,6 +553,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.mjs b/test/index.test.mjs index e3fac60..e4914dc 100644 --- a/test/index.test.mjs +++ b/test/index.test.mjs @@ -11,7 +11,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() @@ -62,6 +62,37 @@ describe('generateAccessToken', () => { .rejects .toThrow('MISSING_PARAMETERS') }) + + test('uses credentials from include-ims-credentials annotation when params has no direct credentials', async () => { + const annotationCredentials = { + clientId: 'annotation-client-id', + clientSecret: 'annotation-client-secret', + orgId: 'annotation-org-id', + scopes: ['openid'] + } + const mockSuccessResponse = { + access_token: 'annotation-token', + token_type: 'bearer', + expires_in: 86399 + } + + 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('client_secret=annotation-client-secret') + expect(callArgs.body).toContain('org_id=annotation-org-id') + }) }) describe('generateAccessToken - with caching', () => {