mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-08 09:08:48 +01:00
Improvements to 'manage trusted extensions' flow
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import * as uuid from 'uuid';
|
||||
import { keychain } from './common/keychain';
|
||||
import { GitHubServer } from './githubServer';
|
||||
import Logger from './common/logger';
|
||||
@@ -122,7 +123,7 @@ export class GitHubAuthenticationProvider {
|
||||
private async tokenToSession(token: string, scopes: string[]): Promise<vscode.AuthenticationSession> {
|
||||
const userInfo = await this._githubServer.getUserInfo(token);
|
||||
return {
|
||||
id: userInfo.id,
|
||||
id: uuid(),
|
||||
getAccessToken: () => Promise.resolve(token),
|
||||
accountName: userInfo.accountName,
|
||||
scopes: scopes
|
||||
|
||||
@@ -51,9 +51,11 @@
|
||||
"typescript": "^3.7.4",
|
||||
"tslint": "^5.12.1",
|
||||
"@types/node": "^10.12.21",
|
||||
"@types/keytar": "^4.0.1"
|
||||
"@types/keytar": "^4.0.1",
|
||||
"@types/uuid": "^3.4.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"uuid": "^3.3.3",
|
||||
"vscode-nls": "^4.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as crypto from 'crypto';
|
||||
import * as https from 'https';
|
||||
import * as querystring from 'querystring';
|
||||
import * as vscode from 'vscode';
|
||||
import * as uuid from 'uuid';
|
||||
import { createServer, startServer } from './authServer';
|
||||
import { keychain } from './keychain';
|
||||
import Logger from './logger';
|
||||
@@ -81,7 +82,7 @@ export class AzureActiveDirectoryService {
|
||||
const sessions = this.parseStoredData(storedData);
|
||||
const refreshes = sessions.map(async session => {
|
||||
try {
|
||||
await this.refreshToken(session.refreshToken, session.scope);
|
||||
await this.refreshToken(session.refreshToken, session.scope, session.id);
|
||||
} catch (e) {
|
||||
if (e.message === REFRESH_NETWORK_FAILURE) {
|
||||
const didSucceedOnRetry = await this.handleRefreshNetworkError(session.id, session.refreshToken, session.scope);
|
||||
@@ -140,7 +141,7 @@ export class AzureActiveDirectoryService {
|
||||
const matchesExisting = this._tokens.some(token => token.scope === session.scope && token.sessionId === session.id);
|
||||
if (!matchesExisting) {
|
||||
try {
|
||||
await this.refreshToken(session.refreshToken, session.scope);
|
||||
await this.refreshToken(session.refreshToken, session.scope, session.id);
|
||||
addedIds.push(session.id);
|
||||
} catch (e) {
|
||||
if (e.message === REFRESH_NETWORK_FAILURE) {
|
||||
@@ -210,7 +211,7 @@ export class AzureActiveDirectoryService {
|
||||
|
||||
try {
|
||||
Logger.info('Token expired or unavailable, trying refresh');
|
||||
const refreshedToken = await this.refreshToken(token.refreshToken, token.scope);
|
||||
const refreshedToken = await this.refreshToken(token.refreshToken, token.scope, token.sessionId);
|
||||
if (refreshedToken.accessToken) {
|
||||
return refreshedToken.accessToken;
|
||||
} else {
|
||||
@@ -386,7 +387,7 @@ export class AzureActiveDirectoryService {
|
||||
if (token.expiresIn) {
|
||||
this._refreshTimeouts.set(token.sessionId, setTimeout(async () => {
|
||||
try {
|
||||
await this.refreshToken(token.refreshToken, scope);
|
||||
await this.refreshToken(token.refreshToken, scope, token.sessionId);
|
||||
onDidChangeSessions.fire({ added: [], removed: [], changed: [token.sessionId] });
|
||||
} catch (e) {
|
||||
if (e.message === REFRESH_NETWORK_FAILURE) {
|
||||
@@ -405,7 +406,7 @@ export class AzureActiveDirectoryService {
|
||||
this.storeTokenData();
|
||||
}
|
||||
|
||||
private getTokenFromResponse(buffer: Buffer[], scope: string): IToken {
|
||||
private getTokenFromResponse(buffer: Buffer[], scope: string, existingId?: string): IToken {
|
||||
const json = JSON.parse(Buffer.concat(buffer).toString());
|
||||
const claims = this.getTokenClaims(json.access_token);
|
||||
return {
|
||||
@@ -414,7 +415,7 @@ export class AzureActiveDirectoryService {
|
||||
accessToken: json.access_token,
|
||||
refreshToken: json.refresh_token,
|
||||
scope,
|
||||
sessionId: `${claims.tid}/${(claims.oid || (claims.altsecid || '' + claims.ipd || ''))}/${scope}`,
|
||||
sessionId: existingId || `${claims.tid}/${(claims.oid || (claims.altsecid || '' + claims.ipd || ''))}/${uuid()}`,
|
||||
accountName: claims.email || claims.unique_name || 'user@example.com'
|
||||
};
|
||||
}
|
||||
@@ -472,7 +473,7 @@ export class AzureActiveDirectoryService {
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshToken(refreshToken: string, scope: string): Promise<IToken> {
|
||||
private async refreshToken(refreshToken: string, scope: string, sessionId: string): Promise<IToken> {
|
||||
return new Promise((resolve: (value: IToken) => void, reject) => {
|
||||
Logger.info('Refreshing token...');
|
||||
const postData = querystring.stringify({
|
||||
@@ -497,7 +498,7 @@ export class AzureActiveDirectoryService {
|
||||
});
|
||||
result.on('end', async () => {
|
||||
if (result.statusCode === 200) {
|
||||
const token = this.getTokenFromResponse(buffer, scope);
|
||||
const token = this.getTokenFromResponse(buffer, scope, sessionId);
|
||||
this.setToken(token, scope);
|
||||
Logger.info('Token refresh success');
|
||||
resolve(token);
|
||||
@@ -540,7 +541,7 @@ export class AzureActiveDirectoryService {
|
||||
|
||||
this._refreshTimeouts.set(sessionId, setTimeout(async () => {
|
||||
try {
|
||||
await this.refreshToken(refreshToken, scope);
|
||||
await this.refreshToken(refreshToken, scope, sessionId);
|
||||
} catch (e) {
|
||||
this.pollForReconnect(sessionId, refreshToken, scope);
|
||||
}
|
||||
@@ -568,7 +569,7 @@ export class AzureActiveDirectoryService {
|
||||
|
||||
this._refreshTimeouts.set(sessionId, setTimeout(async () => {
|
||||
try {
|
||||
await this.refreshToken(refreshToken, scope);
|
||||
await this.refreshToken(refreshToken, scope, sessionId);
|
||||
return resolve(true);
|
||||
} catch (e) {
|
||||
return resolve(await this.handleRefreshNetworkError(sessionId, refreshToken, scope, attempts + 1));
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.13.tgz#ccebcdb990bd6139cd16e84c39dc2fb1023ca90c"
|
||||
integrity sha512-pMCcqU2zT4TjqYFrWtYHKal7Sl30Ims6ulZ4UFXxI4xbtQqK/qqKwkDoBFCfooRqqmRu9vY3xaJRwxSh673aYg==
|
||||
|
||||
"@types/uuid@^3.4.6":
|
||||
version "3.4.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.8.tgz#4ba887fcef88bd9a7515ca2de336d691e3e18318"
|
||||
integrity sha512-zHWce3allXWSmRx6/AGXKCtSOA7JjeWd2L3t4aHfysNk8mouQnWCocveaT7a4IEIlPVHp81jzlnknqTgCjCLXA==
|
||||
|
||||
ansi-regex@^2.0.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
|
||||
@@ -635,6 +640,11 @@ util-deprecate@^1.0.1, util-deprecate@~1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
|
||||
|
||||
uuid@^3.3.3:
|
||||
version "3.4.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
|
||||
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
|
||||
|
||||
vscode-nls@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-4.1.1.tgz#f9916b64e4947b20322defb1e676a495861f133c"
|
||||
|
||||
@@ -35,6 +35,7 @@ const BUILT_IN_AUTH_DEPENDENTS: AuthDependent[] = [
|
||||
interface AllowedExtension {
|
||||
id: string;
|
||||
name: string;
|
||||
sessionIds?: string[];
|
||||
}
|
||||
|
||||
function readAllowedExtensions(storageService: IStorageService, providerId: string, accountName: string): AllowedExtension[] {
|
||||
@@ -85,6 +86,16 @@ export class MainThreadAuthenticationProvider extends Disposable {
|
||||
quickPick.onDidAccept(() => {
|
||||
const updatedAllowedList = quickPick.selectedItems.map(item => item.extension);
|
||||
storageService.store(`${this.id}-${accountName}`, JSON.stringify(updatedAllowedList), StorageScope.GLOBAL);
|
||||
|
||||
// Remove sessions of untrusted extensions
|
||||
const deselectedItems = items.filter(item => !quickPick.selectedItems.includes(item));
|
||||
deselectedItems.forEach(item => {
|
||||
const extensionData = allowedExtensions.find(extension => item.extension.id === extension.id);
|
||||
extensionData?.sessionIds?.forEach(sessionId => {
|
||||
this.logout(sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
quickPick.dispose();
|
||||
});
|
||||
|
||||
@@ -275,9 +286,19 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
|
||||
this.authenticationService.sessionsUpdate(id, event);
|
||||
}
|
||||
|
||||
async $getSessionsPrompt(providerId: string, accountName: string, providerName: string, extensionId: string, extensionName: string): Promise<boolean> {
|
||||
let allowList = readAllowedExtensions(this.storageService, providerId, accountName);
|
||||
if (allowList.some(extension => extension.id === extensionId)) {
|
||||
async $getSessionsPrompt(providerId: string, accountName: string, sessionId: string, providerName: string, extensionId: string, extensionName: string): Promise<boolean> {
|
||||
const allowList = readAllowedExtensions(this.storageService, providerId, accountName);
|
||||
const extensionData = allowList.find(extension => extension.id === extensionId);
|
||||
if (extensionData) {
|
||||
if (!extensionData.sessionIds) {
|
||||
extensionData.sessionIds = [];
|
||||
}
|
||||
|
||||
if (!extensionData.sessionIds.find(id => id === sessionId)) {
|
||||
extensionData.sessionIds.push(sessionId);
|
||||
this.storageService.store(`${providerId}-${accountName}`, JSON.stringify(allowList), StorageScope.GLOBAL);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -292,7 +313,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
|
||||
|
||||
const allow = choice === 1;
|
||||
if (allow) {
|
||||
allowList = allowList.concat({ id: extensionId, name: extensionName });
|
||||
allowList.push({ id: extensionId, name: extensionName, sessionIds: [sessionId] });
|
||||
this.storageService.store(`${providerId}-${accountName}`, JSON.stringify(allowList), StorageScope.GLOBAL);
|
||||
}
|
||||
|
||||
@@ -313,7 +334,10 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
|
||||
}
|
||||
|
||||
async $setTrustedExtension(providerId: string, accountName: string, extensionId: string, extensionName: string): Promise<void> {
|
||||
const allowList = readAllowedExtensions(this.storageService, providerId, accountName).concat({ id: extensionId, name: extensionName });
|
||||
this.storageService.store(`${providerId}-${accountName}`, JSON.stringify(allowList), StorageScope.GLOBAL);
|
||||
const allowList = readAllowedExtensions(this.storageService, providerId, accountName);
|
||||
if (!allowList.find(allowed => allowed.id === extensionId)) {
|
||||
allowList.push({ id: extensionId, name: extensionName, sessionIds: [] });
|
||||
this.storageService.store(`${providerId}-${accountName}`, JSON.stringify(allowList), StorageScope.GLOBAL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
|
||||
const extHostLabelService = rpcProtocol.set(ExtHostContext.ExtHosLabelService, new ExtHostLabelService(rpcProtocol));
|
||||
const extHostNotebook = rpcProtocol.set(ExtHostContext.ExtHostNotebook, new ExtHostNotebookController(rpcProtocol, extHostCommands, extHostDocumentsAndEditors));
|
||||
const extHostTheming = rpcProtocol.set(ExtHostContext.ExtHostTheming, new ExtHostTheming(rpcProtocol));
|
||||
const extHostAuthentication = rpcProtocol.set(ExtHostContext.ExtHostAuthentication, new ExtHostAuthentication(rpcProtocol));
|
||||
const extHostAuthentication = rpcProtocol.set(ExtHostContext.ExtHostAuthentication, new ExtHostAuthentication(rpcProtocol, extHostStorage));
|
||||
const extHostTimeline = rpcProtocol.set(ExtHostContext.ExtHostTimeline, new ExtHostTimeline(rpcProtocol, extHostCommands));
|
||||
const extHostWebviews = rpcProtocol.set(ExtHostContext.ExtHostWebviews, new ExtHostWebviews(rpcProtocol, initData.environment, extHostWorkspace, extHostLogService, extHostApiDeprecation, extHostDocuments));
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ export interface MainThreadAuthenticationShape extends IDisposable {
|
||||
$registerAuthenticationProvider(id: string, displayName: string): void;
|
||||
$unregisterAuthenticationProvider(id: string): void;
|
||||
$onDidChangeSessions(providerId: string, event: modes.AuthenticationSessionsChangeEvent): void;
|
||||
$getSessionsPrompt(providerId: string, accountName: string, providerName: string, extensionId: string, extensionName: string): Promise<boolean>;
|
||||
$getSessionsPrompt(providerId: string, accountName: string, sessionId: string, providerName: string, extensionId: string, extensionName: string): Promise<boolean>;
|
||||
$loginPrompt(providerName: string, extensionName: string): Promise<boolean>;
|
||||
$setTrustedExtension(providerId: string, accountName: string, extensionId: string, extensionName: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Emitter, Event } from 'vs/base/common/event';
|
||||
import { IMainContext, MainContext, MainThreadAuthenticationShape, ExtHostAuthenticationShape } from 'vs/workbench/api/common/extHost.protocol';
|
||||
import { Disposable } from 'vs/workbench/api/common/extHostTypes';
|
||||
import { IExtensionDescription, ExtensionIdentifier } from 'vs/platform/extensions/common/extensions';
|
||||
import { IExtHostStorage } from 'vs/workbench/api/common/extHostStorage';
|
||||
|
||||
export class ExtHostAuthentication implements ExtHostAuthenticationShape {
|
||||
private _proxy: MainThreadAuthenticationShape;
|
||||
@@ -20,7 +21,8 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape {
|
||||
private _onDidChangeSessions = new Emitter<{ [providerId: string]: vscode.AuthenticationSessionsChangeEvent }>();
|
||||
readonly onDidChangeSessions: Event<{ [providerId: string]: vscode.AuthenticationSessionsChangeEvent }> = this._onDidChangeSessions.event;
|
||||
|
||||
constructor(mainContext: IMainContext) {
|
||||
constructor(mainContext: IMainContext,
|
||||
@IExtHostStorage private readonly storageService: IExtHostStorage) {
|
||||
this._proxy = mainContext.getProxy(MainContext.MainThreadAuthentication);
|
||||
}
|
||||
|
||||
@@ -33,15 +35,34 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape {
|
||||
return ids;
|
||||
}
|
||||
|
||||
private async hasNotBeenReadByOtherExtension(providerId: string, session: vscode.AuthenticationSession, extensionId: string): Promise<boolean> {
|
||||
const readerId = await this.storageService.getValue(true, `${providerId}-${session.accountName}-${session.id}`);
|
||||
if (!readerId) {
|
||||
await this.storageService.setValue(true, `${providerId}-${session.accountName}-${session.id}`, extensionId as any);
|
||||
return true;
|
||||
}
|
||||
|
||||
return readerId === extensionId;
|
||||
}
|
||||
|
||||
private async isMatchingSession(session: vscode.AuthenticationSession, scopes: string, providerId: string, extensionId: string): Promise<boolean> {
|
||||
return session.scopes.sort().join(' ') === scopes && (await this.hasNotBeenReadByOtherExtension(providerId, session, extensionId));
|
||||
}
|
||||
|
||||
async getSessions(requestingExtension: IExtensionDescription, providerId: string, scopes: string[]): Promise<readonly vscode.AuthenticationSession[]> {
|
||||
const provider = this._authenticationProviders.get(providerId);
|
||||
if (!provider) {
|
||||
throw new Error(`No authentication provider with id '${providerId}' is currently registered.`);
|
||||
}
|
||||
|
||||
const extensionId = ExtensionIdentifier.toKey(requestingExtension.identifier);
|
||||
const orderedScopes = scopes.sort().join(' ');
|
||||
return (await provider.getSessions())
|
||||
.filter(session => session.scopes.sort().join(' ') === orderedScopes)
|
||||
|
||||
const sessions = await provider.getSessions();
|
||||
const filteredSessions = await Promise.all(sessions.map(session => this.isMatchingSession(session, orderedScopes, providerId, extensionId)));
|
||||
|
||||
return sessions
|
||||
.filter((_, i) => { return filteredSessions[i]; })
|
||||
.map(session => {
|
||||
return {
|
||||
id: session.id,
|
||||
@@ -51,8 +72,9 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape {
|
||||
const isAllowed = await this._proxy.$getSessionsPrompt(
|
||||
provider.id,
|
||||
session.accountName,
|
||||
session.id,
|
||||
provider.displayName,
|
||||
ExtensionIdentifier.toKey(requestingExtension.identifier),
|
||||
extensionId,
|
||||
requestingExtension.displayName || requestingExtension.name);
|
||||
|
||||
if (!isAllowed) {
|
||||
@@ -77,9 +99,28 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape {
|
||||
throw new Error('User did not consent to login.');
|
||||
}
|
||||
|
||||
const newSession = await provider.login(scopes);
|
||||
await this._proxy.$setTrustedExtension(provider.id, newSession.accountName, ExtensionIdentifier.toKey(requestingExtension.identifier), extensionName);
|
||||
return newSession;
|
||||
const session = await provider.login(scopes);
|
||||
await this._proxy.$setTrustedExtension(provider.id, session.accountName, ExtensionIdentifier.toKey(requestingExtension.identifier), extensionName);
|
||||
return {
|
||||
id: session.id,
|
||||
accountName: session.accountName,
|
||||
scopes: session.scopes,
|
||||
getAccessToken: async () => {
|
||||
const isAllowed = await this._proxy.$getSessionsPrompt(
|
||||
provider.id,
|
||||
session.accountName,
|
||||
session.id,
|
||||
provider.displayName,
|
||||
ExtensionIdentifier.toKey(requestingExtension.identifier),
|
||||
requestingExtension.displayName || requestingExtension.name);
|
||||
|
||||
if (!isAllowed) {
|
||||
throw new Error('User did not consent to token access.');
|
||||
}
|
||||
|
||||
return session.getAccessToken();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
registerAuthenticationProvider(provider: vscode.AuthenticationProvider): vscode.Disposable {
|
||||
|
||||
Reference in New Issue
Block a user