Keytar and UIKind Azure Authentication (#9743)

* Handle webmode

* Fix keytar

* Specify that it can be null too.

* Configuraiton option

* Fix callback URL

* Update message, only show if the user is on Linux

* Change message
This commit is contained in:
Amir Omidi
2020-03-26 13:40:44 -07:00
committed by GitHub
parent 2c05b07ee4
commit 5c63419d0d
5 changed files with 155 additions and 55 deletions

View File

@@ -81,6 +81,12 @@
"type": "boolean",
"default": false,
"description": "%config.enableArcFeatures%"
},
"azure.noSystemKeychain": {
"type": "boolean",
"default": false,
"description": "%config.noSystemKeychain%",
"when": "isLinux || isWeb"
}
}
}

View File

@@ -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)"
}

View File

@@ -30,24 +30,43 @@ import { SimpleWebServer } from '../utils/simpleWebServer';
import { SimpleTokenCache } from '../simpleTokenCache';
const localize = nls.loadMessageBundle();
class UriEventHandler extends vscode.EventEmitter<vscode.Uri> 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<void> {
return this.server.shutdown();
}
public async login(): Promise<azdata.Account | azdata.PromptFailedResult> {
let authCompleteDeferred: Deferred<void>;
let authCompletePromise = new Promise<void>((resolve, reject) => authCompleteDeferred = { resolve, reject });
public async loginWithLocalServer(authCompletePromise: Promise<void>): Promise<AuthCodeResponse | undefined> {
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<AuthCodeResponse | undefined> {
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<string> {
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<azdata.Account | azdata.PromptFailedResult> {
let authCompleteDeferred: Deferred<void>;
let authCompletePromise = new Promise<void>((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;

View File

@@ -127,8 +127,9 @@ export class AzureAccountProviderService implements vscode.Disposable {
private async registerAccountProvider(provider: ProviderSettings): Promise<void> {
try {
const noSystemKeychain = vscode.workspace.getConfiguration('azure').get<boolean>('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;

View File

@@ -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<Keytar | undefined> {
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<string> => {
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<string> => {
// 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<string> => {
const decipherIv = crypto.createDecipheriv('aes-256-gcm', keyBuffer, ivBuffer);
// const fileOpener = async (content: string): Promise<string> => {
// 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,7 +120,8 @@ export class SimpleTokenCache {
if (this.forceFileStorage === false) {
keytar = getSystemKeytar();
// Override how findCredentials works
// Add new method to keytar
if (keytar) {
keytar.getPasswords = async (service: string): Promise<MultipleAccountsResponse> => {
const [serviceName, accountPrefix] = service.split(separator);
if (serviceName === undefined || accountPrefix === undefined) {
@@ -132,6 +134,7 @@ export class SimpleTokenCache {
});
};
}
}
if (!keytar) {
keytar = await getFileKeytar(join(this.userStoragePath, this.serviceName), this.credentialService);
}