diff --git a/extensions/azurecore/package.json b/extensions/azurecore/package.json index 10e8337030..b793d4fe69 100644 --- a/extensions/azurecore/package.json +++ b/extensions/azurecore/package.json @@ -81,6 +81,12 @@ "type": "boolean", "default": false, "description": "%config.enableArcFeatures%" + }, + "azure.noSystemKeychain": { + "type": "boolean", + "default": false, + "description": "%config.noSystemKeychain%", + "when": "isLinux || isWeb" } } } diff --git a/extensions/azurecore/package.nls.json b/extensions/azurecore/package.nls.json index 91e99999ea..13e57263d0 100644 --- a/extensions/azurecore/package.nls.json +++ b/extensions/azurecore/package.nls.json @@ -25,5 +25,6 @@ "config.azureAuthMethodConfigurationSection": "Azure Authentication Method", "config.azureCodeGrantMethod": "Code Grant Method", "config.azureDeviceCodeMethod": "Device Code Method", + "config.noSystemKeychain": "Disable system keychain integration. Credentials will be stored in a flat file in the user's home directory.", "config.enableArcFeatures": "Should features related to Azure Arc be enabled (preview)" } diff --git a/extensions/azurecore/src/account-provider/auths/azureAuthCodeGrant.ts b/extensions/azurecore/src/account-provider/auths/azureAuthCodeGrant.ts index 032e9264b0..2eabce3cd8 100644 --- a/extensions/azurecore/src/account-provider/auths/azureAuthCodeGrant.ts +++ b/extensions/azurecore/src/account-provider/auths/azureAuthCodeGrant.ts @@ -30,24 +30,43 @@ import { SimpleWebServer } from '../utils/simpleWebServer'; import { SimpleTokenCache } from '../simpleTokenCache'; const localize = nls.loadMessageBundle(); +class UriEventHandler extends vscode.EventEmitter implements vscode.UriHandler { + public handleUri(uri: vscode.Uri) { + this.fire(uri); + } +} + +function parseQuery(uri: vscode.Uri) { + return uri.query.split('&').reduce((prev: any, current) => { + const queryString = current.split('='); + prev[queryString[0]] = queryString[1]; + return prev; + }, {}); +} + +interface AuthCodeResponse { + authCode: string, + codeVerifier: string +} + export class AzureAuthCodeGrant extends AzureAuth { private static readonly USER_FRIENDLY_NAME: string = localize('azure.azureAuthCodeGrantName', "Azure Auth Code Grant"); private server: SimpleWebServer; + private readonly _uriHandler: UriEventHandler; constructor(metadata: AzureAccountProviderMetadata, tokenCache: SimpleTokenCache, context: vscode.ExtensionContext) { super(metadata, tokenCache, context, AzureAuthType.AuthCodeGrant, AzureAuthCodeGrant.USER_FRIENDLY_NAME); + this._uriHandler = new UriEventHandler(); + vscode.window.registerUriHandler(this._uriHandler); } public async autoOAuthCancelled(): Promise { return this.server.shutdown(); } - public async login(): Promise { - let authCompleteDeferred: Deferred; - let authCompletePromise = new Promise((resolve, reject) => authCompleteDeferred = { resolve, reject }); - + public async loginWithLocalServer(authCompletePromise: Promise): Promise { this.server = new SimpleWebServer(); const nonce = crypto.randomBytes(16).toString('base64'); let serverPort: string; @@ -58,10 +77,9 @@ export class AzureAuthCodeGrant extends AzureAuth { const msg = localize('azure.serverCouldNotStart', 'Server could not start. This could be a permissions error or an incompatibility on your system. You can try enabling device code authentication from settings.'); await vscode.window.showErrorMessage(msg); console.dir(err); - return { canceled: false } as azdata.PromptFailedResult; + return undefined; } - // The login code to use let loginUrl: string; let codeVerifier: string; @@ -85,14 +103,85 @@ export class AzureAuthCodeGrant extends AzureAuth { await vscode.env.openExternal(vscode.Uri.parse(`http://localhost:${serverPort}/signin?nonce=${encodeURIComponent(nonce)}`)); - const authenticatedCode = await this.addServerListeners(this.server, nonce, loginUrl, authCompletePromise); + const authCode = await this.addServerListeners(this.server, nonce, loginUrl, authCompletePromise); + + return { + authCode, + codeVerifier + }; + } + + public async loginWithoutLocalServer(): Promise { + const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://microsoft.azurecore`)); + const nonce = crypto.randomBytes(16).toString('base64'); + const port = (callbackUri.authority.match(/:([0-9]*)$/) || [])[1] || (callbackUri.scheme === 'https' ? 443 : 80); + const state = `${port},${encodeURIComponent(nonce)},${encodeURIComponent(callbackUri.query)}`; + + const codeVerifier = this.toBase64UrlEncoding(crypto.randomBytes(32).toString('base64')); + const codeChallenge = this.toBase64UrlEncoding(crypto.createHash('sha256').update(codeVerifier).digest('base64')); + + const loginQuery = { + response_type: 'code', + response_mode: 'query', + client_id: this.clientId, + redirect_uri: this.redirectUri, + state, + prompt: 'select_account', + code_challenge_method: 'S256', + code_challenge: codeChallenge, + resource: this.metadata.settings.signInResourceId + }; + + const signInUrl = `${this.loginEndpointUrl}${this.commonTenant}/oauth2/authorize?${qs.stringify(loginQuery)}`; + await vscode.env.openExternal(vscode.Uri.parse(signInUrl)); + + const authCode = await this.handleCodeResponse(state); + + return { + authCode, + codeVerifier + }; + } + + public async handleCodeResponse(state: string): Promise { + let uriEventListener: vscode.Disposable; + return new Promise((resolve: (value: any) => void, reject) => { + uriEventListener = this._uriHandler.event(async (uri: vscode.Uri) => { + try { + const query = parseQuery(uri); + const code = query.code; + if (query.state !== state && decodeURIComponent(query.state) !== state) { + reject(new Error('State mismatch')); + return; + } + resolve(code); + } catch (err) { + reject(err); + } + }); + }).finally(() => { + uriEventListener.dispose(); + }); + } + + public async login(): Promise { + + let authCompleteDeferred: Deferred; + let authCompletePromise = new Promise((resolve, reject) => authCompleteDeferred = { resolve, reject }); + + let authResponse: AuthCodeResponse; + if (vscode.env.uiKind === vscode.UIKind.Web) { + authResponse = await this.loginWithoutLocalServer(); + } else { + authResponse = await this.loginWithLocalServer(authCompletePromise); + } let tokenClaims: TokenClaims; let accessToken: AccessToken; let refreshToken: RefreshToken; try { - const { accessToken: at, refreshToken: rt, tokenClaims: tc } = await this.getTokenWithAuthCode(authenticatedCode, codeVerifier, this.redirectUri); + const { accessToken: at, refreshToken: rt, tokenClaims: tc } = await this.getTokenWithAuthCode(authResponse.authCode, authResponse.codeVerifier, this.redirectUri); tokenClaims = tc; accessToken = at; refreshToken = rt; diff --git a/extensions/azurecore/src/account-provider/azureAccountProviderService.ts b/extensions/azurecore/src/account-provider/azureAccountProviderService.ts index 49e754b304..22a0f80041 100644 --- a/extensions/azurecore/src/account-provider/azureAccountProviderService.ts +++ b/extensions/azurecore/src/account-provider/azureAccountProviderService.ts @@ -127,8 +127,9 @@ export class AzureAccountProviderService implements vscode.Disposable { private async registerAccountProvider(provider: ProviderSettings): Promise { try { + const noSystemKeychain = vscode.workspace.getConfiguration('azure').get('noSystemKeychain'); let tokenCacheKey = `azureTokenCache-${provider.metadata.id}`; - let simpleTokenCache = new SimpleTokenCache(tokenCacheKey, this._userStoragePath, false, this._credentialProvider); + let simpleTokenCache = new SimpleTokenCache(tokenCacheKey, this._userStoragePath, noSystemKeychain, this._credentialProvider); await simpleTokenCache.init(); let accountProvider = new AzureAccountProvider(provider.metadata as AzureAccountProviderMetadata, simpleTokenCache, this._context); this._accountProviders[provider.metadata.id] = accountProvider; diff --git a/extensions/azurecore/src/account-provider/simpleTokenCache.ts b/extensions/azurecore/src/account-provider/simpleTokenCache.ts index f9a6e5077e..2547360af0 100644 --- a/extensions/azurecore/src/account-provider/simpleTokenCache.ts +++ b/extensions/azurecore/src/account-provider/simpleTokenCache.ts @@ -3,12 +3,11 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as keytarType from 'keytar'; -import { join, parse } from 'path'; +import { join } from 'path'; import { FileDatabase } from './utils/fileDatabase'; -import * as crypto from 'crypto'; import * as azdata from 'azdata'; -function getSystemKeytar(): Keytar | undefined { +function getSystemKeytar(): Keytar | undefined | null { try { return require('keytar'); } catch (err) { @@ -23,43 +22,45 @@ export type MultipleAccountsResponse = { account: string, password: string }[]; const separator = 'ยง'; async function getFileKeytar(filePath: string, credentialService: azdata.CredentialProvider): Promise { - const fileName = parse(filePath).base; - const iv = await credentialService.readCredential(`${fileName}-iv`); - const key = await credentialService.readCredential(`${fileName}-key`); - let ivBuffer: Buffer; - let keyBuffer: Buffer; - if (!iv?.password || !key?.password) { - ivBuffer = crypto.randomBytes(16); - keyBuffer = crypto.randomBytes(32); - try { - await credentialService.saveCredential(`${fileName}-iv`, ivBuffer.toString('hex')); - await credentialService.saveCredential(`${fileName}-key`, keyBuffer.toString('hex')); - } catch (ex) { - console.log(ex); - } - } else { - ivBuffer = Buffer.from(iv.password, 'hex'); - keyBuffer = Buffer.from(key.password, 'hex'); - } + // Comment alias: amomidi, PR: 9743 March 26th 2020 + // const fileName = parse(filePath).base; + // const iv = await credentialService.readCredential(`${fileName}-iv`); + // const key = await credentialService.readCredential(`${fileName}-key`); + // let ivBuffer: Buffer; + // let keyBuffer: Buffer; + // if (!iv?.password || !key?.password) { + // ivBuffer = crypto.randomBytes(16); + // keyBuffer = crypto.randomBytes(32); + // try { + // await credentialService.saveCredential(`${fileName}-iv`, ivBuffer.toString('hex')); + // await credentialService.saveCredential(`${fileName}-key`, keyBuffer.toString('hex')); + // } catch (ex) { + // console.log(ex); + // } + // } else { + // ivBuffer = Buffer.from(iv.password, 'hex'); + // keyBuffer = Buffer.from(key.password, 'hex'); + // } - const fileSaver = async (content: string): Promise => { - const cipherIv = crypto.createCipheriv('aes-256-gcm', keyBuffer, ivBuffer); - return `${cipherIv.update(content, 'utf8', 'hex')}${cipherIv.final('hex')}%${cipherIv.getAuthTag().toString('hex')}`; - }; + // const fileSaver = async (content: string): Promise => { + // const cipherIv = crypto.createCipheriv('aes-256-gcm', keyBuffer, ivBuffer); + // return `${cipherIv.update(content, 'utf8', 'hex')}${cipherIv.final('hex')}%${cipherIv.getAuthTag().toString('hex')}`; + // }; - const fileOpener = async (content: string): Promise => { - const decipherIv = crypto.createDecipheriv('aes-256-gcm', keyBuffer, ivBuffer); + // const fileOpener = async (content: string): Promise => { + // const decipherIv = crypto.createDecipheriv('aes-256-gcm', keyBuffer, ivBuffer); - const split = content.split('%'); - if (split.length !== 2) { - throw new Error('File didn\'t contain the auth tag.'); - } - decipherIv.setAuthTag(Buffer.from(split[1], 'hex')); + // const split = content.split('%'); + // if (split.length !== 2) { + // throw new Error('File didn\'t contain the auth tag.'); + // } + // decipherIv.setAuthTag(Buffer.from(split[1], 'hex')); - return `${decipherIv.update(split[0], 'hex', 'utf8')}${decipherIv.final('utf8')}`; - }; + // return `${decipherIv.update(split[0], 'hex', 'utf8')}${decipherIv.final('utf8')}`; + // }; - const db = new FileDatabase(filePath, fileOpener, fileSaver); + // const db = new FileDatabase(filePath, fileOpener, fileSaver); + const db = new FileDatabase(filePath); await db.initialize(); const fileKeytar: Keytar = { @@ -119,18 +120,20 @@ export class SimpleTokenCache { if (this.forceFileStorage === false) { keytar = getSystemKeytar(); - // Override how findCredentials works - keytar.getPasswords = async (service: string): Promise => { - const [serviceName, accountPrefix] = service.split(separator); - if (serviceName === undefined || accountPrefix === undefined) { - throw new Error('Service did not have seperator: ' + service); - } + // Add new method to keytar + if (keytar) { + keytar.getPasswords = async (service: string): Promise => { + const [serviceName, accountPrefix] = service.split(separator); + if (serviceName === undefined || accountPrefix === undefined) { + throw new Error('Service did not have seperator: ' + service); + } - const results = await keytar.findCredentials(serviceName); - return results.filter(({ account }) => { - return account.startsWith(accountPrefix); - }); - }; + const results = await keytar.findCredentials(serviceName); + return results.filter(({ account }) => { + return account.startsWith(accountPrefix); + }); + }; + } } if (!keytar) { keytar = await getFileKeytar(join(this.userStoragePath, this.serviceName), this.credentialService);