Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Credentials Management #888

Merged
merged 38 commits into from
Jan 13, 2020
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
409af50
Initial implementation of Credential Provider
awschristou Jan 4, 2020
51c094f
Remove getCredentialsFilename and getConfigFilename from UserCredenti…
awschristou Jan 4, 2020
74ae1c3
BaseCredentialsProviderFactory tests
awschristou Jan 6, 2020
1989d51
Profile Validation now returns text instead of throwing Error
awschristou Jan 6, 2020
aadf828
getStringHash utility method
awschristou Jan 6, 2020
cdfb8a9
Renamed types, produce Credentials, internalize CredentialProviderChain
awschristou Jan 6, 2020
102655c
Pull Default Region from Shared Credentials Profile
awschristou Jan 6, 2020
b7b6445
Get Profile HashCode
awschristou Jan 7, 2020
5ed5f73
When logging in, if credentials are already cached but a change was d…
awschristou Jan 7, 2020
9fd898f
Remove comment
awschristou Jan 7, 2020
6a83b2e
Updated the 'invalid credentials' notice, and added a "View Logs" but…
awschristou Jan 7, 2020
b5abf17
CredentialsProviderId tests
awschristou Jan 7, 2020
2ee351e
CredentialsProviderManager tests
awschristou Jan 8, 2020
dcb2d4c
SharedCredentialsProvider tests
awschristou Jan 8, 2020
f14b125
SharedCredentialsProviderFactory tests
awschristou Jan 8, 2020
f47fad3
Fix envvar side-effects that were affecting other tests
awschristou Jan 8, 2020
29c760f
Lint fix
awschristou Jan 8, 2020
ef8e981
Merge branch 'master' into awschristou/credentials
awschristou Jan 8, 2020
2440bbf
Changelogs
awschristou Jan 8, 2020
004d6b9
Migration - prevent first startup using the new Credentials Providers…
awschristou Jan 8, 2020
7380cce
Test fix
awschristou Jan 8, 2020
c848df8
Merge branch 'master' into awschristou/credentials
awschristou Jan 8, 2020
23f6962
Feedback - changelogs
awschristou Jan 8, 2020
c34820f
Feedback - label
awschristou Jan 8, 2020
150f199
Feedback - Credentials Type for Shared Credentials
awschristou Jan 8, 2020
4094ee4
Feedback - CredentialsProviderManager instance
awschristou Jan 9, 2020
4a0e321
Feedback - label
awschristou Jan 9, 2020
676ae7b
Merge branch 'master' into awschristou/credentials
awschristou Jan 9, 2020
3a7afa6
Shared Credentials Profiles without recognized properties are conside…
awschristou Jan 9, 2020
f7491f3
Feedback - legible code
awschristou Jan 9, 2020
7712177
Feedback - consistent use of Millis
awschristou Jan 9, 2020
5025c19
Changed CredentialsProviderId separator from : to |
awschristou Jan 9, 2020
6f33ac4
CredentialsStore now stores and retrieves Credentials and associated …
awschristou Jan 9, 2020
bc32e0d
Feedback - blank line
awschristou Jan 9, 2020
b262bc7
Feedback - immutable during activation login migration
awschristou Jan 9, 2020
6c76476
CredentialsProviderId is now a type
awschristou Jan 10, 2020
5f38544
Switched string hash utility from hand-rolled to sha256
awschristou Jan 13, 2020
ab3807e
Feedback - concat over spread operator
awschristou Jan 13, 2020
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
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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this something that should be attached to the credential itself rather than a top-level on awsContext

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

awsContext contains an AwsContextCredentials object, which then backs these calls. Would it make more sense to have a single getter for that object instead of getters for each property?

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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get that this will make it more seamless for a user but I don't care to have this lying around if it's solely for that purpose. I'd say just invalidate the existing credentials and have the user log back in...it's fast enough that it shouldn't be an issue. At most, give them a one-version grace period and mark this for deletion in the next release afterwards.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be removed after a few versions. It makes for a better experience than seeing an "invalid credentials" notification, or a phantom log-out. It's low cost, the comment explains what is going on, and git history will tell us (if necessary) when this was added.

// 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: number
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this for?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a hash of the contents that were used to produce the credentials.
Its purpose is to detect when things like Shared Credentials Profiles change during a toolkit session.

}

/**
* 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.
bryceitoc9 marked this conversation as resolved.
Show resolved Hide resolved
* 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(): number
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[] = []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is an array / list really the right data-structure here? We seem to be doing lookup-style operations mostly that point to a dictionary / map being a better choice.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The top access is a mix of "list all providers" and "get provider x" (the doc is currently open in #889 ). Collection still seems reasonable to me -- the lookups don't seem like they would have a heavy performance.

public abstract getCredentialType(): string

public listProviders(): T[] {
return [...this.providers]
hunterwerlla marked this conversation as resolved.
Show resolved Hide resolved
}

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