diff --git a/Authentication.md b/Authentication.md index e4a36b7c..40cfc2f6 100644 --- a/Authentication.md +++ b/Authentication.md @@ -492,6 +492,13 @@ The IAM access token is added to each outbound request in the `Authorization` he The default value of this property is `http://169.254.169.254`. However, if the VPC Instance Metadata Service is configured with the HTTP Secure Protocol setting (`https`), then you should configure this property to be `https://api.metadata.cloud.ibm.com`. +- serviceVersion: (optional) The VPC Instance Metadata Service version to use. +The default value is `2022-03-01`. When set to `2025-08-26`, the authenticator will use the new API paths +(`/identity/v1/token` and `/identity/v1/iam_tokens`) instead of the legacy paths. + +- tokenLifetime: (optional) The lifetime (in seconds) of the instance identity token. +The default value is `300` seconds. This property can only be configured programmatically (not via environment variables). + Usage Notes: 1. At most one of `iamProfileCrn` or `iamProfileId` may be specified. The specified value must map to a trusted IAM profile that has been linked to the compute resource (virtual server instance). @@ -521,12 +528,29 @@ const service = new ExampleServiceV1(options); // 'service' can now be used to invoke operations. ``` +To use the new service version with custom token lifetime: +```js +const authenticator = new VpcInstanceAuthenticator({ + iamProfileCrn: 'crn:iam-profile-123', + serviceVersion: '2025-08-26', + tokenLifetime: 600, +}); +``` + ### Configuration example External configuration: ``` export EXAMPLE_SERVICE_AUTH_TYPE=vpc export EXAMPLE_SERVICE_IAM_PROFILE_CRN=crn:iam-profile-123 ``` + +To use the new service version: +``` +export EXAMPLE_SERVICE_AUTH_TYPE=vpc +export EXAMPLE_SERVICE_IAM_PROFILE_CRN=crn:iam-profile-123 +export EXAMPLE_SERVICE_VPC_IMS_VERSION=2025-08-26 +``` + Application code: ```js const ExampleServiceV1 = require('/example-service/v1'); @@ -540,7 +564,6 @@ const service = ExampleServiceV1.newInstance(options); // 'service' can now be used to invoke operations. ``` - ## Cloud Pak for Data Authentication The `CloudPakForDataAuthenticator` will accept a user-supplied username value, along with either a password or apikey, and will diff --git a/auth/authenticators/vpc-instance-authenticator.ts b/auth/authenticators/vpc-instance-authenticator.ts index 28a07bd3..ca5d8f5f 100644 --- a/auth/authenticators/vpc-instance-authenticator.ts +++ b/auth/authenticators/vpc-instance-authenticator.ts @@ -24,6 +24,10 @@ export interface Options extends BaseOptions { iamProfileCrn?: string; /** The ID of the linked trusted IAM profile to be used when obtaining the IAM access token */ iamProfileId?: string; + /** The version of the Instance Metadata Service to be used obtaining tokens */ + serviceVersion?: string; + /** The lifetime of the Instance Identity Token */ + tokenLifetime?: number; } /** @@ -43,6 +47,10 @@ export class VpcInstanceAuthenticator extends TokenRequestBasedAuthenticator { private iamProfileId: string; + private serviceVersion: string; + + private tokenLifetime: number; + /** * Create a new VpcInstanceAuthenticator instance. * @@ -68,6 +76,12 @@ export class VpcInstanceAuthenticator extends TokenRequestBasedAuthenticator { if (options.iamProfileId) { this.iamProfileId = options.iamProfileId; } + if (options.serviceVersion) { + this.serviceVersion = options.serviceVersion; + } + if (options.tokenLifetime) { + this.tokenLifetime = options.tokenLifetime; + } // the param names are shared between the authenticator and the token // manager so we can just pass along the options object. @@ -97,6 +111,18 @@ export class VpcInstanceAuthenticator extends TokenRequestBasedAuthenticator { this.tokenManager.setIamProfileId(iamProfileId); } + public setServiceVersion(serviceVersion: string): void { + this.serviceVersion = serviceVersion; + + this.tokenManager.setServiceVersion(serviceVersion); + } + + public setTokenLifetime(tokenLifetime: number): void { + this.tokenLifetime = tokenLifetime; + + this.tokenManager.setTokenLifetime(tokenLifetime); + } + /** * Returns the authenticator's type ('vpc'). * diff --git a/auth/token-managers/vpc-instance-token-manager.ts b/auth/token-managers/vpc-instance-token-manager.ts index f435eac8..656847f3 100644 --- a/auth/token-managers/vpc-instance-token-manager.ts +++ b/auth/token-managers/vpc-instance-token-manager.ts @@ -21,7 +21,14 @@ import { JwtTokenManager, JwtTokenManagerOptions } from './jwt-token-manager'; const DEFAULT_IMS_ENDPOINT = 'http://169.254.169.254'; const METADATA_SERVICE_VERSION = '2022-03-01'; +const METADATA_SERVICE_VERSION2 = '2025-08-26'; const IAM_EXPIRATION_WINDOW = 10; +const METADATA_TOKEN_LIFETIME = 300; +const DEFAULT_OPERATION_PATH_CREATE_ACCESS_TOKEN = '/instance_identity/v1/token'; +const DEFAULT_OPERATION_PATH_CREATE_IAM_TOKEN = '/instance_identity/v1/iam_token'; +const DEFAULT_OPERATION_PATH_CREATE_ACCESS_TOKEN2 = '/identity/v1/token'; +const DEFAULT_OPERATION_PATH_CREATE_IAM_TOKEN2 = '/identity/v1/iam_tokens'; +const metadataServiceSupportedVersions = [METADATA_SERVICE_VERSION, METADATA_SERVICE_VERSION2]; /** Configuration options for VPC token retrieval. */ interface Options extends JwtTokenManagerOptions { @@ -29,6 +36,10 @@ interface Options extends JwtTokenManagerOptions { iamProfileCrn?: string; /** The ID of the linked trusted IAM profile to be used when obtaining the IAM access token */ iamProfileId?: string; + /** The version of the Instance Metadata Service to be used obtaining tokens */ + serviceVersion?: string; + /** The lifetime of the Instance Identity Token */ + tokenLifetime?: number; } // this interface is a representation of the response received from @@ -57,6 +68,10 @@ export class VpcInstanceTokenManager extends JwtTokenManager { private iamProfileId: string; + private serviceVersion: string; + + private tokenLifetime: number; + /** * Create a new VpcInstanceTokenManager instance. * @@ -82,6 +97,22 @@ export class VpcInstanceTokenManager extends JwtTokenManager { this.url = options.url || DEFAULT_IMS_ENDPOINT; + // Validate and set serviceVersion + const serviceVersion = options.serviceVersion || METADATA_SERVICE_VERSION; + if (!metadataServiceSupportedVersions.includes(serviceVersion)) { + throw new Error( + `Invalid serviceVersion. Must be one of: ${metadataServiceSupportedVersions.join(', ')}` + ); + } + this.serviceVersion = serviceVersion; + + // Validate and set tokenLifetime + const tokenLifetime = options.tokenLifetime || METADATA_TOKEN_LIFETIME; + if (typeof tokenLifetime !== 'number' || tokenLifetime < 0) { + throw new Error('tokenLifetime must be a non-negative number'); + } + this.tokenLifetime = tokenLifetime; + if (options.iamProfileCrn) { this.iamProfileCrn = options.iamProfileCrn; } @@ -108,6 +139,36 @@ export class VpcInstanceTokenManager extends JwtTokenManager { this.iamProfileId = iamProfileId; } + public setServiceVersion(serviceVersion: string): void { + if (!metadataServiceSupportedVersions.includes(serviceVersion)) { + throw new Error( + `Invalid serviceVersion. Must be one of: ${metadataServiceSupportedVersions.join(', ')}` + ); + } + this.serviceVersion = serviceVersion; + } + + public setTokenLifetime(tokenLifetime: number): void { + if (typeof tokenLifetime !== 'number' || tokenLifetime < 0) { + throw new Error('tokenLifetime must be a non-negative number'); + } + this.tokenLifetime = tokenLifetime; + } + + protected getAccessTokenPath(): string { + if (this.serviceVersion === METADATA_SERVICE_VERSION2) { + return DEFAULT_OPERATION_PATH_CREATE_ACCESS_TOKEN2; + } + return DEFAULT_OPERATION_PATH_CREATE_ACCESS_TOKEN; + } + + protected getIamTokenPath(): string { + if (this.serviceVersion === METADATA_SERVICE_VERSION2) { + return DEFAULT_OPERATION_PATH_CREATE_IAM_TOKEN2; + } + return DEFAULT_OPERATION_PATH_CREATE_IAM_TOKEN; + } + protected async requestToken(): Promise { const instanceIdentityToken: string = await this.getInstanceIdentityToken(); @@ -125,9 +186,9 @@ export class VpcInstanceTokenManager extends JwtTokenManager { const parameters = { options: { - url: `${this.url}/instance_identity/v1/iam_token`, + url: `${this.url}${this.getIamTokenPath()}`, qs: { - version: METADATA_SERVICE_VERSION, + version: this.serviceVersion, }, body, method: 'POST', @@ -136,6 +197,7 @@ export class VpcInstanceTokenManager extends JwtTokenManager { 'User-Agent': this.userAgent, Accept: 'application/json', Authorization: `Bearer ${instanceIdentityToken}`, + 'Metadata-Flavor': 'ibm', }, }, }; @@ -150,12 +212,12 @@ export class VpcInstanceTokenManager extends JwtTokenManager { private async getInstanceIdentityToken(): Promise { const parameters = { options: { - url: `${this.url}/instance_identity/v1/token`, + url: `${this.url}${this.getAccessTokenPath()}`, qs: { - version: METADATA_SERVICE_VERSION, + version: this.serviceVersion, }, body: { - expires_in: 300, + expires_in: this.tokenLifetime, }, method: 'PUT', headers: { diff --git a/etc/ibm-cloud-sdk-core.api.md b/etc/ibm-cloud-sdk-core.api.md index 62d1c953..995bcf7a 100644 --- a/etc/ibm-cloud-sdk-core.api.md +++ b/etc/ibm-cloud-sdk-core.api.md @@ -510,6 +510,10 @@ export class VpcInstanceAuthenticator extends TokenRequestBasedAuthenticator { setIamProfileCrn(iamProfileCrn: string): void; setIamProfileId(iamProfileId: string): void; // (undocumented) + setServiceVersion(serviceVersion: string): void; + // (undocumented) + setTokenLifetime(tokenLifetime: number): void; + // (undocumented) protected tokenManager: VpcInstanceTokenManager; } @@ -517,11 +521,19 @@ export class VpcInstanceAuthenticator extends TokenRequestBasedAuthenticator { export class VpcInstanceTokenManager extends JwtTokenManager { // Warning: (ae-forgotten-export) The symbol "Options_9" needs to be exported by the entry point index.d.ts constructor(options: Options_9); + // (undocumented) + protected getAccessTokenPath(): string; + // (undocumented) + protected getIamTokenPath(): string; protected isTokenExpired(): boolean; // (undocumented) protected requestToken(): Promise; setIamProfileCrn(iamProfileCrn: string): void; setIamProfileId(iamProfileId: string): void; + // (undocumented) + setServiceVersion(serviceVersion: string): void; + // (undocumented) + setTokenLifetime(tokenLifetime: number): void; } // (No @packageDocumentation comment for this package) diff --git a/test/unit/vpc-instance-authenticator.test.js b/test/unit/vpc-instance-authenticator.test.js index cfce4400..f610f864 100644 --- a/test/unit/vpc-instance-authenticator.test.js +++ b/test/unit/vpc-instance-authenticator.test.js @@ -17,6 +17,13 @@ const { Authenticator, VpcInstanceAuthenticator } = require('../../dist/auth'); const { VpcInstanceTokenManager } = require('../../dist/auth'); +// Constants for repeated values +const SERVICE_VERSION_2022 = '2022-03-01'; +const SERVICE_VERSION_2025 = '2025-08-26'; +const DEFAULT_TOKEN_LIFETIME = 300; +const CUSTOM_TOKEN_LIFETIME = 600; +const INVALID_SERVICE_VERSION_ERROR = `Invalid serviceVersion. Must be one of: ${SERVICE_VERSION_2022}, ${SERVICE_VERSION_2025}`; + // mock the `getToken` method in the token manager - dont make any rest calls const fakeToken = 'iam-acess-token'; const mockedTokenManager = new VpcInstanceTokenManager(); @@ -76,6 +83,108 @@ describe('VPC Instance Authenticator', () => { expect(authenticator.tokenManager.iamProfileId).toEqual(config.iamProfileId); }); + it('should store serviceVersion and tokenLifetime when provided in config', () => { + const authenticator = new VpcInstanceAuthenticator({ + serviceVersion: SERVICE_VERSION_2025, + tokenLifetime: CUSTOM_TOKEN_LIFETIME, + }); + + expect(authenticator.serviceVersion).toBe(SERVICE_VERSION_2025); + expect(authenticator.tokenManager.serviceVersion).toBe(SERVICE_VERSION_2025); + expect(authenticator.tokenLifetime).toBe(CUSTOM_TOKEN_LIFETIME); + expect(authenticator.tokenManager.tokenLifetime).toBe(CUSTOM_TOKEN_LIFETIME); + }); + + it('should use default serviceVersion and tokenLifetime when not provided', () => { + const authenticator = new VpcInstanceAuthenticator(); + + expect(authenticator.tokenManager.serviceVersion).toBe(SERVICE_VERSION_2022); + expect(authenticator.tokenManager.tokenLifetime).toBe(DEFAULT_TOKEN_LIFETIME); + }); + + it('should set serviceVersion using the setter even when not declared in constructor', () => { + const authenticator = new VpcInstanceAuthenticator(); + + // Initially should be undefined on authenticator (but token manager has default) + expect(authenticator.serviceVersion).toBeUndefined(); + expect(authenticator.tokenManager.serviceVersion).toBe(SERVICE_VERSION_2022); + + authenticator.setServiceVersion(SERVICE_VERSION_2025); + expect(authenticator.serviceVersion).toBe(SERVICE_VERSION_2025); + + // also, verify that the underlying token manager has been updated + expect(authenticator.tokenManager.serviceVersion).toBe(SERVICE_VERSION_2025); + }); + + it('should set tokenLifetime using the setter even when not declared in constructor', () => { + const authenticator = new VpcInstanceAuthenticator(); + + // Initially should be undefined on authenticator (but token manager has default) + expect(authenticator.tokenLifetime).toBeUndefined(); + expect(authenticator.tokenManager.tokenLifetime).toBe(DEFAULT_TOKEN_LIFETIME); + + authenticator.setTokenLifetime(900); + expect(authenticator.tokenLifetime).toBe(900); + + // also, verify that the underlying token manager has been updated + expect(authenticator.tokenManager.tokenLifetime).toBe(900); + }); + + it('should re-set tokenLifetime using the setter when already set in constructor', () => { + const authenticator = new VpcInstanceAuthenticator({ + tokenLifetime: DEFAULT_TOKEN_LIFETIME, + }); + + expect(authenticator.tokenLifetime).toBe(DEFAULT_TOKEN_LIFETIME); + expect(authenticator.tokenManager.tokenLifetime).toBe(DEFAULT_TOKEN_LIFETIME); + + authenticator.setTokenLifetime(900); + expect(authenticator.tokenLifetime).toBe(900); + + // also, verify that the underlying token manager has been updated + expect(authenticator.tokenManager.tokenLifetime).toBe(900); + }); + + it('should pass all config options to token manager', () => { + const fullConfig = { + iamProfileId: 'some-id', + url: 'someurl.com', + serviceVersion: SERVICE_VERSION_2025, + tokenLifetime: CUSTOM_TOKEN_LIFETIME, + }; + + const authenticator = new VpcInstanceAuthenticator(fullConfig); + + expect(authenticator.tokenManager.iamProfileId).toBe(fullConfig.iamProfileId); + expect(authenticator.tokenManager.url).toBe(fullConfig.url); + expect(authenticator.tokenManager.serviceVersion).toBe(fullConfig.serviceVersion); + expect(authenticator.tokenManager.tokenLifetime).toBe(fullConfig.tokenLifetime); + }); + + it('should accept serviceVersion from environment variables (via constructor)', () => { + // This simulates how environment variables are passed to the authenticator + // via getAuthenticatorFromEnvironment -> readExternalSources + const envConfig = { + iamProfileId: 'some-id', + serviceVersion: SERVICE_VERSION_2025, // This would come from SERVICE_NAME_SERVICE_VERSION env var + }; + + const authenticator = new VpcInstanceAuthenticator(envConfig); + + expect(authenticator.serviceVersion).toBe(SERVICE_VERSION_2025); + expect(authenticator.tokenManager.serviceVersion).toBe(SERVICE_VERSION_2025); + expect(authenticator.iamProfileId).toBe('some-id'); + }); + + it('should throw an error for invalid service version', () => { + expect( + () => + new VpcInstanceAuthenticator({ + serviceVersion: 'invalid-version', + }) + ).toThrow(INVALID_SERVICE_VERSION_ERROR); + }); + // "end to end" style test, to make sure this authenticator integrates properly with parent classes it('should update the options and resolve with `null` when `authenticate` is called', async () => { const authenticator = new VpcInstanceAuthenticator({ iamProfileCrn: config.iamProfileCrn }); diff --git a/test/unit/vpc-instance-token-manager.test.js b/test/unit/vpc-instance-token-manager.test.js index 4b3cd64d..1c9bf42f 100644 --- a/test/unit/vpc-instance-token-manager.test.js +++ b/test/unit/vpc-instance-token-manager.test.js @@ -37,6 +37,11 @@ const debugLogSpy = jest.spyOn(logger, 'debug').mockImplementation(() => {}); const IAM_PROFILE_CRN = 'some-crn'; const IAM_PROFILE_ID = 'some-id'; const EXPIRATION_WINDOW = 10; +const SERVICE_VERSION_2022 = '2022-03-01'; +const SERVICE_VERSION_2025 = '2025-08-26'; +const DEFAULT_TOKEN_LIFETIME = 300; +const CUSTOM_TOKEN_LIFETIME = 600; +const INVALID_SERVICE_VERSION_ERROR = `Invalid serviceVersion. Must be one of: ${SERVICE_VERSION_2022}, ${SERVICE_VERSION_2025}`; describe('VPC Instance Token Manager', () => { const sendRequestMock = jest.fn(); @@ -120,10 +125,10 @@ describe('VPC Instance Token Manager', () => { expect(requestOptions.method).toBe('PUT'); expect(requestOptions.qs).toBeDefined(); - expect(requestOptions.qs.version).toBe('2022-03-01'); + expect(requestOptions.qs.version).toBe(SERVICE_VERSION_2022); expect(requestOptions.body).toBeDefined(); - expect(requestOptions.body.expires_in).toBe(300); + expect(requestOptions.body.expires_in).toBe(DEFAULT_TOKEN_LIFETIME); expect(requestOptions.headers).toBeDefined(); expect(requestOptions.headers['Content-Type']).toBe('application/json'); @@ -182,7 +187,7 @@ describe('VPC Instance Token Manager', () => { expect(requestOptions.method).toBe('POST'); expect(requestOptions.qs).toBeDefined(); - expect(requestOptions.qs.version).toBe('2022-03-01'); + expect(requestOptions.qs.version).toBe(SERVICE_VERSION_2022); // if neither the profile id or crn is set, then the body should be undefined expect(requestOptions.body).toBeUndefined(); @@ -231,7 +236,84 @@ describe('VPC Instance Token Manager', () => { /^ibm-node-sdk-core\/vpc-instance-authenticator.*$/ ); }); + + it('should use default service version and token lifetime', () => { + const instance = new VpcInstanceTokenManager(); + + // Test default service version + expect(instance.serviceVersion).toBe(SERVICE_VERSION_2022); + + // Test default token lifetime + expect(instance.tokenLifetime).toBe(DEFAULT_TOKEN_LIFETIME); + + // Test default paths for old service version + expect(instance.getAccessTokenPath()).toBe('/instance_identity/v1/token'); + expect(instance.getIamTokenPath()).toBe('/instance_identity/v1/iam_token'); + }); + + it('should use custom service version and token lifetime', () => { + const instance = new VpcInstanceTokenManager({ + serviceVersion: SERVICE_VERSION_2025, + tokenLifetime: CUSTOM_TOKEN_LIFETIME, + }); + + // Test custom service version + expect(instance.serviceVersion).toBe(SERVICE_VERSION_2025); + + // Test custom token lifetime + expect(instance.tokenLifetime).toBe(CUSTOM_TOKEN_LIFETIME); + + // Test new paths for new service version + expect(instance.getAccessTokenPath()).toBe('/identity/v1/token'); + expect(instance.getIamTokenPath()).toBe('/identity/v1/iam_tokens'); + }); + + it('should set service version and token lifetime with setters', () => { + const instance = new VpcInstanceTokenManager(); + + instance.setServiceVersion(SERVICE_VERSION_2025); + instance.setTokenLifetime(CUSTOM_TOKEN_LIFETIME); + + // Test service version from setter + expect(instance.serviceVersion).toBe(SERVICE_VERSION_2025); + + // Test token lifetime from setter + expect(instance.tokenLifetime).toBe(CUSTOM_TOKEN_LIFETIME); + + // Test new paths for new service version + expect(instance.getAccessTokenPath()).toBe('/identity/v1/token'); + expect(instance.getIamTokenPath()).toBe('/identity/v1/iam_tokens'); + }); + + it('should throw an error when setting invalid service version with setter', () => { + const instance = new VpcInstanceTokenManager(); + + expect(() => instance.setServiceVersion('2024-01-01')).toThrow(INVALID_SERVICE_VERSION_ERROR); + }); + + it('should use old paths for old service version', () => { + const instance = new VpcInstanceTokenManager({ + serviceVersion: SERVICE_VERSION_2022, + }); + + // Test old service version + expect(instance.serviceVersion).toBe(SERVICE_VERSION_2022); + + // Test old paths for old service version + expect(instance.getAccessTokenPath()).toBe('/instance_identity/v1/token'); + expect(instance.getIamTokenPath()).toBe('/instance_identity/v1/iam_token'); + }); + + it('should throw an error for invalid service version', () => { + expect( + () => + new VpcInstanceTokenManager({ + serviceVersion: '2024-01-01', + }) + ).toThrow(INVALID_SERVICE_VERSION_ERROR); + }); }); + describe('getToken', () => { it('should refresh an expired access token', async () => { const instance = new VpcInstanceTokenManager({ iamProfileId: 'some-id' });