Implement GitHub Enterprise authn provider (#115940)

This commit is contained in:
Kevin Abel 2021-05-07 16:13:11 -05:00 committed by GitHub
parent 0f64d3a2e5
commit 4978a1891e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 200 additions and 123 deletions

View file

@ -4,7 +4,7 @@
"description": "%description%",
"publisher": "vscode",
"license": "MIT",
"version": "0.0.1",
"version": "0.0.2",
"engines": {
"vscode": "^1.41.0"
},
@ -19,7 +19,8 @@
"web"
],
"activationEvents": [
"onAuthenticationRequest:github"
"onAuthenticationRequest:github",
"onAuthenticationRequest:github-enterprise"
],
"capabilities": {
"virtualWorkspaces": true,
@ -31,7 +32,14 @@
"commands": [
{
"command": "github.provide-token",
"title": "Manually Provide Token"
"title": "Manually Provide Token",
"category": "GitHub"
},
{
"command": "github-enterprise.provide-token",
"title": "Manually Provide Token",
"category": "GitHub Enterprise"
}
],
"menus": {
@ -39,6 +47,10 @@
{
"command": "github.provide-token",
"when": "false"
},
{
"command": "github-enterprise.provide-token",
"when": "false"
}
]
},
@ -46,8 +58,21 @@
{
"label": "GitHub",
"id": "github"
},
{
"label": "GitHub Enterprise",
"id": "github-enterprise"
}
]
],
"configuration": {
"title": "GitHub Enterprise Authentication Provider",
"properties": {
"github-enterprise.uri" : {
"type": "string",
"description": "URI of your GitHub Enterprise Instanace"
}
}
}
},
"aiKey": "AIF-d9b70cd4-b9f9-4d70-929b-a071c400b217",
"main": "./out/extension.js",

View file

@ -28,13 +28,11 @@ export type Keytar = {
deletePassword: typeof keytarType['deletePassword'];
};
const SERVICE_ID = `github.auth`;
export class Keychain {
constructor(private context: vscode.ExtensionContext) { }
constructor(private context: vscode.ExtensionContext, private serviceId: string) { }
async setToken(token: string): Promise<void> {
try {
return await this.context.secrets.store(SERVICE_ID, token);
return await this.context.secrets.store(this.serviceId, token);
} catch (e) {
// Ignore
Logger.error(`Setting token failed: ${e}`);
@ -48,7 +46,7 @@ export class Keychain {
async getToken(): Promise<string | null | undefined> {
try {
return await this.context.secrets.get(SERVICE_ID);
return await this.context.secrets.get(this.serviceId);
} catch (e) {
// Ignore
Logger.error(`Getting token failed: ${e}`);
@ -58,7 +56,7 @@ export class Keychain {
async deleteToken(): Promise<void> {
try {
return await this.context.secrets.delete(SERVICE_ID);
return await this.context.secrets.delete(this.serviceId);
} catch (e) {
// Ignore
Logger.error(`Deleting token failed: ${e}`);

View file

@ -4,9 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { GitHubAuthenticationProvider, onDidChangeSessions } from './github';
import { uriHandler } from './githubServer';
import Logger from './common/logger';
import { GitHubAuthenticationProvider, AuthProviderType } from './github';
import TelemetryReporter from 'vscode-extension-telemetry';
import { createExperimentationService, ExperimentationTelemetry } from './experimentationService';
@ -17,74 +15,13 @@ export async function activate(context: vscode.ExtensionContext) {
const experimentationService = await createExperimentationService(context, telemetryReporter);
await experimentationService.initialFetch;
context.subscriptions.push(vscode.window.registerUriHandler(uriHandler));
const loginService = new GitHubAuthenticationProvider(context, telemetryReporter);
await loginService.initialize(context);
context.subscriptions.push(vscode.commands.registerCommand('github.provide-token', () => {
return loginService.manuallyProvideToken();
}));
context.subscriptions.push(vscode.authentication.registerAuthenticationProvider('github', 'GitHub', {
onDidChangeSessions: onDidChangeSessions.event,
getSessions: (scopes?: string[]) => loginService.getSessions(scopes),
createSession: async (scopeList: string[]) => {
try {
/* __GDPR__
"login" : { }
*/
telemetryReporter.sendTelemetryEvent('login');
const session = await loginService.createSession(scopeList.sort().join(' '));
Logger.info('Login success!');
onDidChangeSessions.fire({ added: [session], 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" : { }
*/
telemetryReporter.sendTelemetryEvent('loginFailed');
vscode.window.showErrorMessage(`Sign in failed: ${e}`);
Logger.error(e);
throw e;
}
},
removeSession: async (id: string) => {
try {
/* __GDPR__
"logout" : { }
*/
telemetryReporter.sendTelemetryEvent('logout');
const session = await loginService.removeSession(id);
if (session) {
onDidChangeSessions.fire({ added: [], removed: [session], changed: [] });
}
} catch (e) {
/* __GDPR__
"logoutFailed" : { }
*/
telemetryReporter.sendTelemetryEvent('logoutFailed');
vscode.window.showErrorMessage(`Sign out failed: ${e}`);
Logger.error(e);
throw e;
}
}
}, { supportsMultipleAccounts: false }));
return;
[
AuthProviderType.github,
AuthProviderType['github-enterprise']
].forEach(async type => {
const loginService = new GitHubAuthenticationProvider(context, type, telemetryReporter);
await loginService.initialize();
});
}
// this method is called when your extension is deactivated

View file

@ -6,13 +6,11 @@
import * as vscode from 'vscode';
import { v4 as uuid } from 'uuid';
import { Keychain } from './common/keychain';
import { GitHubServer, NETWORK_ERROR } from './githubServer';
import { GitHubServer, uriHandler, NETWORK_ERROR } from './githubServer';
import Logger from './common/logger';
import { arrayEquals } from './common/utils';
import { ExperimentationTelemetry } from './experimentationService';
export const onDidChangeSessions = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
interface SessionData {
id: string;
account?: {
@ -24,18 +22,29 @@ interface SessionData {
accessToken: string;
}
export class GitHubAuthenticationProvider {
export enum AuthProviderType {
github = 'github',
'github-enterprise' = 'github-enterprise'
}
export class GitHubAuthenticationProvider implements vscode.AuthenticationProvider {
private _sessions: vscode.AuthenticationSession[] = [];
private _sessionChangeEmitter = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
private _githubServer: GitHubServer;
private _keychain: Keychain;
constructor(context: vscode.ExtensionContext, telemetryReporter: ExperimentationTelemetry) {
this._keychain = new Keychain(context);
this._githubServer = new GitHubServer(telemetryReporter);
constructor(private context: vscode.ExtensionContext, private type: AuthProviderType, private telemetryReporter: ExperimentationTelemetry) {
this._keychain = new Keychain(context, `${type}.auth`);
this._githubServer = new GitHubServer(type, telemetryReporter);
}
public async initialize(context: vscode.ExtensionContext): Promise<void> {
get onDidChangeSessions() {
return this._sessionChangeEmitter.event;
}
public async initialize(): Promise<void> {
try {
this._sessions = await this.readSessions();
await this.verifySessions();
@ -43,7 +52,17 @@ export class GitHubAuthenticationProvider {
// Ignore, network request failed
}
context.subscriptions.push(context.secrets.onDidChange(() => this.checkForUpdates()));
let friendlyName = 'GitHub';
if (this.type === AuthProviderType.github) {
this.context.subscriptions.push(vscode.window.registerUriHandler(uriHandler));
}
if (this.type === AuthProviderType['github-enterprise']) {
friendlyName = 'GitHub Enterprise';
}
this.context.subscriptions.push(vscode.commands.registerCommand(`${this.type}.provide-token`, () => this.manuallyProvideToken()));
this.context.subscriptions.push(vscode.authentication.registerAuthenticationProvider(this.type, friendlyName, this, { supportsMultipleAccounts: false }));
this.context.subscriptions.push(this.context.secrets.onDidChange(() => this.checkForUpdates()));
}
async getSessions(scopes?: string[]): Promise<vscode.AuthenticationSession[]> {
@ -52,12 +71,21 @@ export class GitHubAuthenticationProvider {
: this._sessions;
}
private async afterTokenLoad(token: string): Promise<void> {
if (this.type === AuthProviderType.github) {
this._githubServer.checkIsEdu(token);
}
if (this.type === AuthProviderType['github-enterprise']) {
this._githubServer.checkEnterpriseVersion(token);
}
}
private async verifySessions(): Promise<void> {
const verifiedSessions: vscode.AuthenticationSession[] = [];
const verificationPromises = this._sessions.map(async session => {
try {
await this._githubServer.getUserInfo(session.accessToken);
this._githubServer.checkIsEdu(session.accessToken);
this.afterTokenLoad(session.accessToken);
verifiedSessions.push(session);
} catch (e) {
// Remove sessions that return unauthorized response
@ -112,7 +140,7 @@ export class GitHubAuthenticationProvider {
});
if (added.length || removed.length) {
onDidChangeSessions.fire({ added, removed, changed: [] });
this._sessionChangeEmitter.fire({ added, removed, changed: [] });
}
}
@ -163,12 +191,41 @@ export class GitHubAuthenticationProvider {
return this._sessions;
}
public async createSession(scopes: string): Promise<vscode.AuthenticationSession> {
const token = await this._githubServer.login(scopes);
const session = await this.tokenToSession(token, scopes.split(' '));
this._githubServer.checkIsEdu(token);
await this.setToken(session);
return session;
public async createSession(scopes: string[]): Promise<vscode.AuthenticationSession> {
try {
/* __GDPR__
"login" : { }
*/
this.telemetryReporter?.sendTelemetryEvent('login');
const token = await this._githubServer.login(scopes.sort().join(' '));
this.afterTokenLoad(token);
const session = await this.tokenToSession(token, scopes);
await this.setToken(session);
this._sessionChangeEmitter.fire({ added: [session], removed: [], changed: [] });
Logger.info('Login success!');
return session;
} catch (e) {
// If login was cancelled, do not notify user.
if (e.message === 'Cancelled') {
/* __GDPR__
"loginCancelled" : { }
*/
this.telemetryReporter?.sendTelemetryEvent('loginCancelled');
throw e;
}
/* __GDPR__
"loginFailed" : { }
*/
this.telemetryReporter?.sendTelemetryEvent('loginFailed');
vscode.window.showErrorMessage(`Sign in failed: ${e}`);
Logger.error(e);
throw e;
}
}
public async manuallyProvideToken(): Promise<void> {
@ -196,18 +253,33 @@ export class GitHubAuthenticationProvider {
await this.storeSessions();
}
public async removeSession(id: string): Promise<vscode.AuthenticationSession | undefined> {
Logger.info(`Logging out of ${id}`);
const sessionIndex = this._sessions.findIndex(session => session.id === id);
let session: vscode.AuthenticationSession | undefined;
if (sessionIndex > -1) {
session = this._sessions[sessionIndex];
this._sessions.splice(sessionIndex, 1);
} else {
Logger.error('Session not found');
}
public async removeSession(id: string) {
try {
/* __GDPR__
"logout" : { }
*/
this.telemetryReporter?.sendTelemetryEvent('logout');
await this.storeSessions();
return session;
Logger.info(`Logging out of ${id}`);
const sessionIndex = this._sessions.findIndex(session => session.id === id);
if (sessionIndex > -1) {
const session = this._sessions[sessionIndex];
this._sessions.splice(sessionIndex, 1);
this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] });
} else {
Logger.error('Session not found');
}
await this.storeSessions();
} catch (e) {
/* __GDPR__
"logoutFailed" : { }
*/
this.telemetryReporter?.sendTelemetryEvent('logoutFailed');
vscode.window.showErrorMessage(`Sign out failed: ${e}`);
Logger.error(e);
throw e;
}
}
}

View file

@ -10,6 +10,7 @@ import { v4 as uuid } from 'uuid';
import { PromiseAdapter, promiseFromEvent } from './common/utils';
import Logger from './common/logger';
import { ExperimentationTelemetry } from './experimentationService';
import { AuthProviderType } from './github';
const localize = nls.loadMessageBundle();
@ -25,10 +26,6 @@ class UriEventHandler extends vscode.EventEmitter<vscode.Uri> implements vscode.
export const uriHandler = new UriEventHandler;
const onDidManuallyProvideToken = new vscode.EventEmitter<string | undefined>();
function parseQuery(uri: vscode.Uri) {
return uri.query.split('&').reduce((prev: any, current) => {
const queryString = current.split('=');
@ -39,14 +36,15 @@ function parseQuery(uri: vscode.Uri) {
export class GitHubServer {
private _statusBarItem: vscode.StatusBarItem | undefined;
private _onDidManuallyProvideToken = new vscode.EventEmitter<string | undefined>();
private _pendingStates = new Map<string, string[]>();
private _codeExchangePromises = new Map<string, { promise: Promise<string>, cancel: vscode.EventEmitter<void> }>();
constructor(private readonly telemetryReporter: ExperimentationTelemetry) { }
constructor(private type: AuthProviderType, private readonly telemetryReporter: ExperimentationTelemetry) { }
private isTestEnvironment(url: vscode.Uri): boolean {
return /\.azurewebsites\.net$/.test(url.authority) || url.authority.startsWith('localhost:');
return this.type === AuthProviderType['github-enterprise'] || /\.azurewebsites\.net$/.test(url.authority) || url.authority.startsWith('localhost:');
}
// TODO@joaomoreno TODO@RMacfarlane
@ -104,7 +102,7 @@ export class GitHubServer {
return Promise.race([
codeExchangePromise.promise,
promiseFromEvent<string | undefined, string>(onDidManuallyProvideToken.event, (token: string | undefined, resolve, reject): void => {
promiseFromEvent<string | undefined, string>(this._onDidManuallyProvideToken.event, (token: string | undefined, resolve, reject): void => {
if (!token) {
reject('Cancelled');
} else {
@ -164,11 +162,30 @@ export class GitHubServer {
}
};
private getServerUri(path?: string) {
const apiUri = this.type === AuthProviderType['github-enterprise']
? vscode.Uri.parse(vscode.workspace.getConfiguration('github-enterprise').get<string>('uri') || '', true)
: vscode.Uri.parse('https://api.github.com');
if (!path) {
path = '';
}
if (this.type === AuthProviderType['github-enterprise']) {
path = '/api/v3' + path;
}
return vscode.Uri.parse(`${apiUri.scheme}://${apiUri.authority}${path}`);
}
private updateStatusBarItem(isStart?: boolean) {
if (isStart && !this._statusBarItem) {
this._statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
this._statusBarItem.text = localize('signingIn', "$(mark-github) Signing in to github.com...");
this._statusBarItem.command = 'github.provide-token';
this._statusBarItem.text = this.type === AuthProviderType.github
? localize('signingIn', "$(mark-github) Signing in to github.com...")
: localize('signingInEnterprise', "$(mark-github) Signing in to {0}...", this.getServerUri().authority);
this._statusBarItem.command = this.type === AuthProviderType.github
? 'github.provide-token'
: 'github-enterprise.provide-token';
this._statusBarItem.show();
}
@ -181,7 +198,7 @@ export class GitHubServer {
public async manuallyProvideToken() {
const uriOrToken = await vscode.window.showInputBox({ prompt: 'Token', ignoreFocusOut: true });
if (!uriOrToken) {
onDidManuallyProvideToken.fire(undefined);
this._onDidManuallyProvideToken.fire(undefined);
return;
}
@ -192,14 +209,14 @@ export class GitHubServer {
} catch (e) {
// If it doesn't look like a URI, treat it as a token.
Logger.info('Treating input as token');
onDidManuallyProvideToken.fire(uriOrToken);
this._onDidManuallyProvideToken.fire(uriOrToken);
}
}
private async getScopes(token: string): Promise<string[]> {
try {
Logger.info('Getting token scopes...');
const result = await fetch('https://api.github.com', {
const result = await fetch(this.getServerUri('/').toString(), {
headers: {
Authorization: `token ${token}`,
'User-Agent': 'Visual-Studio-Code'
@ -223,7 +240,7 @@ export class GitHubServer {
let result: Response;
try {
Logger.info('Getting user info...');
result = await fetch('https://api.github.com/user', {
result = await fetch(this.getServerUri('/user').toString(), {
headers: {
Authorization: `token ${token}`,
'User-Agent': 'Visual-Studio-Code'
@ -279,6 +296,34 @@ export class GitHubServer {
} catch (e) {
// No-op
}
}
public async checkEnterpriseVersion(token: string): Promise<void> {
try {
const result = await fetch(this.getServerUri('/meta').toString(), {
headers: {
Authorization: `token ${token}`,
'User-Agent': 'Visual-Studio-Code'
}
});
if (!result.ok) {
return;
}
const json: { verifiable_password_authentication: boolean, installed_version: string } = await result.json();
/* __GDPR__
"ghe-session" : {
"version": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
}
*/
this.telemetryReporter.sendTelemetryEvent('ghe-session', {
version: json.installed_version
});
} catch {
// No-op
}
}
}