Skip to content

Commit

Permalink
Credentials Management (#888)
Browse files Browse the repository at this point in the history
This change introduces a more managed approach to implementing Credentials Providers for the toolkit. The goal is to make it easier to onboard support for new credentials types moving forward. This design is very similar to the approach taken in the AWS Toolkit for JetBrains.

Integrating this new system brought about some functional changes and bug fixes.

New Behavior:
* added logging for Shared Credentials profiles detection and validation
* credentials are now shown by "credential provider id" instead of profile name (example: `profile:default`)
* whenever users run the "Connect to AWS" command, the Toolkit's list of available credentials providers is refreshed (eg: pulling the latest updates from the shared credentials files)
* Credentials are cached and re-used for the duration of a toolkit session. When logging in, if a Shared Credentials Profile has been modified since it was cached, the updated version of the profile will be used instead of the cached version now.
* updated the "Invalid Credentials" notification, and added a button guiding users to the logs
  • Loading branch information
awschristou authored Jan 13, 2020
1 parent f91693d commit 86e0604
Show file tree
Hide file tree
Showing 38 changed files with 1,522 additions and 467 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "Bug Fix",
"description": "Fixed an issue where invalid credentials were reused until VS Code was closed and re-opened, even if the credentials source was updated. It is no longer necessary to restart VS Code. (#705)"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "Feature",
"description": "When credentials are invalid a notification is shown. To help diagnose these situations, a button was added to the notification that can open the logs."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "Feature",
"description": "When changes are made to Shared Credentials files, they will be picked up by the Toolkit the next time credentials are selected during the 'Connect to AWS' command."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "Feature",
"description": "Credentials were previously shown by their Shared Credentials profile names. They are now displayed in a \"type:name\" format, to better indicate the type of Credentials being used, and to support additional Credentials types in the future. Shared Credentials are shown with the type \"profile\"."
}
5 changes: 3 additions & 2 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@
"AWS.log.invalidLevel": "Invalid log level: {0}",
"AWS.message.loading": "Loading...",
"AWS.message.credentials.error": "There was an issue trying to use credentials profile {0}: {1}",
"AWS.message.credentials.invalidProfile": "Credentials profile {0} is invalid",
"AWS.message.credentials.invalidProfile.help": "Get Help...",
"AWS.message.credentials.invalid": "Invalid Credentials {0}, see logs for more information.",
"AWS.message.credentials.invalid.help": "Get Help...",
"AWS.message.credentials.invalid.logs": "View Logs...",
"AWS.message.enterProfileName": "Enter the name of the credential profile to use",
"AWS.message.info.cloudFormation.delete": "Deleted CloudFormation Stack {0}",
"AWS.message.error.cloudFormation.delete": "An error occurred while deleting CloudFormation Stack {0}. Please check the stack events on the AWS Console",
Expand Down
5 changes: 1 addition & 4 deletions src/awsexplorer/defaultRegion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ const localize = nls.loadMessageBundle()
import * as vscode from 'vscode'
import { AwsContext } from '../shared/awsContext'
import { extensionSettingsPrefix } from '../shared/constants'
import { DefaultCredentialsFileReaderWriter } from '../shared/credentials/defaultCredentialsFileReaderWriter'
import { AwsExplorer } from './awsExplorer'

/**
Expand Down Expand Up @@ -51,9 +50,7 @@ export async function checkExplorerForDefaultRegion(
awsContext: AwsContext,
awsExplorer: AwsExplorer
): Promise<void> {
const credentialReaderWriter = new DefaultCredentialsFileReaderWriter()

const profileRegion = await credentialReaderWriter.getDefaultRegion(profileName)
const profileRegion = awsContext.getCredentialDefaultRegion()
if (!profileRegion) {
return
}
Expand Down
21 changes: 19 additions & 2 deletions src/credentials/activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { profileSettingKey } from '../shared/constants'
import { CredentialsProfileMru } from '../shared/credentials/credentialsProfileMru'
import { SettingsConfiguration } from '../shared/settingsConfiguration'
import { LoginManager } from './loginManager'
import { CredentialsProviderId, fromString } from './providers/credentialsProviderId'
import { SharedCredentialsProvider } from './providers/sharedCredentialsProvider'

export interface CredentialsInitializeParameters {
extensionContext: vscode.ExtensionContext
Expand All @@ -29,9 +31,16 @@ export async function loginWithMostRecentCredentials(
toolkitSettings: SettingsConfiguration,
loginManager: LoginManager
): Promise<void> {
const previousCredentialsId = toolkitSettings.readSetting(profileSettingKey, '')
const previousCredentialsId = toolkitSettings.readSetting<string>(profileSettingKey, '')
if (previousCredentialsId) {
await loginManager.login(previousCredentialsId)
// Migrate from older Toolkits - If the last providerId isn't in the new CredentialProviderId format,
// treat it like a Shared Crdentials Provider.
const loginCredentialsId = tryMakeCredentialsProviderId(previousCredentialsId) ?? {
credentialType: SharedCredentialsProvider.getCredentialsType(),
credentialTypeId: previousCredentialsId
}

await loginManager.login(loginCredentialsId)
} else {
await loginManager.logout()
}
Expand Down Expand Up @@ -68,3 +77,11 @@ function updateConfigurationWhenAwsContextChanges(
})
)
}

function tryMakeCredentialsProviderId(credentialsProviderId: string): CredentialsProviderId | undefined {
try {
return fromString(credentialsProviderId)
} catch (err) {
return undefined
}
}
14 changes: 0 additions & 14 deletions src/credentials/credentialsCreator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,13 @@
import * as nls from 'vscode-nls'
const localize = nls.loadMessageBundle()

import * as AWS from 'aws-sdk'
import * as vscode from 'vscode'

const ERROR_MESSAGE_USER_CANCELLED = localize(
'AWS.error.mfa.userCancelled',
'User cancelled entering authentication code'
)

export async function createCredentials(profileName: string): Promise<AWS.Credentials> {
const provider = new AWS.CredentialProviderChain([
() => new AWS.ProcessCredentials({ profile: profileName }),
() =>
new AWS.SharedIniFileCredentials({
profile: profileName,
tokenCodeFn: async (mfaSerial, callback) => await getMfaTokenFromUser(mfaSerial, profileName, callback)
})
])

return provider.resolvePromise()
}

/**
* @description Prompts user for MFA token
*
Expand Down
45 changes: 27 additions & 18 deletions src/credentials/credentialsStore.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
/*!
* Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* Copyright 2019-2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import * as AWS from 'aws-sdk'
import { CredentialsProvider } from './providers/credentialsProvider'
import { asString, CredentialsProviderId } from './providers/credentialsProviderId'

export interface CachedCredentials {
credentials: AWS.Credentials
credentialsHashCode: string
}

/**
* Simple cache for credentials
*/
export class CredentialsStore {
private readonly credentialsCache: { [key: string]: AWS.Credentials }
private readonly credentialsCache: { [key: string]: CachedCredentials }

public constructor() {
this.credentialsCache = {}
Expand All @@ -18,34 +25,36 @@ export class CredentialsStore {
/**
* Returns undefined if credentials are not stored for given ID
*/
public async getCredentials(credentialsId: string): Promise<AWS.Credentials | undefined> {
return this.credentialsCache[credentialsId]
public async getCredentials(credentialsProviderId: CredentialsProviderId): Promise<CachedCredentials | undefined> {
return this.credentialsCache[asString(credentialsProviderId)]
}

/**
* If credentials are not stored, the provided create function is called. Created credentials are then stored.
* If credentials are not stored, the credentialsProvider is used to produce credentials (which are then stored).
*/
public async getCredentialsOrCreate(
credentialsId: string,
createCredentialsFn: (credentialsId: string) => Promise<AWS.Credentials>
): Promise<AWS.Credentials> {
let credentials = await this.getCredentials(credentialsId)

if (credentials) {
return credentials
public async getOrCreateCredentials(
credentialsProviderId: CredentialsProviderId,
credentialsProvider: CredentialsProvider
): Promise<CachedCredentials> {
let credentials = await this.getCredentials(credentialsProviderId)

if (!credentials) {
credentials = {
credentials: await credentialsProvider.getCredentials(),
credentialsHashCode: credentialsProvider.getHashCode()
}

this.credentialsCache[asString(credentialsProviderId)] = credentials
}

credentials = await createCredentialsFn(credentialsId)
this.credentialsCache[credentialsId] = credentials

return credentials
}

/**
* Evicts credentials from storage
*/
public invalidateCredentials(credentialsId: string) {
public invalidateCredentials(credentialsProviderId: CredentialsProviderId) {
// tslint:disable-next-line:no-dynamic-delete
delete this.credentialsCache[credentialsId]
delete this.credentialsCache[asString(credentialsProviderId)]
}
}
92 changes: 76 additions & 16 deletions src/credentials/loginManager.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
/*!
* Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import * as nls from 'vscode-nls'
const localize = nls.loadMessageBundle()

import * as vscode from 'vscode'
import { AwsContext } from '../shared/awsContext'
import { credentialHelpUrl } from '../shared/constants'
import { getAccountId } from '../shared/credentials/accountId'
import { UserCredentialsUtils } from '../shared/credentials/userCredentialsUtils'
import { getLogger } from '../shared/logger'
import { createCredentials } from './credentialsCreator'
import { CredentialsStore } from './credentialsStore'
import { CredentialsProvider } from './providers/credentialsProvider'
import { asString, CredentialsProviderId } from './providers/credentialsProviderId'
import { CredentialsProviderManager } from './providers/credentialsProviderManager'

export class LoginManager {
private readonly credentialsStore: CredentialsStore = new CredentialsStore()
Expand All @@ -19,33 +25,46 @@ export class LoginManager {
* Establishes a Credentials for the Toolkit to use. Essentially the Toolkit becomes "logged in".
* If an error occurs while trying to set up and verify these credentials, the Toolkit is "logged out".
*/
public async login(credentialsId: string): Promise<void> {
public async login(credentialsProviderId: CredentialsProviderId): Promise<void> {
try {
const credentials = await this.credentialsStore.getCredentialsOrCreate(credentialsId, createCredentials)
if (!credentials) {
throw new Error(`No credentials found for id ${credentialsId}`)
const provider = await CredentialsProviderManager.getInstance().getCredentialsProvider(
credentialsProviderId
)
if (!provider) {
throw new Error(`Could not find Credentials Provider for ${asString(credentialsProviderId)}`)
}

// TODO : Get a region relevant to the partition for these credentials -- https://github.com/aws/aws-toolkit-vscode/issues/188
const accountId = await getAccountId(credentials, 'us-east-1')
await this.updateCredentialsStore(credentialsProviderId, provider)

const storedCredentials = await this.credentialsStore.getCredentials(credentialsProviderId)
if (!storedCredentials) {
throw new Error(`No credentials found for id ${asString(credentialsProviderId)}`)
}

// TODO : Get a region relevant to the partition for these credentials -- https://github.com/aws/aws-toolkit-vscode/issues/188
const accountId = await getAccountId(storedCredentials.credentials, 'us-east-1')
if (!accountId) {
throw new Error('Could not determine Account Id for credentials')
}

await this.awsContext.setCredentials({
credentials: credentials,
credentialsId: credentialsId,
accountId: accountId
credentials: storedCredentials.credentials,
credentialsId: asString(credentialsProviderId),
accountId: accountId,
defaultRegion: provider.getDefaultRegion()
})
} catch (err) {
getLogger().error('Error logging in', err as Error)
this.credentialsStore.invalidateCredentials(credentialsId)
getLogger().error(
`Error trying to connect to AWS with Credentials Provider ${asString(
credentialsProviderId
)}. Toolkit will now disconnect from AWS.`,
err as Error
)
this.credentialsStore.invalidateCredentials(credentialsProviderId)

await this.logout()

// tslint:disable-next-line: no-floating-promises
UserCredentialsUtils.notifyUserCredentialsAreBad(credentialsId)
this.notifyUserInvalidCredentials(credentialsProviderId)
}
}

Expand All @@ -55,4 +74,45 @@ export class LoginManager {
public async logout(): Promise<void> {
await this.awsContext.setCredentials(undefined)
}

/**
* Updates the CredentialsStore if the credentials are considered different
*/
private async updateCredentialsStore(
credentialsProviderId: CredentialsProviderId,
provider: CredentialsProvider
): Promise<void> {
const storedCredentials = await this.credentialsStore.getCredentials(credentialsProviderId)
if (provider.getHashCode() !== storedCredentials?.credentialsHashCode) {
getLogger().verbose(
`Credentials for ${asString(credentialsProviderId)} have changed, using updated credentials.`
)
this.credentialsStore.invalidateCredentials(credentialsProviderId)
}

await this.credentialsStore.getOrCreateCredentials(credentialsProviderId, provider)
}

private notifyUserInvalidCredentials(credentialProviderId: CredentialsProviderId) {
const getHelp = localize('AWS.message.credentials.invalid.help', 'Get Help...')
const viewLogs = localize('AWS.message.credentials.invalid.logs', 'View Logs...')

vscode.window
.showErrorMessage(
localize(
'AWS.message.credentials.invalid',
'Invalid Credentials {0}, see logs for more information.',
asString(credentialProviderId)
),
getHelp,
viewLogs
)
.then((selection: string | undefined) => {
if (selection === getHelp) {
vscode.env.openExternal(vscode.Uri.parse(credentialHelpUrl))
} else if (selection === viewLogs) {
vscode.commands.executeCommand('aws.viewLogs')
}
})
}
}
13 changes: 13 additions & 0 deletions src/credentials/providers/credentialsProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*!
* Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import { CredentialsProviderId } from './credentialsProviderId'

export interface CredentialsProvider {
getCredentialsProviderId(): CredentialsProviderId
getDefaultRegion(): string | undefined
getHashCode(): string
getCredentials(): Promise<AWS.Credentials>
}
51 changes: 51 additions & 0 deletions src/credentials/providers/credentialsProviderFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*!
* Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import { CredentialsProvider } from './credentialsProvider'
import { CredentialsProviderId, isEqual } from './credentialsProviderId'

/**
* Responsible for producing CredentialsProvider objects for a Credential Type
*/
export interface CredentialsProviderFactory {
getCredentialType(): string
listProviders(): CredentialsProvider[]
getProvider(credentialsProviderId: CredentialsProviderId): CredentialsProvider | undefined
refresh(): Promise<void>
}

export abstract class BaseCredentialsProviderFactory<T extends CredentialsProvider>
implements CredentialsProviderFactory {
protected providers: T[] = []
public abstract getCredentialType(): string

public listProviders(): T[] {
return [...this.providers]
}

public getProvider(credentialsProviderId: CredentialsProviderId): CredentialsProvider | undefined {
for (const provider of this.providers) {
if (isEqual(provider.getCredentialsProviderId(), credentialsProviderId)) {
return provider
}
}

return undefined
}

public abstract async refresh(): Promise<void>

protected addProvider(provider: T) {
this.providers.push(provider)
}

protected removeProvider(provider: T) {
this.providers = this.providers.filter(x => x !== provider)
}

protected resetProviders() {
this.providers = []
}
}
Loading

0 comments on commit 86e0604

Please sign in to comment.