mirror of
https://github.com/Microsoft/vscode
synced 2024-08-27 04:49:35 +00:00
Implement GitHub Enterprise authn provider (#115940)
This commit is contained in:
parent
0f64d3a2e5
commit
4978a1891e
|
@ -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",
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue