mirror of
https://github.com/Microsoft/vscode
synced 2024-08-27 04:49:35 +00:00
GHES oauth flow support (#160679)
* GHES oauth flow support * update server number * delete testing line
This commit is contained in:
parent
41aacfa3fb
commit
fc1ef74eb0
|
@ -6,7 +6,7 @@
|
|||
import * as vscode from 'vscode';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Keychain } from './common/keychain';
|
||||
import { GitHubEnterpriseServer, GitHubServer, IGitHubServer } from './githubServer';
|
||||
import { GitHubServer, IGitHubServer } from './githubServer';
|
||||
import { arrayEquals } from './common/utils';
|
||||
import { ExperimentationTelemetry } from './experimentationService';
|
||||
import TelemetryReporter from '@vscode/extension-telemetry';
|
||||
|
@ -43,15 +43,12 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
|
|||
const { name, version, aiKey } = context.extension.packageJSON as { name: string; version: string; aiKey: string };
|
||||
this._telemetryReporter = new ExperimentationTelemetry(context, new TelemetryReporter(name, version, aiKey));
|
||||
|
||||
if (this.type === AuthProviderType.github) {
|
||||
this._githubServer = new GitHubServer(
|
||||
// We only can use the Device Code flow when we have a full node environment because of CORS.
|
||||
context.extension.extensionKind === vscode.ExtensionKind.Workspace || vscode.env.uiKind === vscode.UIKind.Desktop,
|
||||
this._logger,
|
||||
this._telemetryReporter);
|
||||
} else {
|
||||
this._githubServer = new GitHubEnterpriseServer(this._logger, this._telemetryReporter);
|
||||
}
|
||||
this._githubServer = new GitHubServer(
|
||||
this.type,
|
||||
// We only can use the Device Code flow when we have a full node environment because of CORS.
|
||||
context.extension.extensionKind === vscode.ExtensionKind.Workspace || vscode.env.uiKind === vscode.UIKind.Desktop,
|
||||
this._logger,
|
||||
this._telemetryReporter);
|
||||
|
||||
// Contains the current state of the sessions we have available.
|
||||
this._sessionsPromise = this.readSessions().then((sessions) => {
|
||||
|
|
|
@ -17,8 +17,6 @@ import path = require('path');
|
|||
|
||||
const localize = nls.loadMessageBundle();
|
||||
const CLIENT_ID = '01ab8ac9400c4e429b23';
|
||||
const GITHUB_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize';
|
||||
// TODO: change to stable when that happens
|
||||
const GITHUB_TOKEN_URL = 'https://vscode.dev/codeExchangeProxyEndpoints/github/login/oauth/access_token';
|
||||
const NETWORK_ERROR = 'network error';
|
||||
|
||||
|
@ -74,71 +72,77 @@ async function getScopes(token: string, serverUri: vscode.Uri, logger: Log): Pro
|
|||
}
|
||||
}
|
||||
|
||||
async function getUserInfo(token: string, serverUri: vscode.Uri, logger: Log): Promise<{ id: string; accountName: string }> {
|
||||
let result: Response;
|
||||
try {
|
||||
logger.info('Getting user info...');
|
||||
result = await fetch(serverUri.toString(), {
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
'User-Agent': 'Visual-Studio-Code'
|
||||
}
|
||||
});
|
||||
} catch (ex) {
|
||||
logger.error(ex.message);
|
||||
throw new Error(NETWORK_ERROR);
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
try {
|
||||
const json = await result.json();
|
||||
logger.info('Got account info!');
|
||||
return { id: json.id, accountName: json.login };
|
||||
} catch (e) {
|
||||
logger.error(`Unexpected error parsing response from GitHub: ${e.message ?? e}`);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
// either display the response message or the http status text
|
||||
let errorMessage = result.statusText;
|
||||
try {
|
||||
const json = await result.json();
|
||||
if (json.message) {
|
||||
errorMessage = json.message;
|
||||
}
|
||||
} catch (err) {
|
||||
// noop
|
||||
}
|
||||
logger.error(`Getting account info failed: ${errorMessage}`);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export class GitHubServer implements IGitHubServer {
|
||||
friendlyName = 'GitHub';
|
||||
type = AuthProviderType.github;
|
||||
readonly friendlyName: string;
|
||||
|
||||
private _pendingNonces = new Map<string, string[]>();
|
||||
private _codeExchangePromises = new Map<string, { promise: Promise<string>; cancel: vscode.EventEmitter<void> }>();
|
||||
private _disposable: vscode.Disposable;
|
||||
private _uriHandler = new UriEventHandler(this._logger);
|
||||
private readonly getRedirectEndpoint: Thenable<string>;
|
||||
private _disposable: vscode.Disposable | undefined;
|
||||
private static _uriHandler: UriEventHandler | undefined;
|
||||
private _redirectEndpoint: string | undefined;
|
||||
|
||||
constructor(private readonly _supportDeviceCodeFlow: boolean, private readonly _logger: Log, private readonly _telemetryReporter: ExperimentationTelemetry) {
|
||||
this._disposable = vscode.window.registerUriHandler(this._uriHandler);
|
||||
constructor(
|
||||
public readonly type: AuthProviderType,
|
||||
private readonly _supportDeviceCodeFlow: boolean,
|
||||
private readonly _logger: Log,
|
||||
private readonly _telemetryReporter: ExperimentationTelemetry
|
||||
) {
|
||||
this.friendlyName = type === AuthProviderType.github ? 'GitHub' : 'GitHub Enterprise';
|
||||
|
||||
this.getRedirectEndpoint = vscode.commands.executeCommand<{ [providerId: string]: string } | undefined>('workbench.getCodeExchangeProxyEndpoints').then((proxyEndpoints) => {
|
||||
if (!GitHubServer._uriHandler) {
|
||||
GitHubServer._uriHandler = new UriEventHandler(this._logger);
|
||||
this._disposable = vscode.window.registerUriHandler(GitHubServer._uriHandler);
|
||||
}
|
||||
}
|
||||
|
||||
get baseUri() {
|
||||
if (this.type === AuthProviderType.github) {
|
||||
return vscode.Uri.parse('https://github.com/');
|
||||
}
|
||||
return vscode.Uri.parse(vscode.workspace.getConfiguration('github-enterprise').get<string>('uri') || '', true);
|
||||
}
|
||||
|
||||
private async getRedirectEndpoint(): Promise<string> {
|
||||
if (this._redirectEndpoint) {
|
||||
return this._redirectEndpoint;
|
||||
}
|
||||
if (this.type === AuthProviderType.github) {
|
||||
const proxyEndpoints = await vscode.commands.executeCommand<{ [providerId: string]: string } | undefined>('workbench.getCodeExchangeProxyEndpoints');
|
||||
// If we are running in insiders vscode.dev, then ensure we use the redirect route on that.
|
||||
let redirectUri = REDIRECT_URL_STABLE;
|
||||
this._redirectEndpoint = REDIRECT_URL_STABLE;
|
||||
if (proxyEndpoints?.github && new URL(proxyEndpoints.github).hostname === 'insiders.vscode.dev') {
|
||||
redirectUri = REDIRECT_URL_INSIDERS;
|
||||
this._redirectEndpoint = REDIRECT_URL_INSIDERS;
|
||||
}
|
||||
return redirectUri;
|
||||
});
|
||||
return this._redirectEndpoint;
|
||||
} else {
|
||||
// GHES
|
||||
const result = await fetch(this.getServerUri('/meta').toString(true));
|
||||
if (result.ok) {
|
||||
try {
|
||||
const json: { installed_version: string } = await result.json();
|
||||
const [majorStr, minorStr, _patch] = json.installed_version.split('.');
|
||||
const major = Number(majorStr);
|
||||
const minor = Number(minorStr);
|
||||
if (major >= 4 || major === 3 && minor >= 8
|
||||
) {
|
||||
// GHES 3.8 and above used vscode.dev/redirect as the route.
|
||||
// It only supports a single redirect endpoint, so we can't use
|
||||
// insiders.vscode.dev/redirect when we're running in Insiders, unfortunately.
|
||||
this._redirectEndpoint = 'https://vscode.dev/redirect';
|
||||
}
|
||||
} catch (e) {
|
||||
this._logger.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO in like 1 year change the default vscode.dev/redirect maybe
|
||||
this._redirectEndpoint = 'https://vscode-auth.github.com/';
|
||||
}
|
||||
return this._redirectEndpoint;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._disposable.dispose();
|
||||
this._disposable?.dispose();
|
||||
}
|
||||
|
||||
// TODO@joaomoreno TODO@TylerLeonhardt
|
||||
|
@ -217,26 +221,30 @@ export class GitHubServer implements IGitHubServer {
|
|||
this._logger.info(`Trying without local server... (${scopes})`);
|
||||
return await vscode.window.withProgress<string>({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: localize('signingIn', "Signing in to github.com..."),
|
||||
title: localize('signingIn', 'Signing in to {0}...', this.baseUri.authority),
|
||||
cancellable: true
|
||||
}, async (_, token) => {
|
||||
const existingNonces = this._pendingNonces.get(scopes) || [];
|
||||
this._pendingNonces.set(scopes, [...existingNonces, nonce]);
|
||||
const redirectUri = await this.getRedirectEndpoint;
|
||||
const redirectUri = await this.getRedirectEndpoint();
|
||||
const searchParams = new URLSearchParams([
|
||||
['client_id', CLIENT_ID],
|
||||
['redirect_uri', redirectUri],
|
||||
['scope', scopes],
|
||||
['state', encodeURIComponent(callbackUri.toString(true))]
|
||||
]);
|
||||
const uri = vscode.Uri.parse(`${GITHUB_AUTHORIZE_URL}?${searchParams.toString()}`);
|
||||
|
||||
const uri = vscode.Uri.parse(this.baseUri.with({
|
||||
path: '/login/oauth/authorize',
|
||||
query: searchParams.toString()
|
||||
}).toString(true));
|
||||
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 codeExchangePromise = this._codeExchangePromises.get(scopes);
|
||||
if (!codeExchangePromise) {
|
||||
codeExchangePromise = promiseFromEvent(this._uriHandler.event, this.handleUri(scopes));
|
||||
codeExchangePromise = promiseFromEvent(GitHubServer._uriHandler!.event, this.handleUri(scopes));
|
||||
this._codeExchangePromises.set(scopes, codeExchangePromise);
|
||||
}
|
||||
|
||||
|
@ -258,17 +266,21 @@ export class GitHubServer implements IGitHubServer {
|
|||
this._logger.info(`Trying with local server... (${scopes})`);
|
||||
return await vscode.window.withProgress<string>({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: localize('signingInAnotherWay', "Signing in to github.com..."),
|
||||
title: localize('signingInAnotherWay', "Signing in to {0}...", this.baseUri.authority),
|
||||
cancellable: true
|
||||
}, async (_, token) => {
|
||||
const redirectUri = await this.getRedirectEndpoint;
|
||||
const redirectUri = await this.getRedirectEndpoint();
|
||||
const searchParams = new URLSearchParams([
|
||||
['client_id', CLIENT_ID],
|
||||
['redirect_uri', redirectUri],
|
||||
['scope', scopes],
|
||||
]);
|
||||
const loginUrl = `${GITHUB_AUTHORIZE_URL}?${searchParams.toString()}`;
|
||||
const server = new LoopbackAuthServer(path.join(__dirname, '../media'), loginUrl);
|
||||
|
||||
const loginUrl = this.baseUri.with({
|
||||
path: '/login/oauth/authorize',
|
||||
query: searchParams.toString()
|
||||
});
|
||||
const server = new LoopbackAuthServer(path.join(__dirname, '../media'), loginUrl.toString(true));
|
||||
const port = await server.start();
|
||||
|
||||
let codeToExchange;
|
||||
|
@ -295,8 +307,11 @@ export class GitHubServer implements IGitHubServer {
|
|||
this._logger.info(`Trying device code flow... (${scopes})`);
|
||||
|
||||
// Get initial device code
|
||||
const uri = `https://github.com/login/device/code?client_id=${CLIENT_ID}&scope=${scopes}`;
|
||||
const result = await fetch(uri, {
|
||||
const uri = this.baseUri.with({
|
||||
path: '/login/device/code',
|
||||
query: `client_id=${CLIENT_ID}&scope=${scopes}`
|
||||
});
|
||||
const result = await fetch(uri.toString(true), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
|
@ -363,7 +378,10 @@ export class GitHubServer implements IGitHubServer {
|
|||
json.verification_uri,
|
||||
json.user_code)
|
||||
}, async (_, token) => {
|
||||
const refreshTokenUri = `https://github.com/login/oauth/access_token?client_id=${CLIENT_ID}&device_code=${json.device_code}&grant_type=urn:ietf:params:oauth:grant-type:device_code`;
|
||||
const refreshTokenUri = this.baseUri.with({
|
||||
path: '/login/oauth/access_token',
|
||||
query: `client_id=${CLIENT_ID}&device_code=${json.device_code}&grant_type=urn:ietf:params:oauth:grant-type:device_code`
|
||||
});
|
||||
|
||||
// Try for 2 minutes
|
||||
const attempts = 120 / json.interval;
|
||||
|
@ -374,7 +392,7 @@ export class GitHubServer implements IGitHubServer {
|
|||
}
|
||||
let accessTokenResult;
|
||||
try {
|
||||
accessTokenResult = await fetch(refreshTokenUri, {
|
||||
accessTokenResult = await fetch(refreshTokenUri.toString(true), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
|
@ -439,7 +457,11 @@ export class GitHubServer implements IGitHubServer {
|
|||
const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
|
||||
const endpointUrl = proxyEndpoints?.github ? `${proxyEndpoints.github}login/oauth/access_token` : GITHUB_TOKEN_URL;
|
||||
|
||||
const body = `code=${code}`;
|
||||
const body = new URLSearchParams([['code', code]]);
|
||||
if (this.type === AuthProviderType.githubEnterprise) {
|
||||
body.append('github_enterprise', this.baseUri.toString(true));
|
||||
body.append('redirect_uri', await this.getRedirectEndpoint());
|
||||
}
|
||||
const result = await fetch(endpointUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
@ -448,7 +470,7 @@ export class GitHubServer implements IGitHubServer {
|
|||
'Content-Length': body.toString()
|
||||
|
||||
},
|
||||
body
|
||||
body: body.toString()
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
|
@ -464,12 +486,52 @@ export class GitHubServer implements IGitHubServer {
|
|||
}
|
||||
|
||||
private getServerUri(path: string = '') {
|
||||
const apiUri = vscode.Uri.parse('https://api.github.com');
|
||||
return vscode.Uri.parse(`${apiUri.scheme}://${apiUri.authority}${path}`);
|
||||
if (this.type === AuthProviderType.github) {
|
||||
return vscode.Uri.parse('https://api.github.com').with({ path });
|
||||
}
|
||||
// GHES
|
||||
const apiUri = vscode.Uri.parse(vscode.workspace.getConfiguration('github-enterprise').get<string>('uri') || '', true);
|
||||
return vscode.Uri.parse(`${apiUri.scheme}://${apiUri.authority}/api/v3${path}`);
|
||||
}
|
||||
|
||||
public getUserInfo(token: string): Promise<{ id: string; accountName: string }> {
|
||||
return getUserInfo(token, this.getServerUri('/user'), this._logger);
|
||||
public async getUserInfo(token: string): Promise<{ id: string; accountName: string }> {
|
||||
let result: Response;
|
||||
try {
|
||||
this._logger.info('Getting user info...');
|
||||
result = await fetch(this.getServerUri('/user').toString(), {
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
'User-Agent': 'Visual-Studio-Code'
|
||||
}
|
||||
});
|
||||
} catch (ex) {
|
||||
this._logger.error(ex.message);
|
||||
throw new Error(NETWORK_ERROR);
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
try {
|
||||
const json = await result.json();
|
||||
this._logger.info('Got account info!');
|
||||
return { id: json.id, accountName: json.login };
|
||||
} catch (e) {
|
||||
this._logger.error(`Unexpected error parsing response from GitHub: ${e.message ?? e}`);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
// either display the response message or the http status text
|
||||
let errorMessage = result.statusText;
|
||||
try {
|
||||
const json = await result.json();
|
||||
if (json.message) {
|
||||
errorMessage = json.message;
|
||||
}
|
||||
} catch (err) {
|
||||
// noop
|
||||
}
|
||||
this._logger.error(`Getting account info failed: ${errorMessage}`);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public async sendAdditionalTelemetryInfo(token: string): Promise<void> {
|
||||
|
@ -482,6 +544,15 @@ export class GitHubServer implements IGitHubServer {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.type === AuthProviderType.github) {
|
||||
return await this.checkEduDetails(token);
|
||||
}
|
||||
|
||||
// GHES
|
||||
await this.checkEnterpriseVersion(token);
|
||||
}
|
||||
|
||||
private async checkEduDetails(token: string): Promise<void> {
|
||||
try {
|
||||
const result = await fetch('https://education.github.com/api/user', {
|
||||
headers: {
|
||||
|
@ -513,7 +584,7 @@ export class GitHubServer implements IGitHubServer {
|
|||
}
|
||||
}
|
||||
|
||||
public async checkEnterpriseVersion(token: string): Promise<void> {
|
||||
private async checkEnterpriseVersion(token: string): Promise<void> {
|
||||
try {
|
||||
|
||||
const result = await fetch(this.getServerUri('/meta').toString(), {
|
||||
|
@ -543,75 +614,3 @@ export class GitHubServer implements IGitHubServer {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GitHubEnterpriseServer implements IGitHubServer {
|
||||
friendlyName = 'GitHub Enterprise';
|
||||
type = AuthProviderType.githubEnterprise;
|
||||
|
||||
constructor(private readonly _logger: Log, private readonly telemetryReporter: ExperimentationTelemetry) { }
|
||||
|
||||
dispose() { }
|
||||
|
||||
public async login(scopes: string): Promise<string> {
|
||||
this._logger.info(`Logging in for the following scopes: ${scopes}`);
|
||||
|
||||
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 getScopes(token, this.getServerUri('/'), this._logger); // 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 does not match the requested scopes: ${scopes}`);
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
private getServerUri(path: string = '') {
|
||||
const apiUri = vscode.Uri.parse(vscode.workspace.getConfiguration('github-enterprise').get<string>('uri') || '', true);
|
||||
return vscode.Uri.parse(`${apiUri.scheme}://${apiUri.authority}/api/v3${path}`);
|
||||
}
|
||||
|
||||
public async getUserInfo(token: string): Promise<{ id: string; accountName: string }> {
|
||||
return getUserInfo(token, this.getServerUri('/user'), this._logger);
|
||||
}
|
||||
|
||||
public async sendAdditionalTelemetryInfo(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" : {
|
||||
"owner": "TylerLeonhardt",
|
||||
"version": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
|
||||
}
|
||||
*/
|
||||
this.telemetryReporter.sendTelemetryEvent('ghe-session', {
|
||||
version: json.installed_version
|
||||
});
|
||||
} catch {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue