Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
13 changes: 6 additions & 7 deletions packages/core/src/auth/consoleSessionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import { CancellationError } from '../shared/utilities/timeoutUtils'
import { ToolkitError } from '../shared/errors'
import { telemetry } from '../shared/telemetry/telemetry'
import { Auth } from './auth'
import { CredentialsId, asString } from './providers/credentials'
import { createRegionPrompter } from '../shared/ui/common/region'
import { getConnectionIdFromProfile } from './utils'

/**
* @description Authenticates with AWS using browser-based login via AWS CLI.
Expand Down Expand Up @@ -289,14 +289,13 @@ export async function authenticateWithConsoleLogin(profileName?: string, region?
)

logger.info(`Activating profile: ${profileName}`)
const credentialsId: CredentialsId = {
credentialSource: 'profile',
credentialTypeId: profileName,
}
const connectionId = asString(credentialsId)
const connectionId = getConnectionIdFromProfile(profileName)
// Invalidate cached credentials to force fresh fetch
getLogger().info(`Invalidated cached credentials for ${connectionId}`)
globals.loginManager.store.invalidateCredentials(credentialsId)
globals.loginManager.store.invalidateCredentials({
credentialSource: 'profile',
credentialTypeId: profileName,
})
logger.info(`Looking for connection with ID: ${connectionId}`)

// Make sure that connection exists before letting other part use connection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export async function handleInvalidConsoleCredentials(
`Reloading window to sync with updated credentials cache using connection for profile: ${profileName}`
)
const reloadResponse = await vscode.window.showInformationMessage(
`Credentials for "${profileName}" were updated. A window reload is required to apply them. Save your work before continuing. Reload now?`,
`Credentials for profile "${profileName}" were updated. A window reload is required to apply them. Save your work before continuing. Reload now?`,
localizedText.yes,
localizedText.no
)
Expand Down
31 changes: 30 additions & 1 deletion packages/core/src/auth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { createQuickPick, DataQuickPickItem, showQuickPick } from '../shared/ui/
import { isValidResponse } from '../shared/wizards/wizard'
import { CancellationError } from '../shared/utilities/timeoutUtils'
import { formatError, ToolkitError } from '../shared/errors'
import { asString } from './providers/credentials'
import { CredentialsId, asString } from './providers/credentials'
import { TreeNode } from '../shared/treeview/resourceTreeDataProvider'
import { createInputBox } from '../shared/ui/inputPrompter'
import { CredentialSourceId, telemetry } from '../shared/telemetry/telemetry'
Expand Down Expand Up @@ -899,3 +899,32 @@ export function isLocalStackConnection(): boolean {
globals.globalState.tryGet('aws.toolkit.externalConnection', String, undefined) === localStackConnectionString
)
}

/**
* Constructs a credentials ID from a profile name.
*
* @param profileName - Profile name
* @returns Credentials ID string
*/
export function getConnectionIdFromProfile(profileName: string): string {
const credentialsId: CredentialsId = {
credentialSource: 'profile',
credentialTypeId: profileName,
}
return asString(credentialsId)
}

/**
* Sets up and activates a console connection via browser login.
* Prompts user to log in via browser, creates a profile-based connection, and sets it as active.
*
* @param profileName - Profile name (typically Lambda function name)
* @param region - AWS region
* @throws Error if console login fails or user cancels
*/
export async function setupConsoleConnection(profileName: string, region: string): Promise<void> {
getLogger().info('Auth: Sets up a connection via browser login for profile: %s, region: %s', profileName, region)
await vscode.commands.executeCommand('aws.toolkit.auth.consoleLogin', profileName, region)
const connectionId = getConnectionIdFromProfile(profileName)
await Auth.instance.useConnection({ id: connectionId })
}
69 changes: 63 additions & 6 deletions packages/core/src/lambda/commands/editLambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/
import * as vscode from 'vscode'
import * as nls from 'vscode-nls'
import { GetFunctionCommandOutput } from '@aws-sdk/client-lambda'
import { LambdaFunctionNode } from '../explorer/lambdaFunctionNode'
import { downloadLambdaInLocation, openLambdaFile } from './downloadLambda'
import { LambdaFunction, runUploadDirectory } from './uploadLambda'
Expand All @@ -24,6 +25,8 @@ import { telemetry } from '../../shared/telemetry/telemetry'
import { ToolkitError } from '../../shared/errors'
import { getFunctionWithCredentials } from '../../shared/clients/lambdaClient'
import { getLogger } from '../../shared/logger/logger'
import { showViewLogsMessage } from '../../shared/utilities/messages'
import { setupConsoleConnection, getIAMConnection } from '../../auth/utils'

const localize = nls.loadMessageBundle()

Expand Down Expand Up @@ -118,8 +121,9 @@ export async function deployFromTemp(lambda: LambdaFunction, projectUri: vscode.
await vscode.workspace.saveAll()
try {
await runUploadDirectory(lambda, 'zip', projectUri)
} catch {
throw new ToolkitError('Failed to deploy Lambda function', { code: 'deployFailure' })
} catch (error) {
// Chain error to preserve root cause for troubleshooting deployment failures
throw ToolkitError.chain(error, 'Failed to deploy Lambda function', { code: 'deployFailure' })
}
await setFunctionInfo(lambda, {
lastDeployed: globals.clock.Date.now(),
Expand Down Expand Up @@ -225,14 +229,67 @@ export async function editLambda(lambda: LambdaFunction, source?: 'workspace' |
})
}

/**
* Retrieves Lambda function configuration with automatic fallback to console credentials.
* Handles credential mismatches (ResourceNotFoundException, AccessDeniedException).
*
* Three scenarios:
* 1. No connection exists → Set up console first, try once, if it fails don't retry (because we already used console)
* 2. Connection exists → Try it first, if it fails with credential error, fall back to console
* 3. Connection exists and fails → Retry with console, if that fails, throw (no second retry)
*
* @param name - Lambda function name
* @param region - AWS region
* @returns Lambda function information with a link to download the deployment package
*/
export async function getFunctionWithFallback(name: string, region: string): Promise<GetFunctionCommandOutput> {
const activeConnection = await getIAMConnection({ prompt: false })
// Tracks if we've already attempted console credentials
let calledConsoleLogin = false

// If no connection, create console connection before first attempt
if (!activeConnection) {
await setupConsoleConnection(name, region)
calledConsoleLogin = true
}

try {
return await getFunctionWithCredentials(region, name)
} catch (error: any) {
// Detect credential mismatches (ResourceNotFoundException, AccessDeniedException)
let message: string | undefined
if (error.name === 'ResourceNotFoundException') {
message = localize('AWS.lambda.open.functionNotFound', 'Function not found in current account.')
} else if (error.name === 'AccessDeniedException') {
message = localize('AWS.lambda.open.accessDenied', 'Local credentials lack permission to access function.')
}

if (message) {
void showViewLogsMessage(message, 'warn')
getLogger().warn(message)
}

if (calledConsoleLogin) {
// Skip retry if we just created console connection - error is not due to credential mismatch
throw ToolkitError.chain(error, 'Failed to get Lambda function with console credentials. Retry skipped.')
} else {
// Retry once with console credentials
await setupConsoleConnection(name, region)
return await getFunctionWithCredentials(region, name)
}
}
}

/**
* Opens a Lambda function for editing in VS Code.
* Retrieves IAM credentials (with console fallback), downloads function code, and opens it in a new workspace.
* Note: IAM credentials are required to interact with AWS resources, even for SSO users.
*/
export async function openLambdaFolderForEdit(name: string, region: string) {
const downloadLocation = getTempLocation(name, region)

// Do all authentication work before opening workspace to avoid race condition
const getFunctionOutput = await getFunctionWithCredentials(region, name)
const getFunctionOutput = await getFunctionWithFallback(name, region)
const configuration = getFunctionOutput.Configuration

// Download and set up Lambda code before opening workspace
await editLambda(
{
name,
Expand Down
18 changes: 16 additions & 2 deletions packages/core/src/lambda/uriHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,24 @@ import { openLambdaFolderForEdit } from './commands/editLambda'
import { showConfirmationMessage } from '../shared/utilities/messages'
import globals from '../shared/extensionGlobals'
import { telemetry } from '../shared/telemetry/telemetry'
import { ToolkitError } from '../shared/errors'
import { ToolkitError, isUserCancelledError } from '../shared/errors'

const localize = nls.loadMessageBundle()

export function handleLambdaUriError(e: unknown, functionName: string, region: string): never {
// Classify cancellations for telemetry metrics
const message = e instanceof Error ? e.message.toLowerCase() : ''
const isCancellation = isUserCancelledError(e) || message.includes('canceled') || message.includes('cancelled')

if (isCancellation) {
throw ToolkitError.chain(e, 'User cancelled operation', { cancelled: true })
}

// Handle other errors
void vscode.window.showErrorMessage(`Unable to open function ${functionName} in region ${region}: ${e}`)
throw ToolkitError.chain(e, 'Failed to open Lambda function')
}

export function registerLambdaUriHandler() {
async function openFunctionHandler(params: ReturnType<typeof parseOpenParams>) {
await telemetry.lambda_uriHandler.run(async () => {
Expand All @@ -34,7 +48,7 @@ export function registerLambdaUriHandler() {
}
await openLambdaFolderForEdit(params.functionName, params.region)
} catch (e) {
throw new ToolkitError(`Unable to get function ${params.functionName} in region ${params.region}: ${e}`)
handleLambdaUriError(e, params.functionName, params.region)
}
})
}
Expand Down
14 changes: 2 additions & 12 deletions packages/core/src/login/webview/vue/toolkit/backend_toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import * as vscode from 'vscode'
import { tryAddCredentials } from '../../../../auth/utils'
import { setupConsoleConnection, tryAddCredentials } from '../../../../auth/utils'
import { getLogger } from '../../../../shared/logger/logger'
import { CommonAuthWebview } from '../backend'
import {
Expand All @@ -21,7 +21,6 @@ import { CodeCatalystAuthenticationProvider } from '../../../../codecatalyst/aut
import { AuthError, AuthFlowState } from '../types'
import { setContext } from '../../../../shared/vscode/setContext'
import { builderIdStartUrl } from '../../../../auth/sso/constants'
import { CredentialsId, asString } from '../../../../auth/providers/credentials'
import { RegionProfile } from '../../../../codewhisperer/models/model'
import { ProfileSwitchIntent } from '../../../../codewhisperer/region/regionProfileManager'
import globals from '../../../../shared/extensionGlobals'
Expand Down Expand Up @@ -91,16 +90,7 @@ export class ToolkitLoginWebview extends CommonAuthWebview {
getLogger().debug(`called startConsoleCredentialSetup()`)
const runAuth = async () => {
try {
// Execute AWS CLI login command
await vscode.commands.executeCommand('aws.toolkit.auth.consoleLogin', profileName, region)

// Use profile as active connection
const credentialsId: CredentialsId = {
credentialSource: 'profile',
credentialTypeId: profileName,
}
const connectionId = asString(credentialsId)
await Auth.instance.useConnection({ id: connectionId })
await setupConsoleConnection(profileName, region)

// Hide auth view and show resource explorer
await setContext('aws.explorer.showAuthView', false)
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/shared/clients/lambdaClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ export async function getFunctionWithCredentials(region: string, name: string):

const credentials =
connection.type === 'iam' ? await connection.getCredentials() : fromSSO({ profile: connection.id })

const client = new LambdaSdkClient({ region, credentials })

const command = new GetFunctionCommand({ FunctionName: name })
Expand Down
60 changes: 60 additions & 0 deletions packages/core/src/test/auth/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import assert from 'assert'
import * as sinon from 'sinon'
import * as vscode from 'vscode'
import { Auth } from '../../auth/auth'
import { ToolkitError } from '../../shared/errors'
import * as authUtils from '../../auth/utils'

describe('getConnectionIdFromProfile', function () {
it('constructs connection ID from profile name', function () {
const result = authUtils.getConnectionIdFromProfile('my-profile')
assert.strictEqual(result, 'profile:my-profile')
})

it('handles profile names with special characters', function () {
const result = authUtils.getConnectionIdFromProfile('my-profile-123')
assert.strictEqual(result, 'profile:my-profile-123')
})
})

describe('setupConsoleConnection', function () {
let sandbox: sinon.SinonSandbox

beforeEach(function () {
sandbox = sinon.createSandbox()
})

afterEach(function () {
sandbox.restore()
})

it('creates connection after successful console login', async function () {
const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves()
const useConnectionStub = sandbox.stub(Auth.instance, 'useConnection').resolves()

await authUtils.setupConsoleConnection('test-profile', 'us-east-1')

assert.ok(executeCommandStub.calledOnceWith('aws.toolkit.auth.consoleLogin', 'test-profile', 'us-east-1'))
assert.ok(useConnectionStub.calledOnceWith({ id: 'profile:test-profile' }))
})

it('throws error when useConnection fails', async function () {
sandbox.stub(vscode.commands, 'executeCommand').resolves()
const error = new Error('useConnection failed')
sandbox.stub(Auth.instance, 'useConnection').rejects(error)

await assert.rejects(() => authUtils.setupConsoleConnection('test-profile', 'us-east-1'), error)
})

it('throws error when console login command fails', async function () {
const error = new ToolkitError('Console login failed')
sandbox.stub(vscode.commands, 'executeCommand').rejects(error)

await assert.rejects(() => authUtils.setupConsoleConnection('test-profile', 'us-east-1'), error)
})
})
Loading
Loading