diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQIAMServiceManager.test.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQIAMServiceManager.test.ts index daf95576d9..93b64d0397 100644 --- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQIAMServiceManager.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQIAMServiceManager.test.ts @@ -2,6 +2,8 @@ import { TestFeatures } from '@aws/language-server-runtimes/testing' import { deepStrictEqual } from 'assert' import sinon from 'ts-sinon' import { AmazonQIAMServiceManager } from './AmazonQIAMServiceManager' +import { AmazonQServicePendingSigninError } from './errors' +import { CodeWhispererServiceIAM } from '../codeWhispererService' import { generateSingletonInitializationTests } from './testUtils' import * as utils from '../utils' @@ -23,6 +25,10 @@ describe('AmazonQIAMServiceManager', () => { 'updateCachedServiceConfig' as keyof AmazonQIAMServiceManager ) + // Default: IAM credentials are present. Individual tests override this to exercise + // the missing-credentials path. + features.credentialsProvider.hasCredentials.withArgs('iam').returns(true) + AmazonQIAMServiceManager.resetInstance() serviceManager = AmazonQIAMServiceManager.initInstance(features) }) @@ -63,17 +69,35 @@ describe('AmazonQIAMServiceManager', () => { serviceManager.getCodewhispererService() throw new Error('Expected error was not thrown') } catch (error) { + deepStrictEqual(error instanceof AmazonQServicePendingSigninError, true) deepStrictEqual((error as Error).message.includes('No IAM credentials available'), true) } }) - it('should validate credentials before creating service', () => { - const hasCredentialsSpy = sinon.spy(features.credentialsProvider, 'hasCredentials') + it('should not create or cache a service while IAM credentials are missing', () => { + features.credentialsProvider.hasCredentials.withArgs('iam').returns(false) + + try { + serviceManager.getCodewhispererService() + } catch { + // expected + } + sinon.assert.notCalled(updateCachedServiceConfigSpy) + + // Once credentials arrive the service is created normally + features.credentialsProvider.hasCredentials.withArgs('iam').returns(true) + const service = serviceManager.getCodewhispererService() + deepStrictEqual(service instanceof CodeWhispererServiceIAM, true) + sinon.assert.calledOnce(updateCachedServiceConfigSpy) + }) + + it('should validate credentials before creating service', () => { + // hasCredentials is already a stub on TestFeatures; assert on it directly features.credentialsProvider.hasCredentials.withArgs('iam').returns(true) serviceManager.getCodewhispererService() - sinon.assert.calledWith(hasCredentialsSpy, 'iam') + sinon.assert.calledWith(features.credentialsProvider.hasCredentials, 'iam') }) it('should return correct credential validation status', () => { diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQIAMServiceManager.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQIAMServiceManager.ts index a3d4a19fe8..ebf8ab72b0 100644 --- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQIAMServiceManager.ts +++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQIAMServiceManager.ts @@ -6,7 +6,11 @@ import { } from './BaseAmazonQServiceManager' import { getAmazonQRegionAndEndpoint } from './configurationUtils' import { StreamingClientServiceIAM } from '../streamingClientService' -import { AmazonQServiceAlreadyInitializedError, AmazonQServiceInitializationError } from './errors' +import { + AmazonQServiceAlreadyInitializedError, + AmazonQServiceInitializationError, + AmazonQServicePendingSigninError, +} from './errors' import { CancellationToken, CredentialsType, @@ -52,6 +56,13 @@ export class AmazonQIAMServiceManager extends BaseAmazonQServiceManager< } public getCodewhispererService() { + // Mirror the token-based manager: do not hand out a service that cannot authenticate. + // Without this, the SDK credential callback dereferences `undefined` and every + // inline-completion trigger fails with a bare TypeError until credentials arrive. + if (!this.hasValidCredentials()) { + throw new AmazonQServicePendingSigninError('No IAM credentials available') + } + if (!this.cachedCodewhispererService) { this.cachedCodewhispererService = new CodeWhispererServiceIAM( this.features.credentialsProvider, diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts index d0bd3ae338..285916b509 100644 --- a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts @@ -14,6 +14,7 @@ import { CancellationToken, InlineCompletionWithReferencesParams, } from '@aws/language-server-runtimes/server-interface' +import { AwsCredentialIdentity } from '@aws-sdk/types' import * as sinon from 'sinon' import * as assert from 'assert' import { @@ -214,6 +215,96 @@ describe('CodeWhispererService', function () { }) }) + describe('credentials provider callback', function () { + // Re-create the service with a stub that captures the SDK client options so the + // `credentials` function handed to the SigV4 client can be exercised directly. + let capturedCredentialsFn: () => Promise + + beforeEach(function () { + const createClientStub = require('../client/sigv4/codewhisperer') + .createCodeWhispererSigv4Client as sinon.SinonStub + createClientStub.callsFake((options: any) => { + capturedCredentialsFn = options.credentials + return { send: sandbox.stub(), middlewareStack: { add: sandbox.stub() } } + }) + service = new CodeWhispererServiceIAM( + mockCredentialsProvider as any, + {} as any, + mockLogging as any, + 'us-east-1', + 'https://codewhisperer.us-east-1.amazonaws.com', + mockSDKInitializator as any + ) + }) + + it('should throw a clear authorization error when IAM credentials are not set', async function () { + mockCredentialsProvider.getCredentials.withArgs('iam').returns(undefined) + + await assert.rejects( + () => capturedCredentialsFn(), + (err: unknown) => + err instanceof Error && + !(err instanceof TypeError) && + err.message === 'Authorization failed, IAM credentials are not set' + ) + }) + + it('should throw a clear authorization error when IAM credentials are incomplete', async function () { + // deliberately incomplete credentials object + mockCredentialsProvider.getCredentials.withArgs('iam').returns({ accessKeyId: 'AKIA' } as any) + + await assert.rejects( + () => capturedCredentialsFn(), + (err: unknown) => err instanceof Error && !(err instanceof TypeError) + ) + }) + + it('should convert a string expiration into a Date so the SDK can call getTime()', async function () { + // Credentials reach the server over JSON, so Date fields arrive as ISO strings. + const iso = new Date(Date.now() + 3600 * 1000).toISOString() + mockCredentialsProvider.getCredentials.withArgs('iam').returns({ + accessKeyId: 'AKIA', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration: iso, + } as any) + + const identity = await capturedCredentialsFn() + assert.ok(identity.expiration instanceof Date, 'expiration must be a Date instance') + assert.strictEqual(identity.expiration!.toISOString(), iso) + // This is exactly what @smithy/core does when deciding whether to refresh. + assert.doesNotThrow(() => identity.expiration!.getTime()) + }) + + it('should leave expiration undefined when the credentials have none', async function () { + mockCredentialsProvider.getCredentials.withArgs('iam').returns({ + accessKeyId: 'AKIA', + secretAccessKey: 'secret', + sessionToken: 'token', + } as any) + + const identity = await capturedCredentialsFn() + assert.strictEqual(identity.expiration, undefined) + }) + + it('should return the IAM credentials when they are set', async function () { + const expiration = new Date() + mockCredentialsProvider.getCredentials.withArgs('iam').returns({ + accessKeyId: 'AKIA', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration, + }) + + assert.deepStrictEqual(await capturedCredentialsFn(), { + accessKeyId: 'AKIA', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration, + }) + }) + }) + describe('generateSuggestions', function () { it('should call client.generateRecommendations and process response', async function () { const mockRequest: GenerateSuggestionsRequest = { diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts index c57a2fbec7..abd4ccb626 100644 --- a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts +++ b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts @@ -310,14 +310,22 @@ export class CodeWhispererServiceIAM extends CodeWhispererServiceBase { logging.info('CodeWhispererService IAM: Attempting to get credentials') try { - const creds = credentialsProvider.getCredentials('iam') as AwsCredentialIdentity + const creds = credentialsProvider.getCredentials('iam') as AwsCredentialIdentity | undefined + if (!creds?.accessKeyId || !creds.secretAccessKey) { + // Same contract as the bearer-token provider: fail with a clear auth error + // instead of a TypeError from dereferencing missing credentials. + throw new Error('Authorization failed, IAM credentials are not set') + } logging.info('CodeWhispererService IAM: Successfully got credentials') return { accessKeyId: creds.accessKeyId, secretAccessKey: creds.secretAccessKey, sessionToken: creds.sessionToken, - expiration: creds.expiration, + // Credentials are pushed to the server over JSON, so `expiration` arrives as an + // ISO string. The SDK calls `expiration.getTime()` when deciding whether to + // refresh, so it must be a real Date (see StreamingClientServiceIAM for the same). + expiration: creds.expiration ? new Date(creds.expiration) : undefined, } } catch (err) { if (err instanceof Error) {