Merge from vscode 2c306f762bf9c3db82dc06c7afaa56ef46d72f79 (#14050)

* Merge from vscode 2c306f762bf9c3db82dc06c7afaa56ef46d72f79

* Fix breaks

* Extension management fixes

* Fix breaks in windows bundling

* Fix/skip failing tests

* Update distro

* Add clear to nuget.config

* Add hygiene task

* Bump distro

* Fix hygiene issue

* Add build to hygiene exclusion

* Update distro

* Update hygiene

* Hygiene exclusions

* Update tsconfig

* Bump distro for server breaks

* Update build config

* Update darwin path

* Add done calls to notebook tests

* Skip failing tests

* Disable smoke tests
This commit is contained in:
Karl Burtram
2021-02-09 16:15:05 -08:00
committed by GitHub
parent 6f192f9af5
commit ce612a3d96
1929 changed files with 68012 additions and 34564 deletions

View File

@@ -28,24 +28,12 @@ export type Keytar = {
deletePassword: typeof keytarType['deletePassword'];
};
const SERVICE_ID = `${vscode.env.uriScheme}-github.login`;
const ACCOUNT_ID = 'account';
const SERVICE_ID = `github.auth`;
export class Keychain {
private keytar: Keytar;
constructor() {
const keytar = getKeytar();
if (!keytar) {
throw new Error('System keychain unavailable');
}
this.keytar = keytar;
}
async setToken(token: string): Promise<void> {
try {
return await this.keytar.setPassword(SERVICE_ID, ACCOUNT_ID, token);
return await vscode.authentication.setPassword(SERVICE_ID, token);
} catch (e) {
// Ignore
Logger.error(`Setting token failed: ${e}`);
@@ -59,7 +47,7 @@ export class Keychain {
async getToken(): Promise<string | null | undefined> {
try {
return await this.keytar.getPassword(SERVICE_ID, ACCOUNT_ID);
return await vscode.authentication.getPassword(SERVICE_ID);
} catch (e) {
// Ignore
Logger.error(`Getting token failed: ${e}`);
@@ -67,15 +55,35 @@ export class Keychain {
}
}
async deleteToken(): Promise<boolean | undefined> {
async deleteToken(): Promise<void> {
try {
return await this.keytar.deletePassword(SERVICE_ID, ACCOUNT_ID);
return await vscode.authentication.deletePassword(SERVICE_ID);
} catch (e) {
// Ignore
Logger.error(`Deleting token failed: ${e}`);
return Promise.resolve(undefined);
}
}
async tryMigrate(): Promise<string | null | undefined> {
try {
const keytar = getKeytar();
if (!keytar) {
throw new Error('keytar unavailable');
}
const oldValue = await keytar.getPassword(`${vscode.env.uriScheme}-github.login`, 'account');
if (oldValue) {
await this.setToken(oldValue);
await keytar.deletePassword(`${vscode.env.uriScheme}-github.login`, 'account');
}
return oldValue;
} catch (_) {
// Ignore
return Promise.resolve(undefined);
}
}
}
export const keychain = new Keychain();

View File

@@ -25,7 +25,7 @@ export interface PromiseAdapter<T, U> {
(
value: T,
resolve:
(value?: U | PromiseLike<U>) => void,
(value: U | PromiseLike<U>) => void,
reject:
(reason: any) => void
): any;

View File

@@ -16,7 +16,7 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(vscode.window.registerUriHandler(uriHandler));
const loginService = new GitHubAuthenticationProvider();
await loginService.initialize();
await loginService.initialize(context);
context.subscriptions.push(vscode.commands.registerCommand('github.provide-token', () => {
return loginService.manuallyProvideToken();
@@ -40,6 +40,15 @@ export async function activate(context: vscode.ExtensionContext) {
onDidChangeSessions.fire({ added: [session.id], removed: [], changed: [] });
return session;
} catch (e) {
// If login was cancelled, do not notify user.
if (e.message === 'Cancelled') {
/* __GDPR__
"loginCancelled" : { }
*/
telemetryReporter.sendTelemetryEvent('loginCancelled');
throw e;
}
/* __GDPR__
"loginFailed" : { }
*/

View File

@@ -26,63 +26,82 @@ export class GitHubAuthenticationProvider {
private _sessions: vscode.AuthenticationSession[] = [];
private _githubServer = new GitHubServer();
public async initialize(): Promise<void> {
public async initialize(context: vscode.ExtensionContext): Promise<void> {
try {
this._sessions = await this.readSessions();
await this.verifySessions();
} catch (e) {
// Ignore, network request failed
}
this.pollForChange();
context.subscriptions.push(vscode.authentication.onDidChangePassword(() => this.checkForUpdates()));
}
private pollForChange() {
setTimeout(async () => {
let storedSessions: vscode.AuthenticationSession[];
private async verifySessions(): Promise<void> {
const verifiedSessions: vscode.AuthenticationSession[] = [];
const verificationPromises = this._sessions.map(async session => {
try {
storedSessions = await this.readSessions();
await this._githubServer.getUserInfo(session.accessToken);
verifiedSessions.push(session);
} catch (e) {
// Ignore, network request failed
return;
}
const added: string[] = [];
const removed: string[] = [];
storedSessions.forEach(session => {
const matchesExisting = this._sessions.some(s => s.id === session.id);
// Another window added a session to the keychain, add it to our state as well
if (!matchesExisting) {
Logger.info('Adding session found in keychain');
this._sessions.push(session);
added.push(session.id);
// Remove sessions that return unauthorized response
if (e.message !== 'Unauthorized') {
verifiedSessions.push(session);
}
});
this._sessions.map(session => {
const matchesExisting = storedSessions.some(s => s.id === session.id);
// Another window has logged out, remove from our state
if (!matchesExisting) {
Logger.info('Removing session no longer found in keychain');
const sessionIndex = this._sessions.findIndex(s => s.id === session.id);
if (sessionIndex > -1) {
this._sessions.splice(sessionIndex, 1);
}
removed.push(session.id);
}
});
if (added.length || removed.length) {
onDidChangeSessions.fire({ added, removed, changed: [] });
}
});
this.pollForChange();
}, 1000 * 30);
Promise.all(verificationPromises).then(_ => {
if (this._sessions.length !== verifiedSessions.length) {
this._sessions = verifiedSessions;
this.storeSessions();
}
});
}
private async checkForUpdates() {
let storedSessions: vscode.AuthenticationSession[];
try {
storedSessions = await this.readSessions();
} catch (e) {
// Ignore, network request failed
return;
}
const added: string[] = [];
const removed: string[] = [];
storedSessions.forEach(session => {
const matchesExisting = this._sessions.some(s => s.id === session.id);
// Another window added a session to the keychain, add it to our state as well
if (!matchesExisting) {
Logger.info('Adding session found in keychain');
this._sessions.push(session);
added.push(session.id);
}
});
this._sessions.map(session => {
const matchesExisting = storedSessions.some(s => s.id === session.id);
// Another window has logged out, remove from our state
if (!matchesExisting) {
Logger.info('Removing session no longer found in keychain');
const sessionIndex = this._sessions.findIndex(s => s.id === session.id);
if (sessionIndex > -1) {
this._sessions.splice(sessionIndex, 1);
}
removed.push(session.id);
}
});
if (added.length || removed.length) {
onDidChangeSessions.fire({ added, removed, changed: [] });
}
}
private async readSessions(): Promise<vscode.AuthenticationSession[]> {
const storedSessions = await keychain.getToken();
const storedSessions = await keychain.getToken() || await keychain.tryMigrate();
if (storedSessions) {
try {
const sessionData: SessionData[] = JSON.parse(storedSessions);
@@ -161,9 +180,12 @@ export class GitHubAuthenticationProvider {
}
public async logout(id: string) {
Logger.info(`Logging out of ${id}`);
const sessionIndex = this._sessions.findIndex(session => session.id === id);
if (sessionIndex > -1) {
this._sessions.splice(sessionIndex, 1);
} else {
Logger.error('Session not found');
}
await this.storeSessions();

View File

@@ -5,7 +5,7 @@
import * as nls from 'vscode-nls';
import * as vscode from 'vscode';
import fetch from 'node-fetch';
import fetch, { Response } from 'node-fetch';
import { v4 as uuid } from 'uuid';
import { PromiseAdapter, promiseFromEvent } from './common/utils';
import Logger from './common/logger';
@@ -23,38 +23,9 @@ class UriEventHandler extends vscode.EventEmitter<vscode.Uri> implements vscode.
export const uriHandler = new UriEventHandler;
const onDidManuallyProvideToken = new vscode.EventEmitter<string>();
const onDidManuallyProvideToken = new vscode.EventEmitter<string | undefined>();
const exchangeCodeForToken: (state: string) => PromiseAdapter<vscode.Uri, string> =
(state) => async (uri, resolve, reject) => {
Logger.info('Exchanging code for token...');
const query = parseQuery(uri);
const code = query.code;
if (query.state !== state) {
reject('Received mismatched state');
return;
}
try {
const result = await fetch(`https://${AUTH_RELAY_SERVER}/token?code=${code}&state=${state}`, {
method: 'POST',
headers: {
Accept: 'application/json'
}
});
if (result.ok) {
const json = await result.json();
Logger.info('Token exchange success!');
resolve(json.access_token);
} else {
reject(result.statusText);
}
} catch (ex) {
reject(ex);
}
};
function parseQuery(uri: vscode.Uri) {
return uri.query.split('&').reduce((prev: any, current) => {
@@ -67,6 +38,9 @@ function parseQuery(uri: vscode.Uri) {
export class GitHubServer {
private _statusBarItem: vscode.StatusBarItem | undefined;
private _pendingStates = new Map<string, string[]>();
private _codeExchangePromises = new Map<string, Promise<string>>();
private isTestEnvironment(url: vscode.Uri): boolean {
return url.authority === 'vscode-web-test-playground.azurewebsites.net' || url.authority.startsWith('localhost:');
}
@@ -81,21 +55,82 @@ export class GitHubServer {
if (this.isTestEnvironment(callbackUri)) {
const token = await vscode.window.showInputBox({ prompt: 'GitHub Personal Access Token', ignoreFocusOut: true });
if (!token) { throw new Error('Sign in failed: No token provided'); }
const tokenScopes = await this.getScopes(token); // Example: ['repo', 'user']
const scopesList = scopes.split(' '); // Example: 'read:user repo user:email'
if (!scopesList.every(scope => {
const included = tokenScopes.includes(scope);
if (included || !scope.includes(':')) {
return included;
}
return scope.split(':').some(splitScopes => {
return tokenScopes.includes(splitScopes);
});
})) {
throw new Error(`The provided token is does not match the requested scopes: ${scopes}`);
}
this.updateStatusBarItem(false);
return token;
} else {
const existingStates = this._pendingStates.get(scopes) || [];
this._pendingStates.set(scopes, [...existingStates, state]);
const uri = vscode.Uri.parse(`https://${AUTH_RELAY_SERVER}/authorize/?callbackUri=${encodeURIComponent(callbackUri.toString())}&scope=${scopes}&state=${state}&responseType=code&authServer=https://github.com`);
await vscode.env.openExternal(uri);
}
// Register a single listener for the URI callback, in case the user starts the login process multiple times
// before completing it.
let existingPromise = this._codeExchangePromises.get(scopes);
if (!existingPromise) {
existingPromise = promiseFromEvent(uriHandler.event, this.exchangeCodeForToken(scopes));
this._codeExchangePromises.set(scopes, existingPromise);
}
return Promise.race([
promiseFromEvent(uriHandler.event, exchangeCodeForToken(state)),
promiseFromEvent<string, string>(onDidManuallyProvideToken.event)
existingPromise,
promiseFromEvent<string | undefined, string>(onDidManuallyProvideToken.event, (token: string | undefined): string => { if (!token) { throw new Error('Cancelled'); } return token; })
]).finally(() => {
this._pendingStates.delete(scopes);
this._codeExchangePromises.delete(scopes);
this.updateStatusBarItem(false);
});
}
private exchangeCodeForToken: (scopes: string) => PromiseAdapter<vscode.Uri, string> =
(scopes) => async (uri, resolve, reject) => {
Logger.info('Exchanging code for token...');
const query = parseQuery(uri);
const code = query.code;
const acceptedStates = this._pendingStates.get(scopes) || [];
if (!acceptedStates.includes(query.state)) {
reject('Received mismatched state');
return;
}
try {
const result = await fetch(`https://${AUTH_RELAY_SERVER}/token?code=${code}&state=${query.state}`, {
method: 'POST',
headers: {
Accept: 'application/json'
}
});
if (result.ok) {
const json = await result.json();
Logger.info('Token exchange success!');
resolve(json.access_token);
} else {
reject(result.statusText);
}
} catch (ex) {
reject(ex);
}
};
private updateStatusBarItem(isStart?: boolean) {
if (isStart && !this._statusBarItem) {
this._statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
@@ -112,7 +147,11 @@ export class GitHubServer {
public async manuallyProvideToken() {
const uriOrToken = await vscode.window.showInputBox({ prompt: 'Token', ignoreFocusOut: true });
if (!uriOrToken) { return; }
if (!uriOrToken) {
onDidManuallyProvideToken.fire(undefined);
return;
}
try {
const uri = vscode.Uri.parse(uriOrToken);
if (!uri.scheme || uri.scheme === 'file') { throw new Error; }
@@ -124,10 +163,10 @@ export class GitHubServer {
}
}
public async getUserInfo(token: string): Promise<{ id: string, accountName: string }> {
private async getScopes(token: string): Promise<string[]> {
try {
Logger.info('Getting user info...');
const result = await fetch('https://api.github.com/user', {
Logger.info('Getting token scopes...');
const result = await fetch('https://api.github.com', {
headers: {
Authorization: `token ${token}`,
'User-Agent': 'Visual-Studio-Code'
@@ -135,11 +174,10 @@ export class GitHubServer {
});
if (result.ok) {
const json = await result.json();
Logger.info('Got account info!');
return { id: json.id, accountName: json.login };
const scopes = result.headers.get('X-OAuth-Scopes');
return scopes ? scopes.split(',').map(scope => scope.trim()) : [];
} else {
Logger.error(`Getting account info failed: ${result.statusText}`);
Logger.error(`Getting scopes failed: ${result.statusText}`);
throw new Error(result.statusText);
}
} catch (ex) {
@@ -147,4 +185,29 @@ export class GitHubServer {
throw new Error(NETWORK_ERROR);
}
}
public async getUserInfo(token: string): Promise<{ id: string, accountName: string }> {
let result: Response;
try {
Logger.info('Getting user info...');
result = await fetch('https://api.github.com/user', {
headers: {
Authorization: `token ${token}`,
'User-Agent': 'Visual-Studio-Code'
}
});
} catch (ex) {
Logger.error(ex.message);
throw new Error(NETWORK_ERROR);
}
if (result.ok) {
const json = await result.json();
Logger.info('Got account info!');
return { id: json.id, accountName: json.login };
} else {
Logger.error(`Getting account info failed: ${result.statusText}`);
throw new Error(result.statusText);
}
}
}