diff --git a/extensions/github-authentication/package.json b/extensions/github-authentication/package.json index d55e8dcfd03d1..2d2e90fe51dae 100644 --- a/extensions/github-authentication/package.json +++ b/extensions/github-authentication/package.json @@ -43,7 +43,11 @@ "properties": { "github-enterprise.uri": { "type": "string", - "description": "GitHub Enterprise Server URI" + "description": "GitHub Enterprise Server URI. Can't be used along with github-enterprise.sso-id" + }, + "github-enterprise.sso-id": { + "type": "string", + "description": "GitHub Enterprise Name. Can't be used along with github-enterprise.uri" } } } diff --git a/extensions/github-authentication/src/common/env.ts b/extensions/github-authentication/src/common/env.ts index ebc474936aa40..a7e158cb6d2e2 100644 --- a/extensions/github-authentication/src/common/env.ts +++ b/extensions/github-authentication/src/common/env.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { Uri } from 'vscode'; -import { AuthProviderType } from '../github'; +import { AuthProviderType, EnterpriseSettings } from '../github'; +import { GitHubTarget } from '../flows'; const VALID_DESKTOP_CALLBACK_SCHEMES = [ 'vscode', @@ -26,13 +27,26 @@ export function isSupportedClient(uri: Uri): boolean { ); } -export function isSupportedTarget(type: AuthProviderType, gheUri?: Uri): boolean { +export function isSupportedTarget(type: AuthProviderType, enterpriseSettings?: EnterpriseSettings): boolean { return ( - type === AuthProviderType.github || - isHostedGitHubEnterprise(gheUri!) + type === AuthProviderType.github || enterpriseSettings?.ssoId !== undefined || + isHostedGitHubEnterprise(enterpriseSettings?.uri!) ); } export function isHostedGitHubEnterprise(uri: Uri): boolean { return /\.ghe\.com$/.test(uri.authority); } + +export function getTarget(enterpriseSettings?: EnterpriseSettings): GitHubTarget { + if (!enterpriseSettings) { + return GitHubTarget.DotCom; + } + + if (enterpriseSettings.uri) { + return isHostedGitHubEnterprise(enterpriseSettings.uri!) ? GitHubTarget.HostedEnterprise : GitHubTarget.Enterprise; + } + + return GitHubTarget.EnterpriseSSO; + +} diff --git a/extensions/github-authentication/src/extension.ts b/extensions/github-authentication/src/extension.ts index 62f69f85b5637..12557c8f8bace 100644 --- a/extensions/github-authentication/src/extension.ts +++ b/extensions/github-authentication/src/extension.ts @@ -4,24 +4,55 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { GitHubAuthenticationProvider, UriEventHandler } from './github'; +import { EnterpriseSettings, GitHubAuthenticationProvider, UriEventHandler } from './github'; -function initGHES(context: vscode.ExtensionContext, uriHandler: UriEventHandler) { - const settingValue = vscode.workspace.getConfiguration().get('github-enterprise.uri'); - if (!settingValue) { +function areEnterpriseSettingsValid(enterpriseUri?: string, enterpriseSsoId?: string): { valid: boolean; error?: string } { + if (enterpriseUri && enterpriseSsoId) { + return { valid: false, error: vscode.l10n.t('Only one of github-enterprise.uri and github-enterprise.sso-id are allowed') }; + } + if (enterpriseUri) { + try { + vscode.Uri.parse(enterpriseUri, true); + } catch (e) { + return { valid: false, error: vscode.l10n.t('GitHub Enterprise Server URI is not a valid URI: {0}', e.message ?? e) }; + } + } else if (enterpriseSsoId && (enterpriseSsoId.includes('/') || enterpriseSsoId.includes('.'))) { + return { valid: false, error: vscode.l10n.t('GitHub Enterprise SSO ID is not valid') }; + } + return { valid: true }; +} + +function setupGHES(context: vscode.ExtensionContext, uriHandler: UriEventHandler) { + const uriSettingKey = 'github-enterprise.uri'; + const ssoIdSettingKey = 'github-enterprise.sso-id'; + const uriValue = vscode.workspace.getConfiguration().get(uriSettingKey); + const ssoIdValue = vscode.workspace.getConfiguration().get(ssoIdSettingKey); + if (!uriValue && !ssoIdValue) { return undefined; } + let authProvider: GitHubAuthenticationProvider | undefined = initGHES(context, uriHandler, uriValue, ssoIdValue); + + context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration(uriSettingKey) || e.affectsConfiguration(ssoIdSettingKey)) { + const uriValue = vscode.workspace.getConfiguration().get(uriSettingKey); + const nameValue = vscode.workspace.getConfiguration().get(ssoIdSettingKey); + if (uriValue || nameValue) { + authProvider?.dispose(); + authProvider = initGHES(context, uriHandler, uriValue, nameValue); + } + } + })); +} - // validate user value - let uri: vscode.Uri; - try { - uri = vscode.Uri.parse(settingValue, true); - } catch (e) { - vscode.window.showErrorMessage(vscode.l10n.t('GitHub Enterprise Server URI is not a valid URI: {0}', e.message ?? e)); +function initGHES(context: vscode.ExtensionContext, uriHandler: UriEventHandler, uri?: string, ssoId?: string) { + const { valid, error } = areEnterpriseSettingsValid(uri, ssoId); + if (!valid) { + vscode.window.showErrorMessage(error!); return; } - const githubEnterpriseAuthProvider = new GitHubAuthenticationProvider(context, uriHandler, uri); + const enterpriseSettings = new EnterpriseSettings(uri, ssoId); + const githubEnterpriseAuthProvider = new GitHubAuthenticationProvider(context, uriHandler, enterpriseSettings); context.subscriptions.push(githubEnterpriseAuthProvider); return githubEnterpriseAuthProvider; } @@ -30,17 +61,6 @@ export function activate(context: vscode.ExtensionContext) { const uriHandler = new UriEventHandler(); context.subscriptions.push(uriHandler); context.subscriptions.push(vscode.window.registerUriHandler(uriHandler)); - context.subscriptions.push(new GitHubAuthenticationProvider(context, uriHandler)); - - let githubEnterpriseAuthProvider: GitHubAuthenticationProvider | undefined = initGHES(context, uriHandler); - - context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(async e => { - if (e.affectsConfiguration('github-enterprise.uri')) { - if (vscode.workspace.getConfiguration().get('github-enterprise.uri')) { - githubEnterpriseAuthProvider?.dispose(); - githubEnterpriseAuthProvider = initGHES(context, uriHandler); - } - } - })); + setupGHES(context, uriHandler); } diff --git a/extensions/github-authentication/src/flows.ts b/extensions/github-authentication/src/flows.ts index 7498a2b22025a..4dbc115509cf2 100644 --- a/extensions/github-authentication/src/flows.ts +++ b/extensions/github-authentication/src/flows.ts @@ -7,12 +7,13 @@ import * as path from 'path'; import { ProgressLocation, Uri, commands, env, l10n, window } from 'vscode'; import { Log } from './common/logger'; import { Config } from './config'; -import { UriEventHandler } from './github'; +import { EnterpriseSettings, UriEventHandler } from './github'; import { fetching } from './node/fetch'; import { LoopbackAuthServer } from './node/authServer'; import { promiseFromEvent } from './common/utils'; import { isHostedGitHubEnterprise } from './common/env'; import { NETWORK_ERROR, TIMED_OUT_ERROR, USER_CANCELLATION_ERROR } from './common/errors'; +import { URLSearchParams } from 'url'; interface IGitHubDeviceCodeResponse { device_code: string; @@ -28,6 +29,8 @@ interface IFlowOptions { readonly supportsGitHubEnterpriseServer: boolean; // A GitHub Enterprise Server that is hosted by GitHub for an organization readonly supportsHostedGitHubEnterprise: boolean; + // A Github Enterprise Server that is hosted by Github but does not follow the .ghe.com domain format + readonly supportsGithubEnterpriseSSO: boolean; // Runtimes - there are constraints on which runtimes support which flows readonly supportsWebWorkerExtensionHost: boolean; @@ -44,7 +47,8 @@ interface IFlowOptions { export const enum GitHubTarget { DotCom, Enterprise, - HostedEnterprise + HostedEnterprise, + EnterpriseSSO, } export const enum ExtensionHost { @@ -68,6 +72,7 @@ interface IFlowTriggerOptions { callbackUri: Uri; uriHandler: UriEventHandler; enterpriseUri?: Uri; + enterpriseSettings?: EnterpriseSettings; existingLogin?: string; } @@ -134,6 +139,7 @@ const allFlows: IFlow[] = [ // other flows that work well. supportsGitHubEnterpriseServer: false, supportsHostedGitHubEnterprise: true, + supportsGithubEnterpriseSSO: true, supportsRemoteExtensionHost: true, supportsWebWorkerExtensionHost: true, // exchanging a code for a token requires a client secret @@ -150,8 +156,8 @@ const allFlows: IFlow[] = [ nonce, callbackUri, uriHandler, - enterpriseUri, - existingLogin + existingLogin, + enterpriseSettings, }: IFlowTriggerOptions): Promise { logger.info(`Trying without local server... (${scopes})`); return await window.withProgress({ @@ -164,33 +170,44 @@ const allFlows: IFlow[] = [ cancellable: true }, async (_, token) => { const promise = uriHandler.waitForCode(logger, scopes, nonce, token); - - const searchParams = new URLSearchParams([ + const loginAuthorizeSearchParams = new URLSearchParams([ ['client_id', Config.gitHubClientId], ['redirect_uri', redirectUri.toString(true)], ['scope', scopes], - ['state', encodeURIComponent(callbackUri.toString(true))] + ['state', encodeURIComponent(callbackUri.toString(true))], ]); if (existingLogin) { - searchParams.append('login', existingLogin); + loginAuthorizeSearchParams.append('login', existingLogin); } - - // The extra toString, parse is apparently needed for env.openExternal - // to open the correct URL. - const uri = Uri.parse(baseUri.with({ + const loginAuthorizeUri = baseUri.with({ path: '/login/oauth/authorize', - query: searchParams.toString() - }).toString(true)); - await env.openExternal(uri); + query: loginAuthorizeSearchParams.toString() + }); + let uri = loginAuthorizeUri; + + if (enterpriseSettings?.ssoId !== undefined) { + const searchParams = new URLSearchParams([ + ['return_to', encodeURIComponent(loginAuthorizeUri.toString(true))], + ]); + uri = Uri.parse(baseUri.with({ + path: `/enterprises/${enterpriseSettings.ssoId}/sso`, + query: searchParams.toString() + }).toString(true)); + } + + logger.info(`opening ${uri.toString(true)}`); + // This cast from string to any is needed due to issue described in https://github.com/microsoft/vscode/issues/85930 + await env.openExternal((uri.toString(true))); const code = await promise; + logger.info(`received code`); const proxyEndpoints: { [providerId: string]: string } | undefined = await commands.executeCommand('workbench.getCodeExchangeProxyEndpoints'); const endpointUrl = proxyEndpoints?.github ? Uri.parse(`${proxyEndpoints.github}login/oauth/access_token`) : baseUri.with({ path: '/login/oauth/access_token' }); - const accessToken = await exchangeCodeForToken(logger, endpointUrl, redirectUri, code, enterpriseUri); + const accessToken = await exchangeCodeForToken(logger, endpointUrl, redirectUri, code, enterpriseSettings?.uri); return accessToken; }); } @@ -205,6 +222,7 @@ const allFlows: IFlow[] = [ // other flows that work well. supportsGitHubEnterpriseServer: false, supportsHostedGitHubEnterprise: true, + supportsGithubEnterpriseSSO: false, // Opening a port on the remote side can't be open in the browser on // the client side so this flow won't work in remote extension hosts supportsRemoteExtensionHost: false, @@ -220,8 +238,8 @@ const allFlows: IFlow[] = [ baseUri, redirectUri, logger, - enterpriseUri, - existingLogin + existingLogin, + enterpriseSettings, }: IFlowTriggerOptions): Promise { logger.info(`Trying with local server... (${scopes})`); return await window.withProgress({ @@ -269,7 +287,7 @@ const allFlows: IFlow[] = [ baseUri.with({ path: '/login/oauth/access_token' }), redirectUri, codeToExchange, - enterpriseUri); + enterpriseSettings?.uri); return accessToken; }); } @@ -280,6 +298,7 @@ const allFlows: IFlow[] = [ supportsGitHubDotCom: true, supportsGitHubEnterpriseServer: true, supportsHostedGitHubEnterprise: true, + supportsGithubEnterpriseSSO: true, supportsRemoteExtensionHost: true, // CORS prevents this from working in web workers supportsWebWorkerExtensionHost: false, @@ -287,9 +306,8 @@ const allFlows: IFlow[] = [ supportsSupportedClients: true, supportsUnsupportedClients: true }; - async trigger({ scopes, baseUri, logger }: IFlowTriggerOptions) { + async trigger({ scopes, baseUri, logger, enterpriseSettings }: IFlowTriggerOptions) { logger.info(`Trying device code flow... (${scopes})`); - // Get initial device code const uri = baseUri.with({ path: '/login/device/code', @@ -306,6 +324,7 @@ const allFlows: IFlow[] = [ } const json = await result.json() as IGitHubDeviceCodeResponse; + logger.info(`json: ${json.verification_uri}`); const button = l10n.t('Copy & Continue to GitHub'); const modalResult = await window.showInformationMessage( @@ -321,8 +340,19 @@ const allFlows: IFlow[] = [ await env.clipboard.writeText(json.user_code); - const uriToOpen = await env.asExternalUri(Uri.parse(json.verification_uri)); - await env.openExternal(uriToOpen); + let verificationUri = json.verification_uri; + if (enterpriseSettings?.ssoId !== undefined) { + verificationUri = baseUri.with({ + path: `/enterprises/${enterpriseSettings.ssoId}/sso`, + query: new URLSearchParams([ + ['return_to', encodeURIComponent(verificationUri)], + ]).toString(), + }).toString(true); + } + + const uriToOpen = await env.asExternalUri(Uri.parse(verificationUri)); + // This cast from string to any is needed due to issue described in https://github.com/microsoft/vscode/issues/85930 + await env.openExternal((uriToOpen.toString(true))); return await this.waitForDeviceCodeAccessToken(baseUri, json); } @@ -394,6 +424,7 @@ const allFlows: IFlow[] = [ supportsGitHubDotCom: true, supportsGitHubEnterpriseServer: true, supportsHostedGitHubEnterprise: true, + supportsGithubEnterpriseSSO: false, supportsRemoteExtensionHost: true, supportsWebWorkerExtensionHost: true, supportsNoClientSecret: true, @@ -484,6 +515,9 @@ export function getFlows(query: IFlowQuery) { case GitHubTarget.HostedEnterprise: useFlow &&= flow.options.supportsHostedGitHubEnterprise; break; + case GitHubTarget.EnterpriseSSO: + useFlow &&= flow.options.supportsGithubEnterpriseSSO; + break; } switch (query.extensionHost) { diff --git a/extensions/github-authentication/src/github.ts b/extensions/github-authentication/src/github.ts index 3d73bfb765614..eeb3621f7d531 100644 --- a/extensions/github-authentication/src/github.ts +++ b/extensions/github-authentication/src/github.ts @@ -29,6 +29,16 @@ export enum AuthProviderType { githubEnterprise = 'github-enterprise' } +export class EnterpriseSettings { + uri?: vscode.Uri; + ssoId?: string; + + constructor(uri?: string, ssoId?: string) { + this.uri = uri ? vscode.Uri.parse(uri) : undefined; + this.ssoId = ssoId; + } +} + export class UriEventHandler extends vscode.EventEmitter implements vscode.UriHandler { private readonly _pendingNonces = new Map(); private readonly _codeExchangePromises = new Map; cancel: vscode.EventEmitter }>(); @@ -103,20 +113,16 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid constructor( private readonly context: vscode.ExtensionContext, uriHandler: UriEventHandler, - ghesUri?: vscode.Uri + enterpriseSettings?: EnterpriseSettings ) { const { aiKey } = context.extension.packageJSON as { name: string; version: string; aiKey: string }; this._telemetryReporter = new ExperimentationTelemetry(context, new TelemetryReporter(aiKey)); - - const type = ghesUri ? AuthProviderType.githubEnterprise : AuthProviderType.github; - + const type = enterpriseSettings ? AuthProviderType.githubEnterprise : AuthProviderType.github; this._logger = new Log(type); this._keychain = new Keychain( this.context, - type === AuthProviderType.github - ? `${type}.auth` - : `${ghesUri?.authority}${ghesUri?.path}.ghes.auth`, + this.getServiceId(type, enterpriseSettings), this._logger); this._githubServer = new GitHubServer( @@ -124,7 +130,8 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid this._telemetryReporter, uriHandler, context.extension.extensionKind, - ghesUri); + enterpriseSettings, + ); // Contains the current state of the sessions we have available. this._sessionsPromise = this.readSessions().then((sessions) => { @@ -140,6 +147,18 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid ); } + private getServiceId(type: AuthProviderType, enterpriseSettings?: EnterpriseSettings) { + if (type === AuthProviderType.github) { + return `${type}.auth`; + } + + if (enterpriseSettings?.uri) { + return `${enterpriseSettings.uri?.authority}${enterpriseSettings.uri?.path}.ghes.auth`; + } + + return `${enterpriseSettings?.ssoId}.ghes.auth`; + } + dispose() { this._disposable?.dispose(); } diff --git a/extensions/github-authentication/src/githubServer.ts b/extensions/github-authentication/src/githubServer.ts index af2cf22724f94..ee6305fb7ee00 100644 --- a/extensions/github-authentication/src/githubServer.ts +++ b/extensions/github-authentication/src/githubServer.ts @@ -5,12 +5,12 @@ import * as vscode from 'vscode'; import { ExperimentationTelemetry } from './common/experimentationService'; -import { AuthProviderType, UriEventHandler } from './github'; +import { AuthProviderType, EnterpriseSettings, UriEventHandler } from './github'; import { Log } from './common/logger'; -import { isSupportedClient, isSupportedTarget } from './common/env'; +import { getTarget, isSupportedClient, isSupportedTarget } from './common/env'; import { crypto } from './node/crypto'; import { fetching } from './node/fetch'; -import { ExtensionHost, GitHubTarget, getFlows } from './flows'; +import { ExtensionHost, getFlows } from './flows'; import { CANCELLATION_ERROR, NETWORK_ERROR, USER_CANCELLATION_ERROR } from './common/errors'; import { Config } from './config'; import { base64Encode } from './node/buffer'; @@ -34,22 +34,27 @@ export class GitHubServer implements IGitHubServer { private _redirectEndpoint: string | undefined; + private shouldUseBaseGithub() { + return this._type === AuthProviderType.github || this._enterpriseSettings?.ssoId; + } + constructor( private readonly _logger: Log, private readonly _telemetryReporter: ExperimentationTelemetry, private readonly _uriHandler: UriEventHandler, private readonly _extensionKind: vscode.ExtensionKind, - private readonly _ghesUri?: vscode.Uri + private readonly _enterpriseSettings?: EnterpriseSettings ) { - this._type = _ghesUri ? AuthProviderType.githubEnterprise : AuthProviderType.github; - this.friendlyName = this._type === AuthProviderType.github ? 'GitHub' : _ghesUri?.authority!; + + this._type = _enterpriseSettings ? AuthProviderType.githubEnterprise : AuthProviderType.github; + this.friendlyName = this.shouldUseBaseGithub() ? 'GitHub' : _enterpriseSettings?.uri?.authority!; } get baseUri() { - if (this._type === AuthProviderType.github) { + if (this.shouldUseBaseGithub()) { return vscode.Uri.parse('https://github.com/'); } - return this._ghesUri!; + return this._enterpriseSettings?.uri!; } private async getRedirectEndpoint(): Promise { @@ -111,20 +116,14 @@ export class GitHubServer implements IGitHubServer { const nonce: string = crypto.getRandomValues(new Uint32Array(2)).reduce((prev, curr) => prev += curr.toString(16), ''); const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.github-authentication/did-authenticate?nonce=${encodeURIComponent(nonce)}`)); - const supportedClient = isSupportedClient(callbackUri); - const supportedTarget = isSupportedTarget(this._type, this._ghesUri); - const flows = getFlows({ - target: this._type === AuthProviderType.github - ? GitHubTarget.DotCom - : supportedTarget ? GitHubTarget.HostedEnterprise : GitHubTarget.Enterprise, + target: getTarget(this._enterpriseSettings), extensionHost: typeof navigator === 'undefined' ? this._extensionKind === vscode.ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote : ExtensionHost.WebWorker, - isSupportedClient: supportedClient + isSupportedClient: isSupportedClient(callbackUri) }); - for (const flow of flows) { try { if (flow !== flows[0]) { @@ -137,9 +136,9 @@ export class GitHubServer implements IGitHubServer { baseUri: this.baseUri, logger: this._logger, uriHandler: this._uriHandler, - enterpriseUri: this._ghesUri, redirectUri: vscode.Uri.parse(await this.getRedirectEndpoint()), - existingLogin + existingLogin: existingLogin, + enterpriseSettings: this._enterpriseSettings, }); } catch (e) { userCancelled = this.processLoginError(e); @@ -164,7 +163,7 @@ export class GitHubServer implements IGitHubServer { return; } - if (!isSupportedTarget(this._type, this._ghesUri)) { + if (!isSupportedTarget(this._type, this._enterpriseSettings)) { this._logger.trace('GitHub.com and GitHub hosted GitHub Enterprise are the only options that support deleting tokens on the server. Skipping.'); return; } @@ -204,7 +203,7 @@ export class GitHubServer implements IGitHubServer { private getServerUri(path: string = '') { const apiUri = this.baseUri; // github.com and Hosted GitHub Enterprise instances - if (isSupportedTarget(this._type, this._ghesUri)) { + if (isSupportedTarget(this._type, this._enterpriseSettings)) { return vscode.Uri.parse(`${apiUri.scheme}://api.${apiUri.authority}`).with({ path }); } // GitHub Enterprise Server (aka on-prem) @@ -312,7 +311,7 @@ export class GitHubServer implements IGitHubServer { private async checkEnterpriseVersion(token: string): Promise { try { let version: string; - if (!isSupportedTarget(this._type, this._ghesUri)) { + if (!isSupportedTarget(this._type, this._enterpriseSettings)) { const result = await fetching(this.getServerUri('/meta').toString(), { headers: { Authorization: `token ${token}`,