Skip to content

Commit

Permalink
Initial flow for entering credentials profile (aws#109)
Browse files Browse the repository at this point in the history
* Initial flow for entering credentials profile
* When a user tries to connect to AWS, and no credentials profiles can be found...
  * if they have no credentials/config file, they are prompted to enter credentials
  * if they have credentials and/or config files, these are opened in the editor for the user to modify
* New Comand Palette action allows user to Create a credential profile, which follows the same flow as above
* When user enters credentials through a prompt, they are validated with a call to STS getCallerIdentity

* compile step now runs typescript compiler before linting
  • Loading branch information
awschristou authored Oct 10, 2018
1 parent 708d99b commit 5677997
Show file tree
Hide file tree
Showing 17 changed files with 3,198 additions and 2,368 deletions.
4,593 changes: 2,329 additions & 2,264 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 12 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
],
"activationEvents": [
"onCommand:aws.login",
"onCommand:aws.credential.profile.create",
"onCommand:aws.logout",
"onCommand:aws.showRegion",
"onCommand:aws.hideRegion",
Expand Down Expand Up @@ -120,6 +121,11 @@
"title": "%AWS.command.login%",
"category": "AWS"
},
{
"command": "aws.credential.profile.create",
"title": "%AWS.command.credential.profile.create%",
"category": "AWS"
},
{
"command": "aws.logout",
"title": "%AWS.command.logout%",
Expand Down Expand Up @@ -177,7 +183,7 @@
"scripts": {
"vscode:prepublish": "npm run compile",
"bundleDeps": "node ./build-scripts/bundleDeps.js",
"compile": "npm run lint && tsc -p ./ && npm run bundleDeps",
"compile": "tsc -p ./ && npm run lint && npm run bundleDeps",
"watch": "tsc -watch -p ./",
"postinstall": "node ./node_modules/vscode/bin/install",
"test": "npm run compile && node ./node_modules/vscode/bin/test",
Expand All @@ -202,14 +208,18 @@
"vscode-nls-dev": "^3.2.2"
},
"dependencies": {
"@types/handlebars": "^4.0.39",
"@types/opn": "^5.1.0",
"async-lock": "^1.1.3",
"aws-sdk": "^2.317.0",
"fs-extra": "^6.0.1",
"handlebars": "^4.0.12",
"lodash": "^4.17.10",
"npm": "^6.1.0",
"opn": "^5.4.0",
"request": "^2.88.0",
"vscode-nls": "^3.2.4",
"vue": "^2.5.16",
"xml2js": "^0.4.19"
}
}
}
7 changes: 6 additions & 1 deletion package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"AWS.configuration.title": "AWS Configuration",
"AWS.command.login": "Connect to AWS",
"AWS.command.logout": "Sign out",
"AWS.command.credential.profile.create": "Create Credentials Profile",
"AWS.command.showRegion": "Show region in the Explorer",
"AWS.command.hideRegion": "Hide region from the Explorer",
"AWS.command.newLambda": "New Lambda Function or Serverless App",
Expand All @@ -35,12 +36,16 @@
"AWS.message.prompt.defaultRegionHidden.ignore": "No",
"AWS.message.prompt.defaultRegionHidden.alwaysIgnore": "No, and don't ask again",
"AWS.message.prompt.defaultRegionHidden.suppressed": "You will no longer be asked what to do when the current profile's default region is hidden from the Explorer. This behavior can be changed by modifying the '{0}' setting.",
"AWS.message.prompt.credentials.create": "You do not appear to have any AWS Credentials defined. Would you like to set one up now?",
"AWS.message.prompt.credentials.definition.help": "Would you like some information related to defining credentials?",
"AWS.message.prompt.credentials.definition.tryAgain": "The credentials do not appear to be valid ({0}). Would you like to try again?",
"AWS.prompt.mfa.enterCode.placeholder": "Enter Authentication Code Here",
"AWS.prompt.mfa.enterCode.prompt": "Enter authentication code for profile {0}",
"AWS.placeHolder.inputAccessKey": "Input the AWS Access Key to be stored in the profile",
"AWS.placeHolder.inputSecretKey": "Input the AWS Secret Key",
"AWS.placeHolder.newProfileName": "Choose a unique name for the new profile",
"AWS.placeHolder.selectProfile": "Select a credential profile",
"AWS.profile.recentlyUsed": "recently used",
"AWS.tooltip.createCredentialProfile": "Create a new credential profile"
"AWS.generic.response.no": "No",
"AWS.generic.response.yes": "Yes"
}
20 changes: 20 additions & 0 deletions resources/newUserCredentialsFile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Amazon Web Services Credentials File used by AWS CLI, SDKs, and tools
# This file was created by the AWS Toolkit for Visual Studio Code extension.
#
# Your AWS credentials are represented by access keys associated with IAM users.
# For information about how to create and manage AWS access keys for a user, see:
# https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html
#
# This credential file can store multiple access keys by placing each one in a
# named "profile". For information about how to change the access keys in a
# profile or to add a new profile with a different access key, see:
# https://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html
#
[{{profileName}}]
# The access key and secret key pair identify your account and grant access to AWS.
aws_access_key_id = {{accessKey}}
# Treat your secret key like a password. Never share your secret key with anyone. Do
# not post it in online forums, or store it in a source control system. If your secret
# key is ever disclosed, immediately use IAM to delete the access key and secret key
# and create a new key pair. Then, update this file with the replacement key details.
aws_secret_access_key = {{secretKey}}
4 changes: 4 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ export async function activate(context: vscode.ExtensionContext) {
ext.statusBar = new AWSStatusBar(awsContext, context)

vscode.commands.registerCommand('aws.login', async () => await ext.awsContextCommands.onCommandLogin())
vscode.commands.registerCommand(
'aws.credential.profile.create',
async () => await ext.awsContextCommands.onCommandCreateCredentialsProfile()
)
vscode.commands.registerCommand('aws.logout', async () => await ext.awsContextCommands.onCommandLogout())

vscode.commands.registerCommand(
Expand Down
1 change: 1 addition & 0 deletions src/shared/awsContextCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

export interface AWSContextCommands {
onCommandLogin(): Promise<void>
onCommandCreateCredentialsProfile(): Promise<void>
onCommandLogout(): Promise<void>
onCommandShowRegion(): Promise<void>
onCommandHideRegion(regionCode?: string): Promise<void>
Expand Down
1 change: 1 addition & 0 deletions src/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export const profileSettingKey: string = 'profile'

export const hostedFilesBaseUrl: string = 'https://d3rrggjwfhwld2.cloudfront.net/'
export const endpointsFileUrl: string = 'https://aws-toolkit-endpoints.s3.amazonaws.com/endpoints.json'
export const aboutCredentialsFileUrl: string = 'https://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html'
8 changes: 2 additions & 6 deletions src/shared/credentials/credentialSelectionDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,17 @@

'use strict'

import { QuickInputButton, QuickPickItem, Uri } from 'vscode'
import { QuickPickItem } from 'vscode'
import { MultiStepInputFlowController } from '../multiStepInputFlowController'
import { CredentialSelectionState } from './credentialSelectionState'

export class AddProfileButton implements QuickInputButton {
public constructor(public iconPath: { light: Uri; dark: Uri; }, public tooltip: string) { }
}

export interface CredentialSelectionDataProvider {
existingProfileNames: string[]

pickCredentialProfile(
input: MultiStepInputFlowController,
state: Partial<CredentialSelectionState>
): Promise<QuickPickItem | AddProfileButton>
): Promise<QuickPickItem>

inputProfileName(
input: MultiStepInputFlowController,
Expand Down
40 changes: 19 additions & 21 deletions src/shared/credentials/defaultCredentialSelectionDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@
import * as nls from 'vscode-nls'
const localize = nls.loadMessageBundle()

import { ExtensionContext, QuickPickItem, Uri } from 'vscode'
import { ExtensionContext, QuickPickItem } from 'vscode'
import { extensionSettingsPrefix } from '../constants'
import { MultiStepInputFlowController } from '../multiStepInputFlowController'
import { DefaultSettingsConfiguration } from '../settingsConfiguration'
import { AddProfileButton, CredentialSelectionDataProvider } from './credentialSelectionDataProvider'
import { CredentialSelectionDataProvider } from './credentialSelectionDataProvider'
import { CredentialSelectionState } from './credentialSelectionState'
import { CredentialsProfileMru } from './credentialsProfileMru'

Expand All @@ -34,30 +34,21 @@ export class DefaultCredentialSelectionDataProvider implements CredentialSelecti
private static readonly defaultCredentialsProfileName: string = 'default'
private readonly _credentialsMru: CredentialsProfileMru =
new CredentialsProfileMru(new DefaultSettingsConfiguration(extensionSettingsPrefix))
private readonly _newProfileButton: AddProfileButton

public constructor(public readonly existingProfileNames: string[], protected context: ExtensionContext) {
this._newProfileButton = new AddProfileButton(
{
dark: Uri.file(context.asAbsolutePath('resources/dark/add.svg')),
light: Uri.file(context.asAbsolutePath('resources/light/add.svg')),
},
localize('AWS.tooltip.createCredentialProfile', 'Create a new credential profile')
)
}

public async pickCredentialProfile(
input: MultiStepInputFlowController,
state: Partial<CredentialSelectionState>
): Promise<QuickPickItem | AddProfileButton> {
): Promise<QuickPickItem> {
return await input.showQuickPick({
title: localize('AWS.title.selectCredentialProfile', 'Select an AWS credential profile'),
step: 1,
totalSteps: 1,
placeholder: localize('AWS.placeHolder.selectProfile', 'Select a credential profile'),
items: this.getProfileSelectionList(),
activeItem: state.credentialProfile,
buttons: [this._newProfileButton],
shouldResume: this.shouldResume.bind(this)
})
}
Expand Down Expand Up @@ -202,16 +193,23 @@ export async function credentialProfileSelector(
input: MultiStepInputFlowController,
state: Partial<CredentialSelectionState>
) {
const pick = await dataProvider.pickCredentialProfile(input, state)
if (pick instanceof AddProfileButton) {
/* tslint:disable promise-function-async */
return (inputController: MultiStepInputFlowController) => inputProfileName(inputController, state)
/* tslint:enable promise-function-async */
}
state.credentialProfile = await dataProvider.pickCredentialProfile(input, state)
}

async function collectInputs() {
const state: Partial<CredentialSelectionState> = {}
await MultiStepInputFlowController.run(async input => await pickCredentialProfile(input, state))

state.credentialProfile = pick
return state as CredentialSelectionState
}

return await collectInputs()
}

export async function promptToDefineCredentialsProfile(
dataProvider: CredentialSelectionDataProvider
): Promise<CredentialSelectionState> {

async function inputProfileName(input: MultiStepInputFlowController, state: Partial<CredentialSelectionState>) {
state.profileName = await dataProvider.inputProfileName(input, state)

Expand All @@ -232,10 +230,10 @@ export async function credentialProfileSelector(
state.secretKey = await dataProvider.inputSecretKey(input, state)
}

async function collectInputs() {
async function collectInputs(): Promise<CredentialSelectionState> {
const state: Partial<CredentialSelectionState> = {}
/* tslint:disable promise-function-async */
await MultiStepInputFlowController.run(input => pickCredentialProfile(input, state))
await MultiStepInputFlowController.run(input => inputProfileName(input, state))
/* tslint:enable promise-function-async */

return state as CredentialSelectionState
Expand Down
142 changes: 142 additions & 0 deletions src/shared/credentials/userCredentialsUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*!
* Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

'use strict'

import * as handlebars from 'handlebars'
import * as path from 'path'
import * as filesystem from '../filesystem'

import { STS } from 'aws-sdk'
import { ServiceConfigurationOptions } from 'aws-sdk/lib/service'
import { EnvironmentVariables } from '../environmentVariables'
import { SystemUtilities } from '../systemUtilities'

/**
* The payload used to fill in the handlebars template
* for the simple credentials file.
*/
export interface CredentialsTemplateContext {
profileName: string
accessKey: string
secretKey: string
}

export interface CredentialsValidationResult {
isValid: boolean,
invalidMessage?: string
}

export class UserCredentialsUtils {

/**
* @description Determines which credentials related files
* exist, and returns their filenames.
*
* @returns array of filenames for files found.
*/
public static async findExistingCredentialsFilenames(): Promise<string[]> {
const candidateFiles: string[] = [
this.getCredentialsFilename(),
this.getConfigFilename()
]

const existsResults: boolean[] = await Promise.all(
candidateFiles.map(async filename => await SystemUtilities.fileExists(filename))
)

return candidateFiles.filter((filename, index) => existsResults[index])
}

/**
* @returns Filename for the credentials file
*/
public static getCredentialsFilename(): string {
const env = process.env as EnvironmentVariables

return env.AWS_SHARED_CREDENTIALS_FILE
|| path.join(SystemUtilities.getHomeDirectory(), '.aws', 'credentials')
}

/**
* @returns Filename for the config file
*/
public static getConfigFilename(): string {
const env = process.env as EnvironmentVariables

return env.AWS_CONFIG_FILE
|| path.join(SystemUtilities.getHomeDirectory(), '.aws', 'config')
}

/**
* @description Produces a credentials file from a template
* containing a single profile based on the given information
*
* @param credentialsContext the profile to create in the file
*/
public static async generateCredentialsFile(
extensionPath: string,
credentialsContext: CredentialsTemplateContext
): Promise<void> {
const templatePath: string = path.join(extensionPath, 'resources', 'newUserCredentialsFile')

const credentialsTemplate: string = await filesystem.readFileAsyncAsString(templatePath, 'utf-8')

const handlebarTemplate = handlebars.compile(credentialsTemplate)
const credentialsFileContents = handlebarTemplate(credentialsContext)

// Make a final check
if (await SystemUtilities.fileExists(this.getCredentialsFilename())) {
throw new Error('Credentials file exists. Not overwriting it.')
}

await filesystem.writeFileAsync(this.getCredentialsFilename(), credentialsFileContents, 'utf8')
}

/**
* @description Tests if the given credentials are valid by making a request to AWS
*
* @param accessKey access key of credentials to validate
* @param secretKey secret key of credentials to validate
* @param sts (Optional) STS Service Client
*
* @returns a validation result, indicating whether or not credentials are valid, and if not,
* an error message.
*/
public static async validateCredentials(
accessKey: string,
secretKey: string,
sts?: STS
): Promise<CredentialsValidationResult> {
try {
if (!sts) {
const awsServiceOpts: ServiceConfigurationOptions = {
accessKeyId: accessKey,
secretAccessKey: secretKey
}

sts = new STS(awsServiceOpts)
}

await sts.getCallerIdentity().promise()

return { isValid: true }

} catch (err) {

let reason: string

if (err instanceof Error) {
const error = err as Error
console.error(error.message)
reason = error.message
} else {
reason = err as string
}

return { isValid: false, invalidMessage: reason }
}
}
}
Loading

0 comments on commit 5677997

Please sign in to comment.