GHES oauth flow support (#160679)

* GHES oauth flow support

* update server number

* delete testing line
This commit is contained in:
Tyler James Leonhardt 2022-09-14 11:26:02 -07:00 committed by GitHub
parent 41aacfa3fb
commit fc1ef74eb0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 152 additions and 156 deletions

View file

@ -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) => {

View file

@ -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
}
}
}