mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-02-16 18:46:40 -05:00
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:
@@ -81,6 +81,12 @@
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "%config.enableArcFeatures%"
|
||||
},
|
||||
"azure.noSystemKeychain": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "%config.noSystemKeychain%",
|
||||
"when": "isLinux || isWeb"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,18 +120,20 @@ export class SimpleTokenCache {
|
||||
if (this.forceFileStorage === false) {
|
||||
keytar = getSystemKeytar();
|
||||
|
||||
// Override how findCredentials works
|
||||
keytar.getPasswords = async (service: string): Promise<MultipleAccountsResponse> => {
|
||||
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<MultipleAccountsResponse> => {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user