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 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 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 82a45c8..1d44761 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 @@ -55,9 +56,21 @@ export function invalidateCache () { * @throws {Error} If there's an error getting the access token */ export 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') - 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',