From 8887f9f344f66e9d4ca09517394510f0e423cf3f Mon Sep 17 00:00:00 2001 From: Laxman Reddy Aileni Date: Fri, 11 Sep 2026 17:15:33 +0000 Subject: [PATCH] fix: inline completions fail with TypeError under IAM auth due to string expiration When the language server runs with IAM authentication, credentials are pushed to it over JSON, so the expiration field arrives as an ISO string. The IAM inline-completion client passed that value straight through to the SDK, which calls expiration.getTime() when deciding whether to refresh, so every request failed before reaching the network with 'TypeError: identity.expiration.getTime is not a function'. The streaming (chat) IAM client already converted the value with new Date(); the inline client did not. Convert expiration to a Date in the inline IAM credential callback. Also harden the same path against missing credentials: the service manager now throws AmazonQServicePendingSigninError when no IAM credentials are available (mirroring the token manager), and the credential callback returns a clear authorization error instead of a TypeError when credentials are absent or incomplete. Fixes the existing service-manager tests that described this behaviour but did not run correctly, and adds regression tests. --- .../AmazonQIAMServiceManager.test.ts | 30 +++++- .../AmazonQIAMServiceManager.ts | 13 ++- .../src/shared/codeWhispererService.test.ts | 91 +++++++++++++++++++ .../src/shared/codeWhispererService.ts | 12 ++- 4 files changed, 140 insertions(+), 6 deletions(-) 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) {