mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-02-17 02:51:36 -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",
|
"type": "boolean",
|
||||||
"default": false,
|
"default": false,
|
||||||
"description": "%config.enableArcFeatures%"
|
"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.azureAuthMethodConfigurationSection": "Azure Authentication Method",
|
||||||
"config.azureCodeGrantMethod": "Code Grant Method",
|
"config.azureCodeGrantMethod": "Code Grant Method",
|
||||||
"config.azureDeviceCodeMethod": "Device Code 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)"
|
"config.enableArcFeatures": "Should features related to Azure Arc be enabled (preview)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,24 +30,43 @@ import { SimpleWebServer } from '../utils/simpleWebServer';
|
|||||||
import { SimpleTokenCache } from '../simpleTokenCache';
|
import { SimpleTokenCache } from '../simpleTokenCache';
|
||||||
const localize = nls.loadMessageBundle();
|
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 {
|
export class AzureAuthCodeGrant extends AzureAuth {
|
||||||
private static readonly USER_FRIENDLY_NAME: string = localize('azure.azureAuthCodeGrantName', "Azure Auth Code Grant");
|
private static readonly USER_FRIENDLY_NAME: string = localize('azure.azureAuthCodeGrantName', "Azure Auth Code Grant");
|
||||||
private server: SimpleWebServer;
|
private server: SimpleWebServer;
|
||||||
|
private readonly _uriHandler: UriEventHandler;
|
||||||
|
|
||||||
constructor(metadata: AzureAccountProviderMetadata,
|
constructor(metadata: AzureAccountProviderMetadata,
|
||||||
tokenCache: SimpleTokenCache,
|
tokenCache: SimpleTokenCache,
|
||||||
context: vscode.ExtensionContext) {
|
context: vscode.ExtensionContext) {
|
||||||
super(metadata, tokenCache, context, AzureAuthType.AuthCodeGrant, AzureAuthCodeGrant.USER_FRIENDLY_NAME);
|
super(metadata, tokenCache, context, AzureAuthType.AuthCodeGrant, AzureAuthCodeGrant.USER_FRIENDLY_NAME);
|
||||||
|
this._uriHandler = new UriEventHandler();
|
||||||
|
vscode.window.registerUriHandler(this._uriHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async autoOAuthCancelled(): Promise<void> {
|
public async autoOAuthCancelled(): Promise<void> {
|
||||||
return this.server.shutdown();
|
return this.server.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async login(): Promise<azdata.Account | azdata.PromptFailedResult> {
|
public async loginWithLocalServer(authCompletePromise: Promise<void>): Promise<AuthCodeResponse | undefined> {
|
||||||
let authCompleteDeferred: Deferred<void>;
|
|
||||||
let authCompletePromise = new Promise<void>((resolve, reject) => authCompleteDeferred = { resolve, reject });
|
|
||||||
|
|
||||||
this.server = new SimpleWebServer();
|
this.server = new SimpleWebServer();
|
||||||
const nonce = crypto.randomBytes(16).toString('base64');
|
const nonce = crypto.randomBytes(16).toString('base64');
|
||||||
let serverPort: string;
|
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.');
|
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);
|
await vscode.window.showErrorMessage(msg);
|
||||||
console.dir(err);
|
console.dir(err);
|
||||||
return { canceled: false } as azdata.PromptFailedResult;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// The login code to use
|
// The login code to use
|
||||||
let loginUrl: string;
|
let loginUrl: string;
|
||||||
let codeVerifier: 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)}`));
|
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 tokenClaims: TokenClaims;
|
||||||
let accessToken: AccessToken;
|
let accessToken: AccessToken;
|
||||||
let refreshToken: RefreshToken;
|
let refreshToken: RefreshToken;
|
||||||
|
|
||||||
try {
|
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;
|
tokenClaims = tc;
|
||||||
accessToken = at;
|
accessToken = at;
|
||||||
refreshToken = rt;
|
refreshToken = rt;
|
||||||
|
|||||||
@@ -127,8 +127,9 @@ export class AzureAccountProviderService implements vscode.Disposable {
|
|||||||
|
|
||||||
private async registerAccountProvider(provider: ProviderSettings): Promise<void> {
|
private async registerAccountProvider(provider: ProviderSettings): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const noSystemKeychain = vscode.workspace.getConfiguration('azure').get<boolean>('noSystemKeychain');
|
||||||
let tokenCacheKey = `azureTokenCache-${provider.metadata.id}`;
|
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();
|
await simpleTokenCache.init();
|
||||||
let accountProvider = new AzureAccountProvider(provider.metadata as AzureAccountProviderMetadata, simpleTokenCache, this._context);
|
let accountProvider = new AzureAccountProvider(provider.metadata as AzureAccountProviderMetadata, simpleTokenCache, this._context);
|
||||||
this._accountProviders[provider.metadata.id] = accountProvider;
|
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.
|
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||||
*--------------------------------------------------------------------------------------------*/
|
*--------------------------------------------------------------------------------------------*/
|
||||||
import * as keytarType from 'keytar';
|
import * as keytarType from 'keytar';
|
||||||
import { join, parse } from 'path';
|
import { join } from 'path';
|
||||||
import { FileDatabase } from './utils/fileDatabase';
|
import { FileDatabase } from './utils/fileDatabase';
|
||||||
import * as crypto from 'crypto';
|
|
||||||
import * as azdata from 'azdata';
|
import * as azdata from 'azdata';
|
||||||
|
|
||||||
function getSystemKeytar(): Keytar | undefined {
|
function getSystemKeytar(): Keytar | undefined | null {
|
||||||
try {
|
try {
|
||||||
return require('keytar');
|
return require('keytar');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -23,43 +22,45 @@ export type MultipleAccountsResponse = { account: string, password: string }[];
|
|||||||
const separator = '§';
|
const separator = '§';
|
||||||
|
|
||||||
async function getFileKeytar(filePath: string, credentialService: azdata.CredentialProvider): Promise<Keytar | undefined> {
|
async function getFileKeytar(filePath: string, credentialService: azdata.CredentialProvider): Promise<Keytar | undefined> {
|
||||||
const fileName = parse(filePath).base;
|
// Comment alias: amomidi, PR: 9743 March 26th 2020
|
||||||
const iv = await credentialService.readCredential(`${fileName}-iv`);
|
// const fileName = parse(filePath).base;
|
||||||
const key = await credentialService.readCredential(`${fileName}-key`);
|
// const iv = await credentialService.readCredential(`${fileName}-iv`);
|
||||||
let ivBuffer: Buffer;
|
// const key = await credentialService.readCredential(`${fileName}-key`);
|
||||||
let keyBuffer: Buffer;
|
// let ivBuffer: Buffer;
|
||||||
if (!iv?.password || !key?.password) {
|
// let keyBuffer: Buffer;
|
||||||
ivBuffer = crypto.randomBytes(16);
|
// if (!iv?.password || !key?.password) {
|
||||||
keyBuffer = crypto.randomBytes(32);
|
// ivBuffer = crypto.randomBytes(16);
|
||||||
try {
|
// keyBuffer = crypto.randomBytes(32);
|
||||||
await credentialService.saveCredential(`${fileName}-iv`, ivBuffer.toString('hex'));
|
// try {
|
||||||
await credentialService.saveCredential(`${fileName}-key`, keyBuffer.toString('hex'));
|
// await credentialService.saveCredential(`${fileName}-iv`, ivBuffer.toString('hex'));
|
||||||
} catch (ex) {
|
// await credentialService.saveCredential(`${fileName}-key`, keyBuffer.toString('hex'));
|
||||||
console.log(ex);
|
// } catch (ex) {
|
||||||
}
|
// console.log(ex);
|
||||||
} else {
|
// }
|
||||||
ivBuffer = Buffer.from(iv.password, 'hex');
|
// } else {
|
||||||
keyBuffer = Buffer.from(key.password, 'hex');
|
// ivBuffer = Buffer.from(iv.password, 'hex');
|
||||||
}
|
// keyBuffer = Buffer.from(key.password, 'hex');
|
||||||
|
// }
|
||||||
|
|
||||||
const fileSaver = async (content: string): Promise<string> => {
|
// const fileSaver = async (content: string): Promise<string> => {
|
||||||
const cipherIv = crypto.createCipheriv('aes-256-gcm', keyBuffer, ivBuffer);
|
// const cipherIv = crypto.createCipheriv('aes-256-gcm', keyBuffer, ivBuffer);
|
||||||
return `${cipherIv.update(content, 'utf8', 'hex')}${cipherIv.final('hex')}%${cipherIv.getAuthTag().toString('hex')}`;
|
// return `${cipherIv.update(content, 'utf8', 'hex')}${cipherIv.final('hex')}%${cipherIv.getAuthTag().toString('hex')}`;
|
||||||
};
|
// };
|
||||||
|
|
||||||
const fileOpener = async (content: string): Promise<string> => {
|
// const fileOpener = async (content: string): Promise<string> => {
|
||||||
const decipherIv = crypto.createDecipheriv('aes-256-gcm', keyBuffer, ivBuffer);
|
// const decipherIv = crypto.createDecipheriv('aes-256-gcm', keyBuffer, ivBuffer);
|
||||||
|
|
||||||
const split = content.split('%');
|
// const split = content.split('%');
|
||||||
if (split.length !== 2) {
|
// if (split.length !== 2) {
|
||||||
throw new Error('File didn\'t contain the auth tag.');
|
// throw new Error('File didn\'t contain the auth tag.');
|
||||||
}
|
// }
|
||||||
decipherIv.setAuthTag(Buffer.from(split[1], 'hex'));
|
// 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();
|
await db.initialize();
|
||||||
|
|
||||||
const fileKeytar: Keytar = {
|
const fileKeytar: Keytar = {
|
||||||
@@ -119,18 +120,20 @@ export class SimpleTokenCache {
|
|||||||
if (this.forceFileStorage === false) {
|
if (this.forceFileStorage === false) {
|
||||||
keytar = getSystemKeytar();
|
keytar = getSystemKeytar();
|
||||||
|
|
||||||
// Override how findCredentials works
|
// Add new method to keytar
|
||||||
keytar.getPasswords = async (service: string): Promise<MultipleAccountsResponse> => {
|
if (keytar) {
|
||||||
const [serviceName, accountPrefix] = service.split(separator);
|
keytar.getPasswords = async (service: string): Promise<MultipleAccountsResponse> => {
|
||||||
if (serviceName === undefined || accountPrefix === undefined) {
|
const [serviceName, accountPrefix] = service.split(separator);
|
||||||
throw new Error('Service did not have seperator: ' + service);
|
if (serviceName === undefined || accountPrefix === undefined) {
|
||||||
}
|
throw new Error('Service did not have seperator: ' + service);
|
||||||
|
}
|
||||||
|
|
||||||
const results = await keytar.findCredentials(serviceName);
|
const results = await keytar.findCredentials(serviceName);
|
||||||
return results.filter(({ account }) => {
|
return results.filter(({ account }) => {
|
||||||
return account.startsWith(accountPrefix);
|
return account.startsWith(accountPrefix);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!keytar) {
|
if (!keytar) {
|
||||||
keytar = await getFileKeytar(join(this.userStoragePath, this.serviceName), this.credentialService);
|
keytar = await getFileKeytar(join(this.userStoragePath, this.serviceName), this.credentialService);
|
||||||
|
|||||||
Reference in New Issue
Block a user