Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix(localstack/lambda): get response headers middleware
  • Loading branch information
carole-lavillonniere authored and valerena committed Nov 18, 2025
commit 422efb278344aab3e68543d454761a8f808fc547
14 changes: 10 additions & 4 deletions packages/core/src/auth/deprecated/loginManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ import { isAutomation } from '../../shared/vscode/env'
import { Credentials } from '@aws-sdk/types'
import { ToolkitError } from '../../shared/errors'
import * as localizedText from '../../shared/localizedText'
import { DefaultStsClient, type GetCallerIdentityResponse } from '../../shared/clients/stsClient'
import {
DefaultStsClient,
type GetCallerIdentityResponse,
type GetCallerIdentityResponseWithHeaders,
} from '../../shared/clients/stsClient'
import { findAsync } from '../../shared/utilities/collectionUtils'
import { telemetry } from '../../shared/telemetry/telemetry'
import { withTelemetryContext } from '../../shared/telemetry/util'
Expand Down Expand Up @@ -135,9 +139,11 @@ export class LoginManager {
return accountId
}

private async detectExternalConnection(callerIdentity: GetCallerIdentityResponse): Promise<void> {
// @ts-ignore
const headers = callerIdentity.$response?.httpResponse?.headers
private async detectExternalConnection(
callerIdentity: GetCallerIdentityResponse | GetCallerIdentityResponseWithHeaders
): Promise<void> {
// SDK v3: Headers are captured via middleware and attached as $httpHeaders
const headers = (callerIdentity as GetCallerIdentityResponseWithHeaders).$httpHeaders
if (headers !== undefined && localStackConnectionHeader in headers) {
await globals.globalState.update('aws.toolkit.externalConnection', localStackConnectionString)
telemetry.auth_localstackEndpoint.emit({ source: 'validateCredentials', result: 'Succeeded' })
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/shared/awsClientBuilderV3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export class AWSClientBuilderV3 {
}
const service = new serviceOptions.serviceClient(opt)
service.middlewareStack.add(telemetryMiddleware, { step: 'deserialize' })
service.middlewareStack.add(captureHeadersMiddleware, { step: 'deserialize' })
service.middlewareStack.add(loggingMiddleware, { step: 'finalizeRequest' })
service.middlewareStack.add(getEndpointMiddleware(serviceOptions.settings), { step: 'build' })

Expand Down Expand Up @@ -254,6 +255,26 @@ function getEndpointMiddleware(settings: DevSettings = DevSettings.instance): Bu
const keepAliveMiddleware: BuildMiddleware<any, any> = (next: BuildHandler<any, any>) => async (args: any) =>
addKeepAliveHeader(next, args)

/**
* Middleware that captures HTTP response headers and attaches them to the output object.
* This makes headers accessible via `response.$httpHeaders` for all AWS SDK v3 operations.
* Useful for detecting custom headers from services like LocalStack.
*/
const captureHeadersMiddleware: DeserializeMiddleware<any, any> =
(next: DeserializeHandler<any, any>) => async (args: any) => {
const result = await next(args)

// Extract headers from HTTP response and attach to output for easy access
if (HttpResponse.isInstance(result.response)) {
const headers = result.response.headers
if (headers && result.output) {
result.output.$httpHeaders = headers
}
}

return result
}

export async function emitOnRequest(next: DeserializeHandler<any, any>, context: HandlerExecutionContext, args: any) {
if (!HttpResponse.isInstance(args.request)) {
return next(args)
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/shared/clients/stsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import { Credentials } from '@aws-sdk/types'
import globals from '../extensionGlobals'
import { ClassToInterfaceType } from '../utilities/tsUtils'

// Extended response type that includes captured HTTP headers (added by global middleware)
export interface GetCallerIdentityResponseWithHeaders extends GetCallerIdentityResponse {
$httpHeaders?: Record<string, string>
}

export type { GetCallerIdentityResponse }
export type StsClient = ClassToInterfaceType<DefaultStsClient>

Expand All @@ -35,8 +40,9 @@ export class DefaultStsClient {
return response
}

public async getCallerIdentity(): Promise<GetCallerIdentityResponse> {
public async getCallerIdentity(): Promise<GetCallerIdentityResponseWithHeaders> {
const sdkClient = this.createSdkClient()
// Note: $httpHeaders are added by global middleware in awsClientBuilderV3
const response = await sdkClient.send(new GetCallerIdentityCommand({}))
return response
}
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/test/shared/awsClientBuilderV3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,28 @@ describe('AwsClientBuilderV3', function () {
assert.strictEqual(newArgs.request.hostname, 'testHost')
assert.strictEqual(newArgs.request.path, 'testPath')
})

it('captures HTTP response headers and attaches to output', async function () {
const testHeaders = {
'x-custom-header': 'test-value',
'content-type': 'application/json',
}
response.response = {
statusCode: 200,
headers: testHeaders,
} as any

const service = builder.createAwsService({ serviceClient: Client })
// Verify middleware stack exists
const middlewareStack = service.middlewareStack as any
assert.ok(middlewareStack, 'Middleware stack should exist')

// Verify the middlewareStack has the expected structure
// The captureHeadersMiddleware is added in the awsClientBuilderV3 implementation
// It should be present in the deserialize step
assert.ok(typeof middlewareStack.add === 'function', 'Middleware stack should have add method')
assert.ok(typeof middlewareStack.use === 'function', 'Middleware stack should have use method')
})
})

describe('clientCredentials', function () {
Expand Down
38 changes: 16 additions & 22 deletions packages/core/src/test/shared/credentials/loginManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import { CredentialsProviderManager } from '../../../auth/providers/credentialsP
import { AwsContext } from '../../../shared/awsContext'
import { CredentialsStore } from '../../../auth/credentials/store'
import { assertTelemetryCurried } from '../../testUtil'
import { DefaultStsClient, GetCallerIdentityResponse } from '../../../shared/clients/stsClient'
import {
DefaultStsClient,
GetCallerIdentityResponse,
GetCallerIdentityResponseWithHeaders,
} from '../../../shared/clients/stsClient'
import globals from '../../../shared/extensionGlobals'
import { localStackConnectionHeader, localStackConnectionString } from '../../../auth/utils'

Expand Down Expand Up @@ -209,18 +213,13 @@ describe('LoginManager', async function () {
})

it('detects LocalStack connection and updates global state', async function () {
const mockCallerIdentityWithLocalStack: GetCallerIdentityResponse = {
const mockCallerIdentityWithLocalStack: GetCallerIdentityResponseWithHeaders = {
Account: 'AccountId1234',
Arn: 'arn:aws:iam::AccountId1234:user/test-user',
UserId: 'AIDACKCEXAMPLEEXAMPLE',
// @ts-ignore - Adding the $response property for testing
$response: {
httpResponse: {
headers: {
[localStackConnectionHeader]: 'true',
'content-type': 'application/json',
},
},
$httpHeaders: {
[localStackConnectionHeader]: 'true',
'content-type': 'application/json',
},
}
getAccountIdStub.reset()
Expand All @@ -234,18 +233,13 @@ describe('LoginManager', async function () {
})

it('does not detect external connection when LocalStack header is missing', async function () {
const mockCallerIdentityWithoutLocalStack: GetCallerIdentityResponse = {
const mockCallerIdentityWithoutLocalStack: GetCallerIdentityResponseWithHeaders = {
Account: 'AccountId1234',
Arn: 'arn:aws:iam::AccountId1234:user/test-user',
UserId: 'AIDACKCEXAMPLEEXAMPLE',
// @ts-ignore - Adding the $response property for testing
$response: {
httpResponse: {
headers: {
'content-type': 'application/json',
'x-amzn-requestid': 'test-request-id',
},
},
$httpHeaders: {
'content-type': 'application/json',
'x-amzn-requestid': 'test-request-id',
},
}
getAccountIdStub.reset()
Expand All @@ -258,14 +252,14 @@ describe('LoginManager', async function () {
assert.strictEqual(globalStateUpdateStub.firstCall.args[1], undefined)
})

it('handles response with no $response property', async function () {
const mockCallerIdentityWithoutResponse: GetCallerIdentityResponse = {
it('handles response with no $httpHeaders property', async function () {
const mockCallerIdentityWithoutHeaders: GetCallerIdentityResponse = {
Account: 'AccountId1234',
Arn: 'arn:aws:iam::AccountId1234:user/test-user',
UserId: 'AIDACKCEXAMPLEEXAMPLE',
}
getAccountIdStub.reset()
getAccountIdStub.resolves(mockCallerIdentityWithoutResponse)
getAccountIdStub.resolves(mockCallerIdentityWithoutHeaders)

await loginManager.validateCredentials(sampleCredentials)

Expand Down